Доразметка студий: nnm «Перевод: … (Студия)», kinozal из заголовка раздачи

* harvest_core.nnm_part_studio: студия из части строки «Перевод:» nnm
  («Дублированный (Невафильм)», «… - Jaskier»); раньше такие строки шли с
  «(не указана)», и модель училась не извлекать студию даже из
  «Дублированный (Пифагор)». tech-строки со студиями больше не
  выбрасываются, если на странице нет строк «Аудио N» с именами.
* harvest_core.kz_title_rows: kinozal, студии из заголовка «ПМ (LostFilm,
  HDrezka Studio)», которых нет на странице; вход — сегмент как у сервиса,
  строка «Аудио Rus: … | Студия» — в audio_raw.
* tools/relabel_studios.py — то же по готовому labels.csv без пересборки
  страниц; применено к data/labels.csv: со студией 22 061 -> 29 331 строк
  (nnm +3 051, kinozal +4 219 строк в 3 007 релизах); prepare_data:
  39 418 строк, спан студии 24 004 -> 31 214.
* e2e: эталон как aggregate — «тип без студии» убирается, если у типа есть
  студия.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-02 15:20:03 +07:00
1 parent f91f6cb48e
commit 83bab13893
6 files changed
+7488 -3056

No files matched your search

+76 -5
View File
@@ -1460,6 +1460,29 @@ def nnm_studio(name: str) -> tuple[str, str]:
return cleaned, " ".join(x for x in (note, extra) if x).strip()
# слова, которые в «Перевод: <тип> - <студия>» не студия: тип, кратность, язык
NNM_PART_TYPEISH = re.compile(
r"(?i)голос|дубл|закадр|авторск|любител|профессион|русск|оригинал|субтитр|лицензи"
r"|^\W*[xх]?\d+[xх]?\W*$")
def nnm_part_studio(part: str) -> str:
"""Студия из части строки «Перевод:» nnm: «Многоголосый профессиональный - Jaskier»,
«Дублированный (Невафильм)», «Авторский (Живов» -> имя; «Дублированный (Лицензия)»,
«Одноголосый x2», «Двуголосый профессиональный» -> ""."""
if len(part) > 90 or re.search(r"(?i)премьер|\bдата\b", part):
return "" # склеилось соседнее поле описания
m = re.search(r"\s[-–—:]\s*(.+)$", part)
cands = ([m.group(1)] if m else []) + [c for c in re.findall(r"\(([^()]*)\)?", part) if c]
for cand in cands:
studio, _ = clean_studio(cand)
studio, _ = canonical_studio(studio)
if len(studio) >= 2 and not NNM_PART_TYPEISH.search(studio) \
and studio.lower() not in SOURCE_LIKE:
return studio
return ""
def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
post = rec.get("post") or ""
title = rec.get("page_title") or rec.get("title") or ""
@@ -1539,20 +1562,21 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
if ttype in ("неизвестно", "оригинал", "субтитры"):
continue
perevod_types.append(part)
# без имён в скобках студия неизвестна, но тип перевода ценен
# студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
rows.append({
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
"year": year, "quality": quality, "is_series": is_series,
"studio": "(не указана)", "translation_type": ttype,
"studio": nnm_part_studio(part) or "(не указана)", "translation_type": ttype,
"professional": pro, "role_raw": part, "actors": "",
"note": mark_18("", has_18(line)), "age_18": int(has_18(line)),
"codec": "", "channels": "", "khz": "", "kbps": "",
"subtitles": "", "studio_source": "tech", "raw": line,
})
# если построчных «Аудио N» с именами не было — оставляем только типы
if any(r["studio"] != "(не указана)" for r in rows):
rows = [r for r in rows if r["studio"] != "(не указана)"]
# есть построчные «Аудио N» с именами — строки «Перевод:» (tech) не нужны; иначе
# оставляем tech-строки: типы и студии, названные в «Перевод:»
if any(r["studio"] != "(не указана)" and r["studio_source"] == "release" for r in rows):
rows = [r for r in rows if r["studio_source"] == "release" and r["studio"] != "(не указана)"]
# дорожка без имени и без типа («- Русский») ничего не даёт датасету
rows = [r for r in rows
@@ -1715,6 +1739,51 @@ def audio_only_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
return out
def kz_title_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
"""kinozal: студии из заголовка раздачи «… / ПМ (LostFilm, HDrezka Studio), ДБ (X) / …».
На kinozal студии часто названы только в заголовке и в строках «Аудио Rus: … | LostFilm»
(без типа), а «Перевод:» во «Техданных» — без студий. Строка-вход — тот же синтетический
сегмент, что строит сервис из заголовка (src.release.title_segments); строка «Аудио» с этой
студией (если есть) — в audio_raw (позитив для сегментатора).
"""
from src.release import RE_TITLE_TRACK, TITLE_TYPES
def key(x: str) -> str:
return re.sub(r"[\W_]+", "", (x or "").casefold())
title = (rec.get("h1") or rec.get("page_title") or "").split(" :: ")[0]
have = key(" ".join(((r.get("studio") or "") + " " + (r.get("raw") or "")) for r in rows))
lines = [ln for ln in audio_lines_all(text) if not ln["original"] and not ln["commentary"]]
base = rows[0] if rows else {}
info = kz_split_title(title)
out = []
for abbr, names in RE_TITLE_TRACK.findall(title):
for name in re.split(r"\s*[,;]\s*", names.strip()):
studio, note = clean_studio(name)
studio, extra = canonical_studio(studio)
k = key(studio)
if len(k) < 2 or k in have or studio.lower() in SOURCE_LIKE:
continue
kind, pro = translation_type(TITLE_TYPES[abbr])
a = next((ln for ln in lines if k in key(ln["raw"])), None)
out.append({
"source": "kinozal", "release_id": rec.get("id", ""),
"ru_title": base.get("ru_title") or info["ru_title"],
"year": base.get("year") or info["year"], "quality": base.get("quality", ""),
"is_series": base.get("is_series", bool(re.search(r"сезон|серии", title, re.I))),
"studio": studio, "translation_type": kind, "professional": pro,
"role_raw": TITLE_TYPES[abbr], "actors": "",
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), bool(a and a["adult"])),
"age_18": int(bool(a and a["adult"])),
"codec": a["codec"] if a else "", "channels": a["channels"] if a else "", "khz": "",
"kbps": a["kbps"] if a else "", "subtitles": "", "studio_source": "title",
"raw": f"{TITLE_TYPES[abbr]} | {name.strip()}",
"lang": (a["lang"] or "RUS") if a else "RUS", "audio_raw": a["raw"] if a else ""})
have += k
return out
def _with_audio(core, texts):
"""Обёртка парсера: lang/audio_raw у строк; число переводов в rel — по итоговым строкам."""
def parse(rec: dict) -> tuple[list[dict], dict]:
@@ -1725,6 +1794,8 @@ def _with_audio(core, texts):
rows += audio_only_rows(rec, text, rows)
elif core is _kz_parse_core:
rows = kz_audio_only_rows(rec, text)
if core is _kz_parse_core:
rows += kz_title_rows(rec, text, rows)
if rows:
rel["n_translations"], rel["ok"] = len(rows), True
rel["studios"] = " | ".join(sorted({r["studio"] for r in rows}))
+103
View File
@@ -0,0 +1,103 @@
"""Доразметка студий в data/labels.csv без пересборки страниц.
То же, что теперь делает сборщик (tools/harvest/harvest_core.py) при переиндексации,
но по готовому labels.csv и data/raw — минуты вместо полного разбора HTML:
* nnm, строки «Перевод:» (studio_source=tech) с «(не указана)»: студия, названная
в самой части строки («Дублированный (Невафильм)», «… - Jaskier») — nnm_part_studio;
* kinozal: студии из заголовка раздачи «ПМ (LostFilm, HDrezka Studio)», которых нет
среди строк релиза (studio_source=title) — kz_title_rows по data/raw/kinozal/<id>.json.
Ручные релизы (data/manual_ids.txt) не трогаются. Перед записью — копия
data/labels.bak_relabel_<время>.csv. Повторный запуск ничего не добавляет.
.venv\\Scripts\\python tools\\relabel_studios.py # применить
.venv\\Scripts\\python tools\\relabel_studios.py --dry-run # только статистика
"""
from __future__ import annotations
import argparse
import csv
import json
import shutil
import sys
import time
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tools" / "harvest"))
import harvest_core as H # noqa: E402
LABELS = ROOT / "data" / "labels.csv"
RAW_KZ = ROOT / "data" / "raw" / "kinozal"
MANUAL = ROOT / "data" / "manual_ids.txt"
NO_STUDIO = "(не указана)"
def relabel(rows: list[dict], raw_kz: Path = RAW_KZ, manual: set[str] | None = None) -> tuple[list[dict], Counter]:
"""rows -> (новые rows, статистика). Исходные dict меняются на месте."""
manual = manual or set()
stats: Counter = Counter()
for r in rows:
if r["source"] == "nnm" and r.get("studio_source") == "tech" and r["studio"] == NO_STUDIO \
and r["release_id"] not in manual:
studio = H.nnm_part_studio(r.get("role_raw") or "")
if studio:
r["studio"] = studio
stats["nnm: студия из «Перевод:»"] += 1
by_rel: dict[str, list[dict]] = defaultdict(list)
for r in rows:
if r["source"] == "kinozal":
by_rel[r["release_id"]].append(r)
added: list[dict] = []
for rid, rel_rows in by_rel.items():
if rid in manual:
continue
path = raw_kz / f"{rid.removeprefix('kz-')}.json"
if not path.exists():
stats["kinozal: нет raw JSON"] += 1
continue
rec = json.loads(path.read_text(encoding="utf-8"))
rec["id"] = rid
text = "\n".join(x for x in (rec.get("release_tab") or "", rec.get("tech_tab") or "") if x)
new = H.kz_title_rows(rec, text, rel_rows)
if new:
stats["kinozal: релизов со студиями из заголовка"] += 1
stats["kinozal: студий из заголовка"] += len(new)
stats["kinozal: из них со строкой «Аудио»"] += sum(bool(x["audio_raw"]) for x in new)
added += new
return rows + added, stats
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--dry-run", action="store_true", help="только посчитать, файл не менять")
args = ap.parse_args()
with LABELS.open(encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
fields = reader.fieldnames
rows = list(reader)
manual = set(MANUAL.read_text(encoding="utf-8").split()) if MANUAL.exists() else set()
before = sum(r["studio"] != NO_STUDIO for r in rows)
rows, stats = relabel(rows, manual=manual)
after = sum(r["studio"] != NO_STUDIO for r in rows)
for k, v in stats.items():
print(f"{k}: {v}")
print(f"строк: {len(rows)}; со студией: {before} -> {after}")
if args.dry_run:
return
backup = LABELS.with_name(f"labels.bak_relabel_{time.strftime('%Y%m%d_%H%M%S')}.csv")
shutil.copy(LABELS, backup)
with LABELS.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
w.writeheader()
w.writerows(rows)
print(f"записано: {LABELS.relative_to(ROOT)}; копия: {backup.name}")
if __name__ == "__main__":
main()