Датасет и обучение: класс «люб. дубляж», раскладка многотиповых строк, язык из «Аудио», строки из «Аудио», потолок повторов, веса классов
docker / build (push) Successful in 1m40s

- harvest_core: дубляж — тип, а не часть имени студии; split_multi_rows; audio_only_rows; kinozal сопоставляет все аудиодорожки
- prepare_data: --max-dup, отсев остатков многотиповых строк
- train: веса классов (корень из обратной частоты, потолок 4), --no-class-weights
- исправлены регулярки с \b, записанные как символ BS (predict.py, audio18.py)
- PARSE_VERSION 3: кэш разбора сбрасывается
- docs/dataset.md, docs/api-reference.md обновлены

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-29 18:13:07 +07:00
1 parent e648415719
commit 05537ac9e9
10 files changed
+233 -23

No files matched your search

+1 -1
View File
@@ -1028,7 +1028,7 @@ def cmd_build(rewrite: bool = False, relabel: bool = False, runner: "Runner | No
# Версия разбора: поднимите при правке парсеров (harvest_core, html_to_text) — тогда
# кэш «уже разобрано» сбросится сам и данные пересоберутся один раз.
PARSE_VERSION = 2
PARSE_VERSION = 3
def load_json(path: Path) -> dict:
+100 -6
View File
@@ -48,6 +48,7 @@ _REPO = Path(__file__).resolve().parents[2]
if str(_REPO) not in sys.path:
sys.path.insert(0, str(_REPO))
from src.audio18 import audio_counts, audio_lines # noqa: E402
from src.html_extract import RE_LINE_PREFIX, RE_TYPE_HINT, _split_entries # noqa: E402
from src.trackinfo import audio_lines_all, lang_of_text, pick_for_row # noqa: E402
ROOT = Path(__file__).resolve().parent
@@ -1029,7 +1030,9 @@ STUDIO_NOISE = re.compile(
r"\b(закадров\w*|многоголос\w*|двухголос\w*|одноголос\w*|"
r"профессиональн\w*|любительск\w*|авторск\w*|дублированн\w*|"
r"неофициальн\w*|официальн\w*|полный|перевод|роли озвучивали)\b", re.I)
ROLE_PATTERNS = [(r"дублир|дубляж", "дубляж"), (r"многоголос", "многоголосый"),
# «дубляж» как тип, а не часть имени студии («Дубляжная», «Продубляж», «Дубляж-Студия»)
RE_DUB_TYPE = r"дублир|(?<![а-яё])дубляж(?![а-яё])"
ROLE_PATTERNS = [(RE_DUB_TYPE, "дубляж"), (r"многоголос", "многоголосый"),
(r"двухголос", "двухголосый"), (r"одноголос", "одноголосый"),
(r"субтитр", "субтитры"), (r"оригинал", "оригинал")]
@@ -1094,6 +1097,8 @@ def translation_type(text: str) -> tuple[str, str]:
kind = name
break
if kind == "дубляж":
if re.search(r"любительск|псевдо", t): # любительский дубляж — отдельный класс, не «проф.»
return "люб. дубляж", "люб."
return "проф. дубляж", "проф."
if "профессиональн" in t:
pref = "проф."
@@ -1243,7 +1248,7 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
if t["type"] in ("оригинал", "субтитры"):
continue
match = None
for j, a in enumerate(ru_audio):
for j, a in enumerate(audios): # по имени студии — среди всех языков (украинский дубляж и т.п.)
if j in used:
continue
tag, st = a["tag"].lower(), t["studio"].lower()
@@ -1251,8 +1256,8 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
match = (j, a)
break
if match is None:
for j, a in enumerate(ru_audio):
if j not in used and not a["tag"]:
for j, a in enumerate(audios): # без имени — только безымянная русская дорожка
if j not in used and not a["tag"] and a["lang"].startswith("RU"):
match = (j, a)
break
if match:
@@ -1270,7 +1275,7 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
"codec": a.get("codec", ""), "channels": a.get("channels", ""),
"khz": a.get("khz", ""), "kbps": a.get("kbps", ""),
"subtitles": subs, "studio_source": studio_source, "raw": t["line"],
"lang": "RUS" if match else "", # в сопоставление идут только русские аудиодорожки
"lang": (a.get("lang") or "RUS") if match else "",
})
rel = {"source": "kinozal", "release_id": rec["id"], "ru_title": info["ru_title"],
"year": info["year"], "quality": rows[0]["quality"] if rows else "",
@@ -1622,13 +1627,102 @@ def kz_audio_only_rows(rec: dict, text: str) -> list[dict]:
return rows
def split_multi_rows(rows: list[dict]) -> list[dict]:
"""Строка с несколькими дорожками («Проф. многоголосый, авторский (одноголосый)») -> по строке на дорожку.
Режет теми же правилами, что сервис при разборе страницы (src.html_extract._split_entries), чтобы
обучение и инференс видели одинаковые фрагменты. Одну дорожку («Многоголосый, профессиональный -
LostFilm») не трогает. nnm tech-строки уже по одной на дорожку (role_raw) — тоже не трогает.
"""
out: list[dict] = []
for r in rows:
if r.get("studio_source") == "tech" and (r.get("role_raw") or "").strip():
out.append(r)
continue
body = RE_LINE_PREFIX.sub("", (r.get("raw") or "").strip())
parts = [p for p in _split_entries(re.sub(r"\s+", " ", body)) if RE_TYPE_HINT.search(p)]
if len(parts) < 2:
out.append(r)
continue
base_studio = (r.get("studio") or "").strip()
for part in parts:
e = rt_parse_entry(part)
if e["type"] in ("оригинал", "субтитры"):
continue
new = dict(r)
keep = base_studio not in ("", "(не указана)") and base_studio.casefold() in part.casefold()
new.update(raw=part, translation_type=e["type"], professional=e["pro"],
role_raw=e["role"], studio=base_studio if keep else (e["studio"] or "(не указана)"),
age_18=int(has_18(part) or (keep and int(r.get("age_18") or 0))))
new["note"] = mark_18(e["note"] or "", bool(new["age_18"]))
out.append(new)
return out
def audio_only_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
"""Строки «Аудио … |MVO, Студия|» с языком, которых нет среди строк разметки (украинский дубляж и т.п).
Берутся только дорожки с блоком |…| (тип, студия) и определимым типом; оригинал и комментарии —
не обучающие строки. Имена из скобок («(L,C,R,l,r)» — раскладка каналов) сюда не попадают.
"""
def key(x: str) -> str:
return re.sub(r"[\W_]+", "", (x or "").casefold())
have = key(" ".join(((r.get("studio") or "") + " " + (r.get("raw") or "")) for r in rows))
out = []
for a in audio_lines_all(text):
if a["original"] or a["commentary"]:
continue
m = re.search(r"\|([^|]+)\|", a["raw"])
if not m:
continue
chunk = m.group(1)
tokens = [t.strip() for t in re.split(r"\s*[,;/]\s*", chunk) if t.strip()]
kind = pro = ""
for t in tokens:
if t.upper().replace(".", "") in NNM_TAG_NAMES: # короткий тег: DUB, MVO, ПМ …
kind, pro = nnm_tag_type(t)
break
k, pr = translation_type(t)
if not k.endswith("неизвестно"):
kind, pro = k, pr
break
if not kind:
continue
studio = ""
for t in tokens:
if t.upper().replace(".", "") in NNM_TAG_NAMES or re.search(r"(?i)голос|дубл|оригинал|original", t):
continue
st, note = clean_studio(t)
st, extra = canonical_studio(st)
if len(st) >= 3:
studio = st
break
if not studio or key(studio) in have or re.match(r"(?i)(?:blu-?ray|bd|dvd|web)\b", studio): # источник, а не студия
continue
base = rows[0] if rows else {}
out.append({
"source": base.get("source", ""), "release_id": rec.get("id", ""),
"ru_title": base.get("ru_title", ""), "year": base.get("year", ""),
"quality": base.get("quality", ""), "is_series": base.get("is_series", False),
"studio": studio, "translation_type": kind, "professional": pro,
"role_raw": chunk.strip(), "actors": "",
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), a["adult"]),
"age_18": int(a["adult"]), "codec": a["codec"], "channels": a["channels"], "khz": "",
"kbps": a["kbps"], "subtitles": "", "studio_source": "audio",
"raw": a["raw"], "lang": a["lang"], "audio_raw": a["raw"]})
have += key(studio)
return out
def _with_audio(core, texts):
"""Обёртка парсера: lang/audio_raw у строк; число переводов в rel — по итоговым строкам."""
def parse(rec: dict) -> tuple[list[dict], dict]:
rows, rel = core(rec)
text = texts(rec)
if rows:
rows = enrich_rows(rows, text)
rows = enrich_rows(split_multi_rows(rows), text)
rows += audio_only_rows(rec, text, rows)
elif core is _kz_parse_core:
rows = kz_audio_only_rows(rec, text)
if rows: