Доразметка студий: 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:
1 parent
f91f6cb48e
commit
83bab13893
6 files changed
+7488
-3056
No files matched your search
@@ -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}))
|
||||
|
||||
Reference in new issue
Block a user