From 05537ac9e9b2e52007739b5cb8c4104b00dad92e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 29 Oct 2026 18:13:07 +0700 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B0=D1=82=D0=B0=D1=81=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B8=20=D0=BE=D0=B1=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5:=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D1=81=D1=81=20=C2=AB=D0=BB=D1=8E=D0=B1.=20?= =?UTF-8?q?=D0=B4=D1=83=D0=B1=D0=BB=D1=8F=D0=B6=C2=BB,=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=BC=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=D1=82=D0=B8=D0=BF=D0=BE=D0=B2=D1=8B=D1=85=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA,=20=D1=8F=D0=B7=D1=8B=D0=BA=20=D0=B8?= =?UTF-8?q?=D0=B7=20=C2=AB=D0=90=D1=83=D0=B4=D0=B8=D0=BE=C2=BB,=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=B8=D0=B7=20=C2=AB=D0=90?= =?UTF-8?q?=D1=83=D0=B4=D0=B8=D0=BE=C2=BB,=20=D0=BF=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B2,=20=D0=B2=D0=B5=D1=81=D0=B0=20=D0=BA=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/api-reference.md | 8 ++- docs/dataset.md | 33 +++++++++-- src/audio18.py | 2 +- src/model.py | 7 ++- src/predict.py | 2 +- src/prepare_data.py | 33 ++++++++++- src/train.py | 31 +++++++++- tests/run_tests.py | 32 ++++++++++ tools/collect.py | 2 +- tools/harvest/harvest_core.py | 106 ++++++++++++++++++++++++++++++++-- 10 files changed, 233 insertions(+), 23 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 1aab666..f96a4a1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -86,7 +86,12 @@ Поле `lang` описывает только язык дорожки; `role` и `lang` не зависят от `translation_type`. -### translation_type: 14 классов +### translation_type: 14 классов (15 после переобучения на пересобранных данных) + +> После пересборки датасета (см. [dataset.md](dataset.md)) добавляется класс `люб. дубляж` — любительский +> дубляж; модели, обученные раньше, его не возвращают и по-прежнему отдают 14 классов из списка ниже. +> Источник истины для конкретной модели — `GET /health` → `classes`; клиенту безопасно сопоставлять по подстроке +> (`дубляж` → дубляж), как описано ниже: `люб. дубляж` её содержит. Точные строки (регистр, пробелы и точки после префикса — как есть; сортировка — по алфавиту, так их возвращает `GET /health` → `classes`): @@ -109,6 +114,7 @@ | Значение | Кто озвучивал | Вид | |---|---|---| | `проф. дубляж` | профессиональная студия | дубляж (речь актёров заменяет оригинал; в страницах «Дублированный») | +| `люб. дубляж` | любительская озвучка | дубляж/псевдодубляж; только в моделях, обученных после пересборки датасета | | `проф. многоголосый` | профессиональная студия | закадровый, три и более голоса | | `проф. двухголосый` | профессиональная студия | закадровый, два голоса | | `проф. одноголосый` | профессиональная студия | закадровый, один голос | diff --git a/docs/dataset.md b/docs/dataset.md index 9db61cd..48e89de 100644 --- a/docs/dataset.md +++ b/docs/dataset.md @@ -10,10 +10,13 @@ регулярками** — модель учится воспроизводить эту разметку и обобщать её на новые формулировки. -Снимок: 3286 строк, 3 источника — rutracker 1660, nnm 1549, kinozal 77. -После подготовки: 3284 строки + 458 аугментированных морфологических -вариантов («многоголосое», «дублированное» и т.п.), всего 3742 обучающих -примера. Файлы `*_entries.csv` / `*_releases.csv` — те же данные в нарезке +Размер (на момент последней сборки): ~41 тыс. строк из ~28 тыс. релизов, 3 источника +(rutracker, nnm, kinozal); HTML-дамп страниц — `data/raw` (~32 тыс. файлов, ≈2.6 ГБ, в git не +хранится), разобранные тексты — `data/pages`. Точные числа показывает «Статистика датасета» в +`tools/collect.py` (пункт 3) и отчёт `reports/data_audit.md` после `prepare_data`. +Ранние цифры этого документа (3286 строк) относились к первому снимку. +К строкам при подготовке добавляются аугментированные морфологические варианты +(«многоголосое», «дублированное» и т.п.). Файлы `*_entries.csv` / `*_releases.csv` — те же данные в нарезке по источникам и по релизам; модель используется только `labels.csv`. ## Колонки labels.csv @@ -40,9 +43,11 @@ `raw` берётся `role_raw` конкретной дорожки, чтобы одинаковый текст не получал разные метки). -## Семантика translation_type (14 классов) +## Семантика translation_type (15 классов после пересборки; 14 в ранних моделях) -Составные метки «кто делал × сколько голосов»: +Составные метки «кто делал × сколько голосов». Набор классов не зашит в код: `prepare_data` берёт его +из данных (`label_maps.json`), поэтому модель, обученная до пересборки, знает 14 классов, а после — +ещё и `люб. дубляж`. * префикс: `проф.` (профессиональная), `люб.` (любительская), `авт.` (авторская), без префикса — в тексте не указано; @@ -52,6 +57,22 @@ Все «*неизвестно»-варианты объединены в `неизвестно`; типы «оригинал» и «субтитры» (1–2 строки-проскока генератора) удалены. +### Что изменено в разметке (парсеры `tools/harvest/harvest_core.py`, `PARSE_VERSION = 3`) + +| Проблема в старых метках | Исправление | +|---|---| +| Любительский дубляж («Любительский (дублированный) Red Head Sound», «псевдодублированный») размечался как `проф. дубляж` (644 из 720 строк) | Новый класс `люб. дубляж` | +| Дубляж по имени студии: «многоголосый … Дубляжная», «Продубляж» получали тип «дубляж» | «дубляж» считается типом, только если это отдельное слово или «дублир…» | +| Несколько дорожек в одной строке с одной меткой («Профессиональный (двухголосый закадровый), авторский (одноголосый закадровый)»; 7.3% строк) | `split_multi_rows`: строка раскладывается на дорожки теми же правилами, что и сервис (`src/html_extract._split_entries`); «Многоголосый, профессиональный - LostFilm» остаётся одной дорожкой. Остатки старых данных `prepare_data` отбрасывает (счётчик в аудите) | +| Язык почти не размечался (122 строки `UKR`, 47 `ENG`): kinozal сопоставлял «Перевод» только с русскими аудиодорожками, а в строках `\|Дубляж, Le Doyen\|` студия читалась как одно имя | Сопоставление со всеми аудиодорожками; язык берётся из строки «Аудио»; `src/trackinfo.parse_audio_line` делит `\|Тип, Студия\|` | +| Дорожки, названные только в строках «Аудио» (украинский дубляж и т.п.) терялись | `audio_only_rows`: строки с блоком `\|…\|`, студией и определимым типом (оригинал, комментарии, источники вроде «Blu-ray CEE» не берутся) | +| Одни и те же короткие строки доминировали («Перевод: Дублированный» — 2727 копий) | `prepare_data --max-dup` (по умолчанию 40 копий одного текста и типа) | +| Дисбаланс классов | Веса классов в потерях типа: корень из обратной частоты, потолок 4 (`--no-class-weights` отключает; значение записывается в `run.json`) | + +Для применения к уже собранным данным нужна переиндексация с пересчётом меток +(`python tools/collect.py` → пункт 2 → «Переиндексировать data/raw … y»): старые строки +`labels.csv` заменяются, копия прежнего файла сохраняется как `data/labels.bak_<время>.csv`. + ## Аудит (reports/data_audit.md) и что с находками сделано | Находка | Обработка | diff --git a/src/audio18.py b/src/audio18.py index ce37339..c11d456 100644 --- a/src/audio18.py +++ b/src/audio18.py @@ -25,7 +25,7 @@ def _norm(s: str) -> str: RE_CODEC = re.compile(r"(E-?AC-?3|[AА][CС]-?3|AAC|DTS-?HD|DTS|FLAC|TrueHD|Opus|MP3)", re.I) -RE_CH = re.compile(r"(\d(?:\.\d)?)\s*ch", re.I) +RE_CH = re.compile(r"(\d(?:\.\d)?)\s*ch\b", re.I) RE_KHZ = re.compile(r"(\d+(?:\.\d+)?)\s*kHz", re.I) RE_KBPS = re.compile(r"(\d+)\s*kbps", re.I) diff --git a/src/model.py b/src/model.py index 5bb9a8e..616b267 100644 --- a/src/model.py +++ b/src/model.py @@ -46,6 +46,11 @@ class DubTagger(nn.Module): self.n_bio = n_bio self.span_weight = span_weight self.age_weight = age_weight + self.register_buffer("type_weights", None, persistent=False) # только для обучения; в model.pt не пишется + + def set_type_weights(self, weights) -> None: + """Веса классов для потерь типа (редкие классы весят больше); None — без весов.""" + self.type_weights = None if weights is None else weights.to(self.type_head.weight.device) def forward(self, input_ids, attention_mask, token_type_ids=None, type_labels=None, bio_labels=None, age_labels=None, @@ -63,7 +68,7 @@ class DubTagger(nn.Module): loss = None if type_labels is not None: - loss = F.cross_entropy(type_logits, type_labels) + loss = F.cross_entropy(type_logits, type_labels, weight=self.type_weights) if bio_labels is not None: loss = loss + self.span_weight * F.cross_entropy( bio_logits.reshape(-1, self.n_bio), diff --git a/src/predict.py b/src/predict.py index f7927d6..13f6724 100644 --- a/src/predict.py +++ b/src/predict.py @@ -39,7 +39,7 @@ from .trackinfo import lang_of_text, model_text, role_of # мусор, который модель иногда принимает за автора: раскладка каналов, кодеки RE_NOT_AUTHOR = re.compile( r"(?i)^[\s,/:+.\d]*(?:[LRC]|Ls|Rs|LFE|l|r|Front|Side|ch)?(?:[\s,/:+.\d]*(?:[LRC]|Ls|Rs|LFE|l|r|Front|Side|ch))*[\s,/:+.\d]*$" - r"|dolby|digital|audio|kbps|khz|AC-?3|DTS|AAC|Opus") + r"|dolby|digital|audio|kbps|khz|\bAC-?3\b|\bDTS\b|\bAAC\b|\bOpus\b") def refine_span(text: str, span: tuple[int, int] | None) -> tuple[int, int] | None: diff --git a/src/prepare_data.py b/src/prepare_data.py index 9e3e49b..436460f 100644 --- a/src/prepare_data.py +++ b/src/prepare_data.py @@ -32,7 +32,7 @@ import re from collections import Counter, defaultdict from pathlib import Path -from .html_extract import RE_LINE_PREFIX +from .html_extract import RE_LINE_PREFIX, RE_TYPE_HINT, _split_entries from .manual import REPEAT, manual_ids from .studio_norm import JUNK_STUDIO_RE, find_studio_span from .trackinfo import context, model_text, parse_audio_line @@ -45,6 +45,7 @@ UNKNOWN_TYPES = {"неизвестно", "авт. неизвестно", "про DROP_TYPES_RE = re.compile(r"оригинал|субтитры") NO_STUDIO = "(не указана)" MIN_LANG_ROWS = 20 # язык с меньшим числом примеров не становится классом +MAX_DUP = 40 # одинаковых (текст, тип) в обучении не больше: «Перевод: Дублированный» встречается тысячи раз # лёгкая аугментация: морфологические варианты типовых слов (средний род и # пр.), которых в данных мало («Озвучивание: Дублированное | Official») @@ -93,6 +94,8 @@ def main() -> None: ap.add_argument("--out", default="data/processed") ap.add_argument("--test-size", type=float, default=0.2) ap.add_argument("--seed", type=int, default=42) + ap.add_argument("--max-dup", type=int, default=MAX_DUP, + help="сколько копий одинакового (текст, тип) оставлять; 0 — не ограничивать") args = ap.parse_args() out_dir = Path(args.out) @@ -106,13 +109,19 @@ def main() -> None: # --- 1..4: текст, тип, автор, спан -------------------------------- rows = [] - dropped_empty = dropped_junk = dropped_type = 0 + dropped_empty = dropped_junk = dropped_type = dropped_multi = 0 span_misses: list[tuple[str, str, str]] = [] for r in raw_rows: text = build_text(r) # без хвоста «Аудио»: спан автора ищем в чистом тексте дорожки if not text: dropped_empty += 1 continue + # несколько дорожек в одной строке при одной метке — неоднозначный пример (после переиндексации + # такие строки уже разложены сборщиком; здесь отсекаются остатки старых данных) + nested = [p for p in _split_entries(text) if RE_TYPE_HINT.search(p)] + if len(nested) >= 2 and not (r.get("source") == "nnm" and r.get("studio_source") == "tech"): + dropped_multi += 1 + continue ttype = (r.get("translation_type") or "").strip() if ttype in UNKNOWN_TYPES: ttype = "неизвестно" @@ -201,6 +210,23 @@ def main() -> None: drop.update(idxs[1:]) # оставляем первую строку rows = [r for i, r in enumerate(rows) if i not in drop] + # --- 5б: потолок повторов (дубли одного текста всё равно лежат в одной группе сплита) ----------- + n_capped = 0 + if args.max_dup: + seen: Counter = Counter() + kept = [] + for r in rows: + if r["release_id"] in manual: + kept.append(r) + continue + k = (norm_text(r["text"]), r["translation_type"]) + seen[k] += 1 + if seen[k] <= args.max_dup: + kept.append(r) + else: + n_capped += 1 + rows = kept + # --- 6: группы (релиз + одинаковый текст) ------------------------- uf = UnionFind(len(rows)) by_release: dict[tuple[str, str], list[int]] = defaultdict(list) @@ -271,7 +297,8 @@ def main() -> None: f"групп (релиз ∪ одинаковый текст): {len(roots)}", f"удалено: пустой текст {dropped_empty}, мусорный studio {dropped_junk}, " f"не-озвучка (оригинал/субтитры) {dropped_type}, " - f"противоречивые дубли {len(drop)} (групп-противоречий: {len(contradictions)})", + f"противоречивые дубли {len(drop)} (групп-противоречий: {len(contradictions)}), " + f"несколько дорожек в одной строке {dropped_multi}, лишние повторы (> {args.max_dup} одинаковых) {n_capped}", f"спан автора найден: {with_studio} ({with_studio / max(len(rows), 1):.1%}), " f"не найден: {n_span_miss}", f"аугментировано морфологических вариантов: {n_aug}", diff --git a/src/train.py b/src/train.py index 1f6e3be..efeccdd 100644 --- a/src/train.py +++ b/src/train.py @@ -171,8 +171,25 @@ def evaluate(model, loader, device, id2type, per_row: list | None = None) -> dic } +def class_weights(rows, type2id: dict[str, int], power: float = 0.5) -> torch.Tensor: + """Веса классов типа: (N / (K · n_c)) ** power, нормированные так, что средний вес по примерам = 1. + + power 0.5 (корень из обратной частоты) смягчает дисбаланс, не раздувая шум редчайших классов + (у «авт. многоголосый» несколько строк): полная обратная частота дала бы им вес в сотни раз больше. + Потолок веса — 4. + """ + counts = np.ones(len(type2id)) + for r in rows: + counts[type2id[r["translation_type"]]] += 1 + w = (counts.sum() / (len(counts) * counts)) ** power + w = w / (w * counts).sum() * counts.sum() + w = np.minimum(w, 4.0) # потолок: классы из единиц строк не должны перетягивать обучение + return torch.tensor(w, dtype=torch.float32) + + def train_model(name: str, cfg: dict, train_rows, val_rows, test_rows, - type2id, device, epochs=None, seed=SEED, log_prefix="", lang2id=None): + type2id, device, epochs=None, seed=SEED, log_prefix="", lang2id=None, + use_class_weights: bool = True): """Обучение с early stopping на val; возвращает (модель, токенизатор, отчёт).""" set_seed(seed) tokenizer = AutoTokenizer.from_pretrained(cfg["backbone"]) @@ -186,6 +203,8 @@ def train_model(name: str, cfg: dict, train_rows, val_rows, test_rows, val_loader = DataLoader(Rows(val_rows), batch_size=128, collate_fn=collate) test_loader = DataLoader(Rows(test_rows), batch_size=128, collate_fn=collate) id2type = {i: t for t, i in type2id.items()} + if use_class_weights: + model.set_type_weights(class_weights(train_rows, type2id)) epochs = epochs or cfg["epochs"] steps = len(train_loader) * epochs @@ -248,6 +267,8 @@ def main() -> None: ap.add_argument("--lr", type=float, default=None, help="заменяет lr профиля") ap.add_argument("--batch", type=int, default=None, help="заменяет batch профиля") ap.add_argument("--seed", type=int, default=SEED) + ap.add_argument("--no-class-weights", action="store_true", + help="потери типа без весов классов (по умолчанию: корень из обратной частоты)") ap.add_argument("--runs", action="store_true", help="каждая модель — отдельный запуск в --runs-dir; models/best, reports/final_* " "и summary.md не трогаются (выбор лучшей — tools/collect.py, пункт 7)") @@ -293,7 +314,8 @@ def main() -> None: va = [r for r in train_rows if r["fold"] == f] _, _, res = train_model(name, cfg, tr, va, va, type2id, device, epochs=args.epochs_override, - seed=args.seed + f, log_prefix=f"cv{f} ", lang2id=lang2id) + seed=args.seed + f, log_prefix=f"cv{f} ", lang2id=lang2id, + use_class_weights=not args.no_class_weights) cv_metrics.append(res["test"]) cv = ({k: float(np.mean([m[k] for m in cv_metrics])) for k in cv_metrics[0]} if cv_metrics else None) @@ -308,7 +330,8 @@ def main() -> None: va = [r for r in train_rows if r["group_id"] in val_groups] model, tokenizer, res = train_model( name, cfg, tr, va, test_rows, type2id, device, - epochs=args.epochs_override, seed=args.seed, lang2id=lang2id) + epochs=args.epochs_override, seed=args.seed, lang2id=lang2id, + use_class_weights=not args.no_class_weights) res["cv_mean"] = cv res["train_seconds"] = round(time.time() - t0, 1) per_row = res.pop("per_row") @@ -319,6 +342,7 @@ def main() -> None: save_model(model, tokenizer, run_dir) shutil.copy(maps_path, run_dir / "label_maps.json") info = {"name": name, **cfg, "max_len": MAX_LEN, "seed": args.seed, "folds": args.folds, + "class_weights": not args.no_class_weights, "created": time.strftime("%Y-%m-%d %H:%M:%S"), "fingerprint": fp, "rows": {"train": len(train_rows), "test": len(test_rows)}, "final": {k: v for k, v in res.items() if k != "history"}} @@ -338,6 +362,7 @@ def main() -> None: shutil.copy(maps_path, model_dir) (model_dir / "train_config.json").write_text(json.dumps( {"name": name, **cfg, "max_len": MAX_LEN, "seed": args.seed, + "class_weights": not args.no_class_weights, "final": {k: v for k, v in res.items() if k != "history"}}, ensure_ascii=False, indent=1), encoding="utf-8") runs.store_eval(model_dir, fp, {"metrics": res["test"], "per_row": per_row, "n_unscorable": 0}) diff --git a/tests/run_tests.py b/tests/run_tests.py index 172328f..0bd5a32 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -855,6 +855,38 @@ check("prepare_data: вход модели несёт строку «Аудио and all(r["text"][r["span"][0]:r["span"][1]].startswith("Студия") for r in _ds if r["span"])) check("prepare_data: у редкого языка метка пустая", [r["lang"] for r in _ds if r["release_id"] == "p1"] == [None]) +print("== метки: тип, раскладка, строки из «Аудио», веса классов ==") +import harvest_core as _hc2 # noqa: E402 +_tt = _hc2.translation_type +check("тип: любительский дубляж — отдельный класс", _tt("Любительский (дублированный) Red Head Sound")[0] == "люб. дубляж") +check("тип: псевдодубляж -> люб. дубляж", _tt("Любительский (псевдодублированный)")[0] == "люб. дубляж") +check("тип: обычный дубляж остаётся проф.", _tt("Профессиональный (дублированный) Продубляж")[0] == "проф. дубляж") +check("тип: «Дубляжная» — имя студии, а не дубляж", + _tt("Любительский (многоголосый закадровый) Дубляжная")[0] == "люб. многоголосый") +check("тип: слово «дубляж» отдельно — дубляж", _tt("Дубляж, HDRezka")[0] == "проф. дубляж") +_base = {"source": "kinozal", "release_id": "1", "ru_title": "x", "year": "", "is_series": False, "studio": "(не указана)", + "translation_type": "проф. многоголосый", "professional": "проф.", "role_raw": "", "age_18": 0, + "studio_source": "release", "note": ""} +_sp = _hc2.split_multi_rows([{**_base, "raw": "Перевод: Профессиональный (двухголосый закадровый), авторский (одноголосый закадровый)"}]) +check("раскладка: две дорожки из одной строки", [r["translation_type"] for r in _sp] == ["проф. двухголосый", "авт. одноголосый"], + str([(r["translation_type"], r["raw"]) for r in _sp])) +_one = _hc2.split_multi_rows([{**_base, "raw": "Перевод: Многоголосый, профессиональный - LostFilm"}]) +check("раскладка: одна дорожка не режется", len(_one) == 1, str(len(_one))) +_tech = _hc2.split_multi_rows([{**_base, "studio_source": "tech", "role_raw": "Многоголосый", "raw": "Перевод: A, B"}]) +check("раскладка: nnm tech-строка не режется", len(_tech) == 1) +_ao = _hc2.audio_only_rows({"id": "2"}, ( + "Аудио 1: Russian E-AC3 / 5.1 / 48 kHz / 768 kbps |MVO, AlexFilm|\n" + "Аудио 2: Ukrainian E-AC3 / 5.1 / 48 kHz / 256 kbps |Дубляж, Le Doyen|\n" + "Аудио 3: Russian DTS, 3/2 (L,C,R,l,r) + LFE ch, 640 kbps |Дубляж, Blu-ray CEE|\n" + "Аудио 4: English E-AC3 / 5.1 / 48 kHz / 768 kbps |Original|\n"), + [{**_base, "studio": "AlexFilm", "raw": "Профессиональный (многоголосый закадровый) AlexFilm"}]) +check("строки из «Аудио»: украинский дубляж добавлен, оригинал/источник/уже известные — нет", + [(r["studio"], r["lang"], r["translation_type"]) for r in _ao] == [("Le Doyen", "UKR", "проф. дубляж")], + str([(r["studio"], r["lang"], r["translation_type"]) for r in _ao])) +from src.train import class_weights # noqa: E402 +_cw = class_weights([{"translation_type": t} for t in ["a"] * 1000 + ["b"] * 100 + ["c"] * 4], {"a": 0, "b": 1, "c": 2}) +check("веса классов: редкие тяжелее, потолок 4", _cw[0] < _cw[1] < _cw[2] <= 4.0, str(_cw)) + print("== зависания сбора ==") import json as _js5, threading as _th5, time as _tm5 # noqa: E402 diff --git a/tools/collect.py b/tools/collect.py index 80153a2..04a96e2 100644 --- a/tools/collect.py +++ b/tools/collect.py @@ -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: diff --git a/tools/harvest/harvest_core.py b/tools/harvest/harvest_core.py index 83a3c54..d7c9796 100644 --- a/tools/harvest/harvest_core.py +++ b/tools/harvest/harvest_core.py @@ -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"дублир|(? 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: