"""Аудит дорожек в описаниях релизов: data/raw/* против data/labels.csv. Читает текст каждой сохранённой страницы (kinozal: «Техданные» + «Релиз»; nnm / rutracker: пост), находит строки русских/украинских дорожек («Перевод N:», «Озвучка:», «Аудио N: … (MVO, X)» и т. п., без субтитров) и считает: * какие форматы строк встречаются и какие из них не ловят правила разметки (segmenter.RE_AUDIO_TRACK) и запасной путь сервиса (release.codec_segments); * студии из словаря меток, названные в строках дорожек, но отсутствующие в метках релиза (сравнение через release.same_studio — «А. Гаврилов» = «Андрей Гаврилов»); * мусор в колонке studio (каналы, языки, тип-слова, «Лицензия», «по заказу»); * релизы без меток: legacy / ok / есть ли в них строки дорожек. Результат — reports/tracks_audit_numbers.md (цифры и примеры); выводы и план — reports/tracks_audit.md. Чтение raw ~2–3 мин; --cache сохраняет тексты в pickle. .venv\\Scripts\\python tools\\audit_tracks.py [--cache путь.pkl] """ from __future__ import annotations import argparse import csv import json import os import pickle import random import re import sys from collections import Counter, defaultdict from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from src.release import RE_CODEC_TRACK, codec_ok, same_studio # noqa: E402 from src.segmenter import RE_AUDIO_TRACK # noqa: E402 RAW = ROOT / "data" / "raw" LABELS = ROOT / "data" / "labels.csv" OUT = ROOT / "reports" / "tracks_audit_numbers.md" SOURCES = (("kinozal", "kz-"), ("nnm", "nnm-"), ("rutracker", "rt-")) NO_STUDIO = "(не указана)" # строка дорожки: перевод/озвучка или кодек-строка RE_TRANS_LINE = re.compile( r"(?i)^\s*[#№*•\-\s]*(перевод\w*|озвуч\w*|дубляж\w*|автор перевода)\s*(№|#)?\s*[\d\-–,\s]*(\([^)]*\))?\s*[:.]") RE_AUDIO_LINE = re.compile( r"(?i)^\s*[#№*•\-\s]*(аудио\w*|audio\w*|звук\w*|аудиодорожк\w*|дорожк\w*|track\w*)\b") RE_CODEC = re.compile( r"(?i)\b(e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2|dolby)\b|\d\s*(kbps|кбит|kb/s|khz|кгц)") RE_RU = re.compile(r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b|україн") RE_NON_RU = re.compile( r"(?i)english|\beng\b|англ|япон|japan|\bjap\b|казах|kazakh|корей|китай|немец|француз|итальян|испан|оригинал|original") RE_TYPE = re.compile( r"(?i)\b(MVO|DVO|AVO|DUB|VO)\b|дубл|многоголос|двухголос|двуголос|одноголос|закадр|авторск|любительск|профессиональн") RE_SUBS = re.compile(r"(?i)субтитр") # не студия: тех. параметры, языки, тип-слова, источник дорожки JUNK = re.compile( r"(?i)^(\d+\s*ch|.*\b(ac-?3|dts|aac|kbps|kb/s|ch)\b.*|l,.*|.*русск\w*.*|английск\w*|украинск\w*|французск\w*" r"|испанск\w*|немецк\w*|итальянск\w*|японск\w*|казахск\w*|субтитр\w*|ukr|rus|eng|eur|cee|vhs|tv|ts" r"|дубляж|полное дублирование|дублированный|дву(х)?голосый|многоголосый|одноголосый|закадровый" r"|профессиональный|любительский|авторский|студийный|неизвестн\w*|оригинал\w*|original" r"|.*лицензи\w*.*|звук.*|blu-?ray.*|bd.*|itunes.*|netflix|amazon|web-?dl|в составе контейнера" r"|.*ненормативн\w*.*|18\+)$") # виды мусора для отчёта (первое совпадение) NOISE_KINDS = ( ("каналы/кодек", r"(?i)\b(\d+\s*ch|ac-?3|dts|aac|kbps|kb/s)\b|^l,"), ("язык вместо студии", r"(?i)^(русск|английск|украинск|французск|испанск|казахск)\w*$"), ("язык-префикс «Русский, - X»", r"(?i)^(русск|украинск|english)\w*\s*,"), ("тип-слово", r"(?i)^(дубляж|полное дублирование|дву(х)?голосый|многоголосый|одноголосый|закадровый)$"), ("источник: лицензия/Blu-ray/iTunes/Netflix", r"(?i)лицензи|blu-?ray|^bd\b|itunes|netflix|amazon"), ("«X по заказу Y»", r"(?i)по заказу"), ("субтитры", r"(?i)субтитр"), ("неру дорожка", r"(?i)казах|qazaq|kazakh|английск|english"), ) def nid(rid: str) -> str: return re.sub(r"^(kz|nnm|rt)-", "", rid) def norm(s: str) -> str: return re.sub(r"\s+", " ", s).strip().lower().replace("ё", "е") def load_texts(cache: Path | None) -> dict: """{(source, id): (title, text, legacy)}; kinozal — h1 и вкладки, nnm/rt — пост.""" if cache and cache.exists(): return pickle.loads(cache.read_bytes()) out = {} for src, _ in SOURCES: d = RAW / src for n in sorted(os.listdir(d), key=lambda n: "legacy" in n): if not n.endswith(".json"): continue rid = n.split(".")[0] if (src, rid) in out: continue try: rec = json.loads((d / n).read_text(encoding="utf-8")) except (OSError, ValueError): continue if src == "kinozal": title = rec.get("h1") or "" text = "\n".join(x for x in (rec.get("tech_tab"), rec.get("release_tab")) if x) else: title, text = rec.get("title") or "", rec.get("post") or "" out[(src, rid)] = (title, text, "legacy" in n, rec.get("ok", True)) if cache: cache.write_bytes(pickle.dumps(out)) return out def track_lines(text: str) -> list[tuple[str, str]]: """[(вид, строка)] строк русских/украинских дорожек; вид: trans | audio.""" out = [] for line in text.splitlines(): line = line.strip() if not line or len(line) > 400 or RE_SUBS.search(line) and not RE_TYPE.search(line): continue if RE_TRANS_LINE.match(line): if RE_NON_RU.search(line.split(":", 1)[0]) and not RE_RU.search(line): continue out.append(("trans", line)) elif (RE_AUDIO_LINE.match(line) or RE_RU.match(line)) and RE_CODEC.search(line): if RE_RU.search(line) and (RE_TYPE.search(line) or "|" in line or "(" in line): out.append(("audio", line)) return out def line_prefix(line: str) -> str: p = re.match(r"\s*\W*\w*[^:]{0,14}:?", line).group(0).strip().lower() return re.sub(r"\s+", " ", re.sub(r"\d+", "N", p)) def studio_vocab(rows: list[dict]) -> tuple[re.Pattern, dict]: cnt = Counter(r["studio"].strip() for r in rows if r["studio"] not in ("", NO_STUDIO)) vocab = [s for s, c in cnt.items() if c >= 5 and len(s) >= 3 and not JUNK.match(s)] vocab.sort(key=len, reverse=True) rx = re.compile(r"(?i)(? dict: lab = defaultdict(list) for r in rows: lab[(r["source"], nid(r["release_id"]))].append(r) voc_rx, canon = studio_vocab(rows) R = defaultdict(lambda: defaultdict(Counter)) ex = defaultdict(list) for (src, rid), (title, text, legacy, ok) in texts.items(): L = lab.get((src, rid), []) lines = track_lines(text) rel = R[src]["releases"] rel["всего"] += 1 if not L: rel["без меток"] += 1 key = ("legacy" if legacy else "обычный") + (", ok=False" if not ok else "") + \ (", есть строки дорожек" if lines else ", нет строк дорожек") R[src]["без меток"][key] += 1 if lines and len(ex[(src, "unlab")]) < 3000: ex[(src, "unlab")].append((rid, title[:80], lines[0][1][:140])) labs = [r["studio"] for r in L if r["studio"] not in ("", NO_STUDIO) and not JUNK.match(r["studio"].strip())] missing: dict[str, tuple[str, str]] = {} for kind, line in lines: R[src]["строки"][kind] += 1 if kind == "audio": a = bool(RE_AUDIO_TRACK.match(line)) b = bool(RE_CODEC_TRACK.match(line) and codec_ok(line)) R[src]["кодек-строки"][("RE_AUDIO_TRACK " if a else "мимо RE_AUDIO_TRACK ") + ("| codec_segments" if b else "| мимо codec_segments")] += 1 if not a or not b: miss = ("seg " if not a else "") + ("codec" if not b else "") R[src]["форматы мимо: " + miss.strip()][line_prefix(line)] += 1 if len(ex[(src, "fmt:" + miss.strip())]) < 3000: ex[(src, "fmt:" + miss.strip())].append((rid, line[:160])) if not L: continue for m in voc_rx.finditer(line): st = canon.get(norm(m.group(1)), m.group(1)) if any(same_studio(st, x) for x in labs) or any(same_studio(st, x) for x, _ in missing.values()): continue missing[norm(st)] = (st, kind) if len(ex[(src, "miss:" + kind)]) < 5000: ex[(src, "miss:" + kind)].append((rid, st, line[:160])) if L: rel["с метками"] += 1 rel["с метками, есть недостающая студия"] += bool(missing) for st, kind in missing.values(): R[src]["недостающие студии: вид строки"][kind] += 1 R[src]["недостающие студии: топ"][st] += 1 # недостающие студии в релизе, где у меток вообще нет студий if missing and not labs: rel["с метками без студий, студия есть в кодек-строке"] += 1 full = norm(title + "\n" + text) for r in L: st = r["studio"].strip() if st in ("", NO_STUDIO) or JUNK.match(st): continue R[src]["студии меток"]["всего"] += 1 words = [w for w in re.findall(r"\w{4,}", norm(st))] if norm(st) not in full and not any(w in full for w in words): R[src]["студии меток"]["нет в тексте/заголовке"] += 1 R[src]["студии меток: нет в тексте, studio_source"][r["studio_source"]] += 1 if len(ex[(src, "ghost")]) < 3000: ex[(src, "ghost")].append((rid, st, r["raw"][:140])) for r in rows: st = r["studio"].strip() for k, rx in NOISE_KINDS: if re.search(rx, st): R[r["source"]]["мусор в studio меток"][k] += 1 if len(ex[(r["source"], "noise:" + k)]) < 500: ex[(r["source"], "noise:" + k)].append((r["release_id"], st, r["raw"][:120])) break return {"stats": R, "examples": ex} def write_md(res: dict, path: Path) -> None: R, ex = res["stats"], res["examples"] rnd = random.Random(7) out = ["# Аудит дорожек в raw-страницах (цифры)", "", "Сгенерировано `tools/audit_tracks.py`; выводы и план — `reports/tracks_audit.md`.", ""] for src, _ in SOURCES: out += [f"## {src}", ""] for block, cnt in R[src].items(): top = cnt.most_common(25) out.append(f"**{block}**: " + "; ".join(f"{k} — {v}" for k, v in top)) out.append("") for key in sorted(k for k in ex if k[0] == src): v = ex[key] out.append(f"
примеры {key[1]} ({len(v)})\n") for t in rnd.sample(v, min(15, len(v))): out.append("- " + " · ".join(str(x).replace("|", "\\|") for x in t)) out += ["", "
", ""] path.write_text("\n".join(out), encoding="utf-8") def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--cache", type=Path, help="pickle с текстами raw (создаётся, если нет)") ap.add_argument("--out", type=Path, default=OUT) args = ap.parse_args() sys.stdout.reconfigure(encoding="utf-8") with LABELS.open(encoding="utf-8-sig", newline="") as f: rows = list(csv.DictReader(f)) res = audit(load_texts(args.cache), rows) args.out.parent.mkdir(exist_ok=True) write_md(res, args.out) for src, _ in SOURCES: print(f"== {src}") for block, cnt in res["stats"][src].items(): print(f" {block}: " + "; ".join(f"{k} — {v}" for k, v in cnt.most_common(12))) print(f"записано: {args.out.relative_to(ROOT)}") if __name__ == "__main__": main()