Доразметка студий: 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}))