"""Аудит покрытия: все страницы data/raw против data/labels.csv. Для каждой сохранённой страницы (35 тыс.) считает, что на ней есть строки дорожек озвучки (независимыми от парсеров правилами), и что из этого попало в датасет: * страницы без меток: есть ли на них дорожки вообще (русский оригинал/«не требуется»/немой — не дорожка) и какие форматы строк не разобраны; * страницы с метками: строки дорожек, которым не соответствует ни одна строка датасета (пропущенные дорожки/студии); * кластеры форматов (по префиксу строки) для правки парсеров. Результат: reports/coverage_audit.md (цифры, кластеры, примеры), data/raw/_coverage_pages.csv (по странице) и data/raw/_coverage_lines.csv (непокрытые строки, до 20 тыс.). .venv\\Scripts\\python tools\\coverage_audit.py [--cache data/raw/_coverage.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] RAW = ROOT / "data" / "raw" LABELS = ROOT / "data" / "labels.csv" OUT_MD = ROOT / "reports" / "coverage_audit.md" OUT_PAGES = RAW / "_coverage_pages.csv" OUT_LINES = RAW / "_coverage_lines.csv" SOURCES = ("kinozal", "nnm", "rutracker") NO_STUDIO = "(не указана)" # ---------------------------------------------------------------- детектор строк # латинские/кириллические двойники в аббревиатурах: «АVO», «МVO», «DVO» _HOMO = str.maketrans("АВЕКМНОРСТУХ", "ABEKMHOPCTYX") def up(s: str) -> str: return s.translate(_HOMO).upper() RE_KW = re.compile( r"(?i)^[\s\W]{0,6}(?:перевод\w*|озвуч\w*|озвучиван\w*|дубляж\w*|дубль\w*|звук\w*" r"|аудио\w*|audio\w*|аудиодорожк\w*|дорожк\w*|track\w*)" r"\s*(?:№|#)?\s*(?:\(?\d+\)?|\d+\s*[-–]\s*\d+|rus|ukr|jap|eng|pic|ru|uk)?\s*[:.]") RE_PREFIX = re.compile( r"(?i)^[\s\W]{0,6}(?:перевод\w*|озвуч\w*|озвучиван\w*|дубляж\w*|дубль\w*|звук\w*" r"|аудио\w*|audio\w*|аудиодорожк\w*|дорожк\w*|track\w*)" r"\s*(?:№|#)?\s*(?:\(?\d+\)?|\d+\s*[-–]\s*\d+|rus|ukr|jap|eng|pic|ru|uk)?\s*[:.]\s*") RE_TYPE = re.compile( r"(?i)(?:\bMVO\b|\bDVO\b|\bAVO\b|\bDUB\b|\bVO\b|дубл|многоголос|двухголос|двуголос" r"|одноголос|закадр|авторск|любительск|профессиональн|озвучен|перевод\b)") RE_NO_DUB = re.compile( r"(?i)не\s*требу|немо[йе]|без\s*слов|без\s*диалог|нет\s*диалог|оригинал\w*\s*(?:дорожк|звук|аудио)?\s*[:.]?\s*$" r"|^оригинал|субтитр|без\s*перевода|отсутств|не\s*озвуч|только\s*(?:музык|звук)|музыкальное\s*сопровожд") RE_RU = re.compile(r"(?i)русск|russian|\brus\b|\bru\b|украин|ukrain|\bukr\b|україн") RE_LANG = re.compile( r"(?i)\b(русск\w*|russian|english|англ\w*|украин\w*|ukrainian|японск\w*|japanese" r"|китайск\w*|корейск\w*|немецк\w*|французск\w*|итальянск\w*|испанск\w*|казахск\w*" r"|оригинал\w*|original)\b") RE_CODEC = re.compile( r"(?i)\b(?:e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2|dolby|ddp?)\b" r"|\d\s*(?:kbps|кбит|kb/s|khz|кгц)") RE_PIPE = re.compile(r"\|([^|]{2,60})\|") RE_PAREN = re.compile(r"\(([^()]{2,80})\)") def track_kind(line: str) -> str | None: """'dub' — строка дорожки озвучки, 'other' — аудио без признаков озвучки, None — не строка.""" s = line.strip() if not s or len(s) > 300: return None m = RE_KW.match(s) if not m: # строка без префикса: «Russian (Dolby AC3, 6 ch) … | LostFilm» (nnm, rutracker) if RE_CODEC.search(s) and (RE_RU.search(s) or RE_PIPE.search(s)) and len(s) < 220: pass else: return None body = s[m.end():] if m else s if RE_NO_DUB.search(body) or (m and RE_NO_DUB.search(s[m.start():m.end() + 40])): return None if RE_TYPE.search(up(body)): return "dub" if RE_PIPE.search(body) or re.search(r"\([^()]*(?:mvo|dvo|avo|dub|дубл)", up(body)): return "dub" if RE_RU.search(body) and (RE_CODEC.search(s) or m and "перевод" in up(s[:30]).lower()): return "other" # русская дорожка без типа: русский оригинал или неизвестный дубляж return None def norm(s: str) -> str: return re.sub(r"\s+", " ", (s or "").replace("\u00a0", " ")).strip().casefold() def line_prefix(line: str) -> str: s = norm(line) s = re.sub(r"\d+", "N", s) m = re.match(r"^(.{0,26}?[:.])", s) return (m.group(1) if m else s[:26]).strip() def page_text(src: str, rec: dict) -> str: if src == "kinozal": return "\n".join(x for x in (rec.get("h1"), rec.get("tech_tab"), rec.get("release_tab")) if x) return rec.get("post") or "" # ---------------------------------------------------------------- загрузка def load_pages(cache: Path | None) -> list[tuple[str, str, str, bool, bool]]: if cache and cache.exists(): return pickle.loads(cache.read_bytes()) out = [] for src in SOURCES: d = RAW / src for name in sorted(os.listdir(d)): if not name.endswith(".json"): continue rid = name.split(".")[0] try: rec = json.loads((d / name).read_text(encoding="utf-8")) except (OSError, ValueError): continue out.append((src, rid, page_text(src, rec), "legacy" in name, bool(rec.get("ok", True)))) if cache: cache.write_bytes(pickle.dumps(out)) return out def row_strings(rows: list[dict]) -> list[str]: out = [] for r in rows: for k in ("raw", "audio_raw", "role_raw"): v = norm(r.get(k) or "") if len(v) >= 4: out.append(v) return out def covered(line: str, rstr: list[str]) -> bool: l = norm(line) return any(r in l or (len(l) >= 12 and l in r) for r in rstr) def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--cache", type=Path) args = ap.parse_args() sys.stdout.reconfigure(encoding="utf-8") pages = load_pages(args.cache) rows = list(csv.DictReader(LABELS.open(encoding="utf-8-sig", newline=""))) by_rel = defaultdict(list) for r in rows: by_rel[(r["source"], re.sub(r"^(kz|nnm|rt)-", "", r["release_id"]))].append(r) per = defaultdict(Counter) fmt_unlab = defaultdict(Counter) fmt_part = defaultdict(Counter) per_rel = defaultdict(Counter) gaps = [] # (src, rid, kind, line, why) examples = defaultdict(list) page_csv = [] rnd = random.Random(11) for src, rid, text, legacy, ok in pages: L = by_rel.get((src, rid), []) lines = [] for raw in text.splitlines(): kind = track_kind(raw) if kind: lines.append((kind, re.sub(r"\s+", " ", raw).strip())) dub = [l for k, l in lines if k == "dub"] stat = per[src] stat["страниц"] += 1 stat["legacy" if legacy else "обычных"] += 1 if not ok: stat["ok=False"] += 1 page_csv.append({"source": src, "release_id": rid, "has_labels": int(bool(L)), "n_rows": len(L), "n_lines": len(lines), "n_dub_lines": len(dub)}) if not L: stat["без меток"] += 1 if not lines: stat["без меток: нет аудио-строк"] += 1 elif not dub: stat["без меток: только русская дорожка/не озвучка"] += 1 else: stat["без меток: ЕСТЬ дорожка озвучки"] += 1 for l in dub: key = line_prefix(l) fmt_unlab[src][key] += 1 if len(examples[(src, "unlabeled", key)]) < 20: examples[(src, "unlabeled", key)].append((rid, l)) gaps.append((src, rid, "no_labels", l, "есть дорожка, строки датасета нет")) continue stat["с метками"] += 1 rs = row_strings(L) unmatched = [l for l in dub if not covered(l, rs)] page_csv[-1]["n_unmatched"] = len(unmatched) if unmatched: per_rel[src]["релизов с непокрытыми строками"] += 1 per_rel[src]["непокрытых строк"] += len(unmatched) for l in unmatched: key = line_prefix(l) fmt_part[src][key] += 1 if len(examples[(src, "covered", key)]) < 20: examples[(src, "covered", key)].append((rid, l)) gaps.append((src, rid, "partial", l, "строка не попала в метки")) stat["строк дорожек найдено"] += len(dub) stat["строк покрыто"] += len(dub) - len(unmatched) # ---------------------------------------------------------------- отчёт out = ["# Покрытие страниц data/raw датасетом", "", "Сгенерировано `tools/coverage_audit.py`. Строка «дорожки» — независимый от парсеров", "детектор (префикс «Перевод/Озвучка/Аудио…», тип озвучки или блок `|студия|`);", "«покрыта» — для строки есть строка `labels.csv` этого релиза (`raw`/`audio_raw`/`role_raw`).", ""] for src in SOURCES: stat = per[src] out += [f"## {src}", ""] for k in ("страниц", "обычных", "legacy", "ok=False", "без меток", "без меток: нет аудио-строк", "без меток: только русская дорожка/не озвучка", "без меток: ЕСТЬ дорожка озвучки", "с метками", "строк дорожек найдено", "строк покрыто"): if stat[k]: out.append(f"- {k}: {stat[k]}") if stat["с метками"]: found = stat["строк дорожек найдено"] out.append(f"- покрытие строк: {stat['строк покрыто'] / max(found, 1):.1%}") out.append("") for key in sorted(fmt_unlab[src], key=fmt_unlab[src].get, reverse=True)[:12]: out.append(f"**без меток · {key}** — {fmt_unlab[src][key]}") for rid, l in examples[(src, "unlabeled", key)][:3]: out.append(f" - {rid}: {l[:150]}") out.append("") for key in sorted(fmt_part[src], key=fmt_part[src].get, reverse=True)[:12]: out.append(f"**не покрыто · {key}** — {fmt_part[src][key]}") for rid, l in examples[(src, "covered", key)][:3]: out.append(f" - {rid}: {l[:150]}") out.append("") OUT_MD.parent.mkdir(exist_ok=True) OUT_MD.write_text("\n".join(out), encoding="utf-8") with OUT_PAGES.open("w", encoding="utf-8-sig", newline="") as f: w = csv.DictWriter(f, fieldnames=["source", "release_id", "has_labels", "n_rows", "n_lines", "n_dub_lines", "n_unmatched"]) w.writeheader() for r in page_csv: w.writerow({**{"n_unmatched": ""}, **r}) with OUT_LINES.open("w", encoding="utf-8-sig", newline="") as f: w = csv.writer(f) w.writerow(["source", "release_id", "kind", "line", "why"]) w.writerows(gaps[:20000]) print(f"страниц {len(pages)}, непокрытых строк {len(gaps)}") for src in SOURCES: s = per[src] print(f"== {src}: страниц {s['страниц']}, без меток {s['без меток']} " f"(есть дорожка {s['без меток: ЕСТЬ дорожка озвучки']}), " f"с метками {s['с метками']}, строк {s['строк дорожек найдено']}, " f"непокрыто {s['строк дорожек найдено'] - s['строк покрыто']}") print(f"отчёт: {OUT_MD.relative_to(ROOT)}") if __name__ == "__main__": main()