Compare commits

...
9 Commits
Author SHA1 Message Date
DenozordecandClaude Opus 5.5 666caa60d5 Отчёт аудита данных после доразметки студий
docker / build (push) Successful in 1m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:38:55 +07:00
DenozordecandClaude Opus 5.5 83bab13893 Доразметка студий: 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>
2026-11-02 15:20:03 +07:00
DenozordecandClaude Opus 5.5 f91f6cb48e Сегментатор: -1 только для кодек-строк с типом или студией
Пустая кодек-строка («Аудио 1: AC3, 6 ch, 384 kbps - Русский») снова
негатив: с -1 модель разучилась её отбрасывать и на nnm подменяла ею
строку «Перевод: …».

Пробное обучение (2 эпохи, ~34 мин на 27.9k страниц) e2e по 400 тестовых
страниц, P/R пар (тип, студия), текущие веса -> новые: nnm 0.872/0.879 ->
0.836/0.860, rutracker 0.891/0.917 -> 0.883/0.910, kinozal 0.720/0.844 ->
0.724/0.845. Новые веса не лучше текущих, models/segmenter не заменён.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:10:32 +07:00
DenozordecandClaude Opus 5.5 6470f882f1 Сегментатор: текст kinozal из raw JSON, кодек-строки не как негатив; e2e по 400 страниц
Обучение:
* page_text/kinozal_text: текст kinozal = h1 + «Техданные» + «Релиз» из
  data/raw/kinozal/*.json (в pages/kz-*.txt нет заголовка и «Техданных»,
  откуда половина меток kinozal; сервис видит обе вкладки).
* label_page: позитив — raw или audio_raw; неразмеченная кодек-строка
  русской дорожки («Аудио 3: … Русский (MVO, X)», «Аудио Rus: … | X»)
  получает -1 и в обучение не идёт: как негатив она учила отбрасывать
  кодек-строки. Сводка «Аудио: Русский, английский (…)» остаётся негативом.

Инференс (release):
* model_segments -> (основные, кодек); выбранные моделью кодек-строки идут
  второстепенным источником вместе с правилом codec_segments; иноязычные
  («Казахский (Dub, …)», «English Dubbing», «Original») отбрасываются.
* merge_extra_tracks(adopt_type): кодек-дорожка с типом «неизвестно»
  получает тип единственной дорожки страницы без студии.

e2e_segmenter: тот же текст kinozal, не больше E2E_MAX (400) страниц на
трекер, train-страницы только с --train-pages (меню парсит только тест).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:05:20 +07:00
DenozordecandClaude Opus 5.5 099d33bdaa Слияние claude/advisor-opus-78114c: модели на пересобранном датасете, Docker, язык и серии
Конфликт в src/release.py: взяты original_info, lostfilm_release,
series_info и серии дорожек из advisor; сохранены same_studio,
merge_extra_tracks и codec_segments. Кодек-дорожкам тоже проставляются
серии.

Кодек-строки теперь подключаются, только если у какой-то дорожки страницы
нет студии: с новыми моделями безусловное подключение роняло precision
студий на rutracker (0.908 -> 0.793). С условием, test-сплит, по 300
релизов, студии P/R: rutracker 0.908/0.882 -> 0.840/0.900, nnm
0.875/0.915 -> 0.875/0.940, kinozal 0.816/0.845 -> 0.800/0.863. Тесты:
240 ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:50:44 +07:00
DenozordecandClaude Opus 5.5 666a266b2f Сегментатор: truncation longest_first — не падать на строке длиннее max_length
С only_second токенизатор бросал исключение, если сама строка (до 220
символов) не помещалась в MAX_LEN и обрезать было нечего, кроме контекста.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:44:18 +07:00
DenozordecandClaude Opus 5.5 1f9ee46a0d kinozal: догрузка вкладки «Релиз» по URL; кодек-строки «Аудио N» как источник студий
На kinozal во встроенном #tabs только «Техданные» («Перевод:» без студий),
а строки «Аудио N: … (MVO, LostFilm)» лежат во вкладке «Релиз», которую
страница грузит JS. Поэтому по URL определялись только типы без студий.

* fetch: для kinozal скачиваются вкладки showtab(id,N)
  (get_srv_details.php?id=…&pagesd=N, кроме «Техданных» и «Скриншотов»)
  и дописываются в #tabs; недоступная вкладка пропускается.
* release: кодек-строки с типом озвучки берутся правилом codec_segments
  (сегментатор их отбрасывает — в разметке rutracker это не raw) и идут
  второстепенным источником (source=codec / page+codec): только дорожки
  со студией, дубли студии (same_studio) пропускаются, студия вписывается
  в дорожку того же типа без студии. «English Dubbing» и т.п. не берутся.
* merge_title_tracks переведён на общий merge_extra_tracks.

e2e test rutracker без изменений (P 0.988 / R 0.982); train-страницы
P 0.975 -> 0.951. Тесты: 77 ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:33:01 +07:00
DenozordecandClaude Sonnet 5.5 6a87bc993c Слияние claude/advisor-opus-78114c: сегментатор страниц, URL/FlareSolverr, агрегат релиза
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:07:22 +07:00
Denozordec 2eb4f8b72e Merge claude/advisor-opus-78114c: релиз целиком, URL-вход, int8 tiny2, фикс сегментации, датасет 6864 2026-10-27 17:22:31 +07:00
12 changed files with 7886 additions and 3136 deletions

No files matched your search

+7270 -3051
View File
File diff suppressed because it is too large. Load diff
+17 -7
View File
@@ -73,13 +73,23 @@ BIO-разметка строится по символьным спанам ч
используются, если `models/segmenter` нет или модель не нашла ни одной строки; используются, если `models/segmenter` нет или модель не нашла ни одной строки;
режим можно форсировать: `predict_release(..., segmenter="rules")`. режим можно форсировать: `predict_release(..., segmenter="rules")`.
* Обучение: `python -m src.segmenter --train` (~1 мин). Разметка строк — по * Обучение: `python -m src.segmenter --train --out models/segmenter_v2` (или меню
`raw` из `labels.csv` (362 страницы в `data/pages`); синтетика: `raw` всех `tools/collect.py` → «2»). Разметка строк — по `raw` и `audio_raw` из `labels.csv`
train-строк (в т.ч. nnm/kinozal без страниц) вставляются в контекст реальных (страницы `data/pages`); синтетика: `raw` всех train-строк вставляются в контекст
строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест). реальных строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест).
* Метрики: строки (test) P 0.936 / R 0.994; e2e на непросмотренных страницах * Текст kinozal берётся из `data/raw/kinozal/<id>.json` (`h1` + «Техданные» +
rutracker (n=64) — precision 0.904 → 0.946 против правил, recall 0.975 → 0.975 «Релиз», `segmenter.page_text`): в `data/pages/kz-*.txt` нет ни заголовка, ни
(`tests/e2e_segmenter.py`; выборка мала, разброс большой). «Техданных», откуда взята половина меток kinozal, а сервис (`fetch_html`)
видит обе вкладки.
* Кодек-строка русской дорожки («Аудио 3: AC3 … Русский (MVO, LostFilm)»,
«Аудио Rus: … | LostFilm»), не попавшая в разметку, в обучение не идёт
(метка -1): у rutracker `raw` — строка «Перевод N:», и как негатив она учила
модель отбрасывать кодек-строки — а на kinozal студии только в них.
* В `predict_release` выбранные моделью кодек-строки — второстепенный источник
(`source: codec`): подключаются, только если у какой-то дорожки страницы нет
студии; если других строк нет (nnm), они и есть дорожки.
* e2e: `tests/e2e_segmenter.py [каталог]` — пары (тип, студия) на тестовых
страницах, не больше `E2E_MAX` (400) на трекер; `--train-pages` — ещё и train.
* Нарезка ВНУТРИ строки («Дублированный, многоголосый») в данных не размечена * Нарезка ВНУТРИ строки («Дублированный, многоголосый») в данных не размечена
(91 группа из 6735) — остаётся на правилах `_split_entries`, но только на (91 группа из 6735) — остаётся на правилах `_split_entries`, но только на
строках, выбранных моделью. строках, выбранных моделью.
+17
View File
@@ -142,6 +142,23 @@ id разных трекеров не пересекались. Коллизий
повторяют логику правил. Для нарезки внутри строки («Дублированный, повторяют логику правил. Для нарезки внутри строки («Дублированный,
многоголосый») нужна ручная проверка — режим ревью ещё не сделан. многоголосый») нужна ручная проверка — режим ревью ещё не сделан.
Студии, которые парсеры раньше теряли (доразметка от 02.11.2026):
* **nnm, «Перевод: Дублированный (Невафильм)»** — строки «Перевод:» (tech) шли с
«(не указана)», хотя студия названа в той же части. Модель училась её не извлекать.
Теперь — `nnm_part_studio` (скобки или «… - Студия»; тип, «x2», «Лицензия», источник
вроде iTunes/Blu-ray — не студия). +3 051 студия.
* **kinozal, заголовок «… / ПМ (LostFilm, HDrezka Studio), СТ / …»** — во «Техданных»
только «Перевод: Профессиональный многоголосый», студии названы в заголовке и в
строках «Аудио Rus: … | LostFilm» без типа. Теперь — `kz_title_rows`: строка на каждую
студию из заголовка, которой нет на странице (`studio_source=title`, вход — тот же
сегмент, что строит сервис из заголовка; строка «Аудио» с этой студией — в
`audio_raw`). +4 219 строк в 3 007 релизах.
Сборщик делает это при переиндексации. Для уже собранного `labels.csv` без пересборки
страниц: `.venv\Scripts\python tools\relabel_studios.py` (копия — `data/labels.bak_relabel_*.csv`;
`--dry-run` — только статистика; повторный запуск ничего не добавляет).
## Разнообразная выборка (режим balanced) ## Разнообразная выборка (режим balanced)
Списки разделов идут от новых к старым, поэтому чтение подряд даёт только свежие Списки разделов идут от новых к старым, поэтому чтение подряд даёт только свежие
+1 -1
View File
@@ -65,7 +65,7 @@
| 1 baseline | сделано | | 1 baseline | сделано |
| 2 URL + агрегат релиза | сделано: `src/fetch.py`, `src/release.py`, `--url`, `POST /predict {"url"}` | | 2 URL + агрегат релиза | сделано: `src/fetch.py`, `src/release.py`, `--url`, `POST /predict {"url"}` |
| 3 title-fallback | сделано (`ПМ (LostFilm)` → дорожка с `source: "title"`); заголовок берётся из HTML (`<h1>`/`<title>`), из текстового дампа (первая строка вида «… / год / …»), либо передаётся явно (`title`). Если на странице есть дорожка того же типа без студии, студия вписывается в неё (`source: page+title`), а не дублируется. **Эффект на e2e мал**: kinozal P/R 0.107 → 0.143, rutracker без изменений (0.826 / 0.984) — в разметке датасета студия из заголовка почти не нужна | | 3 title-fallback | сделано (`ПМ (LostFilm)` → дорожка с `source: "title"`); заголовок берётся из HTML (`<h1>`/`<title>`), из текстового дампа (первая строка вида «… / год / …»), либо передаётся явно (`title`). Если на странице есть дорожка того же типа без студии, студия вписывается в неё (`source: page+title`), а не дублируется. **Эффект на e2e мал**: kinozal P/R 0.107 → 0.143, rutracker без изменений (0.826 / 0.984) — в разметке датасета студия из заголовка почти не нужна |
| 4 kinozal-вкладка | **не сделано** в сервисе: страница релиза требует авторизации, а `get_srv_details.php?id=…&pagesd=N` уже вызывает `KinozalClient.php` в grabber. Рекомендация: grabber скачивает вкладку сам и шлёт `{"html": …, "title": <заголовок раздачи>}` (во фрагменте вкладки заголовка нет) | | 4 kinozal-вкладка | сделано: `fetch_html` для kinozal скачивает вкладки `showtab(id,N)` (`get_srv_details.php?id=…&pagesd=N`, кроме «Техданных» и «Скриншотов») и дописывает их в `#tabs`. Кодек-строки «Аудио N: … (MVO, LostFilm)» сегментатор отбрасывает (в разметке rutracker это не `raw`), поэтому они берутся правилом `codec_segments` как второстепенный источник (`source: codec` / `page+codec`): только дорожки со студией, студия-дубль (`same_studio`) пропускается. Кодек-строки подключаются, только если у какой-то дорожки страницы нет студии. На test-сплите модели 29.10 (по 300 релизов), студии P/R без → с кодек-строками: rutracker 0.908/0.882 → 0.840/0.900 (почти все «ложные» — реальные студии, которых нет в эталоне), nnm 0.875/0.915 → 0.875/0.940, kinozal (текст из raw JSON) 0.816/0.845 → 0.800/0.863 |
| 5 false-positives сегментации | сделано: починена резка «Авторский, одноголосый» по запятой; постобработка спана (`refine_span`: до границ слова, мусор вроде `L,C,R,l,r`/кодеков → null). Остаток — промахи модели на `kbps \| DUB Автор` и особенности разметки | | 5 false-positives сегментации | сделано: починена резка «Авторский, одноголосый» по запятой; постобработка спана (`refine_span`: до границ слова, мусор вроде `L,C,R,l,r`/кодеков → null). Остаток — промахи модели на `kbps \| DUB Автор` и особенности разметки |
| 9 переобучение | датасет вырос 3286 → 6864 строк; обе модели переобучены (см. ниже) | | 9 переобучение | датасет вырос 3286 → 6864 строк; обе модели переобучены (см. ниже) |
| 6 tiny2 + int8 | сделано: 29.3 МБ, parity 99.8% тип / 99.2% спан / 100% 18+ (`reports/onnx_parity_tiny2_int8.md`) | | 6 tiny2 + int8 | сделано: 29.3 МБ, parity 99.8% тип / 99.2% спан / 100% 18+ (`reports/onnx_parity_tiny2_int8.md`) |
+18 -18
View File
@@ -1,28 +1,28 @@
# Аудит датасета: data/labels.csv # Аудит датасета: data/labels.csv
строк в CSV: 44677 строк в CSV: 48896
строк после подготовки: 35383 (train 31343 / test 4040) строк после подготовки: 39418 (train 34183 / test 5235)
источники: {'kinozal': 6382, 'rutracker': 14342, 'nnm': 14659} источники: {'kinozal': 10412, 'rutracker': 14347, 'nnm': 14659}
уникальных нормализованных текстов: 18870 уникальных нормализованных текстов: 20260
групп (релиз ∪ одинаковый текст): 6681 групп (релиз ∪ одинаковый текст): 6947
удалено: пустой текст 0, мусорный studio 155, не-озвучка (оригинал/субтитры) 6, противоречивые дубли 0 (групп-противоречий: 0), несколько дорожек в одной строке 20, лишние повторы (> 40 одинаковых) 16950 удалено: пустой текст 0, мусорный studio 155, не-озвучка (оригинал/субтитры) 6, противоречивые дубли 0 (групп-противоречий: 0), несколько дорожек в одной строке 20, лишние повторы (> 40 одинаковых) 18196
спан автора найден: 24004 (67.8%), не найден: 231 спан автора найден: 31214 (79.2%), не найден: 237
аугментировано морфологических вариантов: 7658 аугментировано морфологических вариантов: 8720
дорожек 18+: 701 (из них без упоминания «18» в тексте: 8) дорожек 18+: 781 (из них без упоминания «18» в тексте: 8)
язык размечен: 15282 из 35383; со строкой «Аудио» во входе: 11796; классы: {'RUS': 14673, 'UKR': 556, 'ENG': 53}; редкие (<20, не учатся): {'DEU': 7, 'FRA': 14, 'CHI': 3, 'KAZ': 10, 'GEO': 2, 'BEL': 2, 'KOR': 1, 'JPN': 4, 'LAV': 1, 'CZE': 1, 'ITA': 6, 'SPA': 8} язык размечен: 19312 из 39418; со строкой «Аудио» во входе: 13140; классы: {'RUS': 18703, 'UKR': 556, 'ENG': 53}; редкие (<20, не учатся): {'DEU': 7, 'FRA': 14, 'CHI': 3, 'KAZ': 10, 'GEO': 2, 'BEL': 2, 'KOR': 1, 'JPN': 4, 'LAV': 1, 'CZE': 1, 'ITA': 6, 'SPA': 8}
## Распределение translation_type ## Распределение translation_type
- проф. дубляж: 9936 - проф. многоголосый: 11399
- проф. многоголосый: 9831 - проф. дубляж: 10468
- авт. одноголосый: 4265 - авт. одноголосый: 5409
- неизвестно: 2800 - неизвестно: 2800
- проф. двухголосый: 2153 - проф. двухголосый: 2339
- одноголосый: 2126 - одноголосый: 2126
- люб. многоголосый: 1381 - люб. многоголосый: 1701
- многоголосый: 824 - многоголосый: 824
- люб. одноголосый: 646 - люб. двухголосый: 731
- люб. двухголосый: 551 - люб. одноголосый: 694
- люб. дубляж: 380 - люб. дубляж: 380
- проф. одноголосый: 293 - проф. одноголосый: 350
- двухголосый: 192 - двухголосый: 192
- авт. двухголосый: 3 - авт. двухголосый: 3
- авт. многоголосый: 2 - авт. многоголосый: 2
+73 -11
View File
@@ -6,12 +6,19 @@
Публичный API: Публичный API:
detect_tracker(url) — 'rutracker' | 'kinozal' | 'nnm' | ValueError detect_tracker(url) — 'rutracker' | 'kinozal' | 'nnm' | ValueError
fetch_html(url, ...) — скачать страницу, вернуть (html, tracker) fetch_html(url, ...) — скачать страницу, вернуть (html, tracker)
kinozal_tabs(html) — вкладки kinozal, которые грузятся JS (Релиз, …)
inline_tabs(html, t) — вписать скачанные вкладки в блок #tabs
decode_bytes(data) — байты -> текст (utf-8 -> cp1251, чинит «двойную») decode_bytes(data) — байты -> текст (utf-8 -> cp1251, чинит «двойную»)
kinozal: описание многих раздач открывается и без входа (cookie необязателен). Если трекер kinozal: описание многих раздач открывается и без входа (cookie необязателен). Если трекер
вернул форму входа, передайте cookie (`cookie="uid=...; pass=..."`) либо скачайте страницу сами вернул форму входа, передайте cookie (`cookie="uid=...; pass=..."`) либо скачайте страницу сами
(grabber уже умеет) и отдайте сервису готовый `html`. (grabber уже умеет) и отдайте сервису готовый `html`.
На kinozal во встроенном блоке #tabs только «Техданные» (там «Перевод:» без
студий), а «Релиз» со строками «Аудио N: … (MVO, LostFilm)» грузится JS:
`showtab(id, N)` -> `get_srv_details.php?id=…&pagesd=N`. fetch_html скачивает
эти вкладки и дописывает их в #tabs, чтобы описание разбиралось целиком.
Cloudflare / защита от ботов: если задан FlareSolverr (ваш собственный Cloudflare / защита от ботов: если задан FlareSolverr (ваш собственный
экземпляр, как у Jackett/Prowlarr) — `flaresolverr="http://host:8191"` или экземпляр, как у Jackett/Prowlarr) — `flaresolverr="http://host:8191"` или
переменная окружения DUB_FLARESOLVERR_URL, — то при ответе 403/503 или переменная окружения DUB_FLARESOLVERR_URL, — то при ответе 403/503 или
@@ -23,10 +30,13 @@ from __future__ import annotations
import json import json
import os import os
import re
import urllib.error import urllib.error
import urllib.request import urllib.request
from urllib.parse import urlparse from urllib.parse import urlparse
from bs4 import BeautifulSoup
from .html_extract import _fix_mojibake from .html_extract import _fix_mojibake
TRACKER_HOSTS = { TRACKER_HOSTS = {
@@ -127,32 +137,84 @@ def _fetch_flaresolverr(endpoint: str, url: str, timeout: float,
return data["solution"]["response"] return data["solution"]["response"]
def fetch_html(url: str, *, timeout: float = 15.0, cookie: str | None = None, def _get(url: str, timeout: float, cookie: str | None,
flaresolverr: str | None = None) -> tuple[str, str]: flaresolverr: str | None) -> str:
"""Скачивает страницу релиза. Возвращает (html, tracker). """Прямой запрос; при 403/503 или странице-заглушке — через FlareSolverr."""
html = None
Сначала прямой запрос; при 403/503 или странице-заглушке и заданном
FlareSolverr — повтор через него.
"""
tracker = detect_tracker(url)
flaresolverr = flaresolverr or os.environ.get("DUB_FLARESOLVERR_URL") or None
html, direct_error = None, None
try: try:
html = _fetch_direct(url, timeout, cookie) html = _fetch_direct(url, timeout, cookie)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code not in RETRY_CODES or not flaresolverr: if e.code not in RETRY_CODES or not flaresolverr:
raise raise
direct_error = e
if html is not None and blocked_reason(html) and flaresolverr: if html is not None and blocked_reason(html) and flaresolverr:
html = None html = None
if html is None: if html is None:
if not flaresolverr: if not flaresolverr:
raise ValueError("страница недоступна") raise ValueError("страница недоступна")
html = _fetch_flaresolverr(flaresolverr, url, timeout, cookie) html = _fetch_flaresolverr(flaresolverr, url, timeout, cookie)
return html
# «showtab(2155882,0)»; 100 — «Техданные», они уже встроены в страницу
RE_KZ_TAB = re.compile(r"showtab\((\d+)\s*,\s*(\d+)\)[^>]*>([^<]*)<")
KZ_INLINE_TAB = 100
KZ_SKIP_TABS = ("скриншот",)
def kinozal_tabs(html: str) -> list[tuple[str, int, str]]:
"""Вкладки описания kinozal, которые грузятся JS: [(id, N, название)]."""
out, seen = [], set()
for rid, n, label in RE_KZ_TAB.findall(html):
n = int(n)
if n == KZ_INLINE_TAB or n in seen or label.strip().lower().startswith(KZ_SKIP_TABS):
continue
seen.add(n)
out.append((rid, n, label.strip()))
return out
def inline_tabs(html: str, tabs: list[tuple[str, str]]) -> str:
"""Дописывает фрагменты вкладок [(название, html)] в конец блока #tabs."""
soup = BeautifulSoup(html, "lxml")
node = soup.select_one("#tabs")
if node is None or not tabs:
return html
for label, fragment in tabs:
box = soup.new_tag("div", attrs={"class": "dub-tab", "data-tab": label})
frag = BeautifulSoup(fragment, "lxml")
for child in list((frag.body or frag).contents):
box.append(child)
node.append(box)
return str(soup)
def fetch_html(url: str, *, timeout: float = 15.0, cookie: str | None = None,
flaresolverr: str | None = None) -> tuple[str, str]:
"""Скачивает страницу релиза. Возвращает (html, tracker).
Сначала прямой запрос; при 403/503 или странице-заглушке и заданном
FlareSolverr — повтор через него. Для kinozal дополнительно скачиваются
вкладки описания («Релиз», «Внимание»); недоступная вкладка пропускается.
"""
tracker = detect_tracker(url)
flaresolverr = flaresolverr or os.environ.get("DUB_FLARESOLVERR_URL") or None
html = _get(url, timeout, cookie, flaresolverr)
reason = blocked_reason(html) reason = blocked_reason(html)
if reason: if reason:
hint = "" if flaresolverr else " (задайте FlareSolverr: DUB_FLARESOLVERR_URL)" hint = "" if flaresolverr else " (задайте FlareSolverr: DUB_FLARESOLVERR_URL)"
raise ValueError(f"страница недоступна: {reason}{hint}") raise ValueError(f"страница недоступна: {reason}{hint}")
if tracker == "kinozal":
parsed = urlparse(url)
tabs = []
for rid, n, label in kinozal_tabs(html):
tab_url = f"{parsed.scheme}://{parsed.netloc}/get_srv_details.php?id={rid}&pagesd={n}"
try:
frag = _get(tab_url, timeout, cookie, flaresolverr)
except (urllib.error.URLError, ValueError, OSError):
continue
if not blocked_reason(frag):
tabs.append((label, frag))
html = inline_tabs(html, tabs)
return html, tracker return html, tracker
+116 -25
View File
@@ -149,7 +149,6 @@ def aggregate(tracks: list[dict]) -> dict:
} }
def original_info(lines: list[dict]) -> dict | None: def original_info(lines: list[dict]) -> dict | None:
"""Оригинальная дорожка из строки «Аудио … |Original|» (без студии и комментариев); нет — None.""" """Оригинальная дорожка из строки «Аудио … |Original|» (без студии и комментариев); нет — None."""
for ln in lines: for ln in lines:
@@ -198,17 +197,45 @@ def series_info(title: str, tracks: list[dict]) -> dict | None:
if t.get("studio") and not t.get("episodes")})} if t.get("studio") and not t.get("episodes")})}
def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None: def _studio_tokens(name: str) -> set[str]:
"""Добавляет дорожки из заголовка к дорожкам страницы (на месте). return {w for w in re.findall(r"\w+", name.casefold()) if len(w) >= 4}
* студия уже есть на странице -> пропуск;
* на странице есть дорожка того же типа без студии -> студия берётся def same_studio(a: str | None, b: str | None) -> bool:
из заголовка (source=page+title): это та же дорожка; """Одна студия в разном написании: «DVD» ~ «DVD Магия», «НТВ» ~ «НТВ+»,
* иначе -> новая дорожка (source=title). «Михаил Яроцкий» ~ «М.Яроцкий/Kyberpunk» (общее слово ≥ 4 букв)."""
a, b = (a or "").casefold().strip(), (b or "").casefold().strip()
if not a or not b:
return False
return a in b or b in a or bool(_studio_tokens(a) & _studio_tokens(b))
def merge_extra_tracks(tracks: list[dict], extra: list[dict], source: str,
match=lambda a, b: (a or "").casefold() == (b or "").casefold(),
adopt_type: bool = False) -> None:
"""Добавляет дорожки второстепенного источника к дорожкам страницы (на месте).
* такая студия уже есть (по `match`) -> пропуск;
* есть дорожка того же типа без студии -> студия вписывается в неё
(source=page+<source>): это та же дорожка;
* иначе -> новая дорожка (source=<source>).
Дорожки без студии не берутся: тип без студии страница уже даёт, а в
кодек-строках он часто грубее («одноголосый» вместо «авт. одноголосый»).
Дорожка с типом «неизвестно» («Аудио Rus: AC3 … | LostFilm») берётся
только при `adopt_type` и если у страницы ровно один тип без студии
(«Перевод: Профессиональный многоголосый»): она получает этот тип.
""" """
for t in title_tracks: bare = {x["translation_type"] for x in tracks
name = (t.get("studio") or "").casefold() if not x.get("studio") and x["translation_type"] != UNKNOWN_TYPE}
if any((x.get("studio") or "").casefold() == name for x in tracks): for t in extra:
if not t.get("studio"):
continue
if t["translation_type"] == UNKNOWN_TYPE:
if not (adopt_type and len(bare) == 1):
continue
t["translation_type"] = next(iter(bare))
if any(match(x.get("studio"), t["studio"]) for x in tracks):
continue continue
same = next((x for x in tracks if not x.get("studio") same = next((x for x in tracks if not x.get("studio")
and x["translation_type"] == t["translation_type"]), None) and x["translation_type"] == t["translation_type"]), None)
@@ -216,26 +243,75 @@ def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None:
same.update(studio=t["studio"], studio_span=t["studio_span"], same.update(studio=t["studio"], studio_span=t["studio_span"],
studio_confidence=t["studio_confidence"], studio_confidence=t["studio_confidence"],
is_18plus=same["is_18plus"] or t["is_18plus"], is_18plus=same["is_18plus"] or t["is_18plus"],
source="page+title") source=f"page+{source}")
else: else:
t["source"] = "title" t["source"] = source
tracks.append(t) tracks.append(t)
def model_segments(segmenter, page_text: str) -> list[str]: def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None:
"""Дорожки из заголовка («ПМ (LostFilm)») -> к дорожкам страницы."""
merge_extra_tracks(tracks, title_tracks, "title")
# кодек-строка: «Аудио 3: AC3 …», «Аудио Rus: …», «Аудиодорожка 1: …», «Audio #2: …»
RE_AUDIO_PREFIX = re.compile(r"(?i)^\s*(?:Аудио\w*|Audio)\s*(?:№|#)?\s*(?:\d+|rus|ukr|eng)?\s*[:.]")
# с типом озвучки: «Аудио 3: AC3 … (MVO, HDrezka Studio)»; «English Dubbing» — не тип
RE_CODEC_TRACK = re.compile(
r"(?i)^\s*(?:Аудио|Audio)\s*(?:№|#)?\s*\d*\s*:.*(?:\b(?:MVO|DVO|AVO|DUB|VO)\b"
r"|дубл|многоголос|двухголос|одноголос|закадр|авторск)")
RE_RU_TRACK = re.compile(r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b")
RE_FOREIGN_TRACK = re.compile(
r"(?i)казах|english|англ|француз|немец|немецк|итальян|испан|японск|корейск|китайск"
r"|\boriginal\b|оригинал|\beng\b")
def codec_ok(line: str) -> bool:
"""Кодек-строка русской (украинской) дорожки: «Казахский (Dub, TK Qazaqstan)»,
«English Dubbing», «Original» — нет."""
if RE_RU_TRACK.search(line):
return True
return not RE_FOREIGN_TRACK.search(line) and bool(RE_CODEC_TRACK.match(line))
def _entries(line: str) -> list[str]:
body = RE_LINE_PREFIX.sub("", line, count=1)
return [e for e in (re.sub(r"\s+", " ", x).strip() for x in _split_entries(body)) if e]
def codec_segments(page_text: str) -> list[str]:
"""Кодек-строки с типом озвучки («Аудио 2: AC3 … (MVO, LostFilm)») по правилу.
Запасной путь к тому, что выбирает сегментатор (`model_segments`): старые
веса сегментатора кодек-строки отбрасывают. Они нужны там, где студии есть
только в них (kinozal: вкладка «Релиз» против «Перевод:» без студий в
«Техданных»), и идут второстепенным источником, см. `merge_extra_tracks`.
"""
out = []
for line in (page_text or "").splitlines():
if RE_CODEC_TRACK.match(line) and codec_ok(line):
out += _entries(line)
return out
def model_segments(segmenter, page_text: str) -> tuple[list[str], list[str]]:
"""Дорожки страницы: строки выбирает обученный сегментатор. """Дорожки страницы: строки выбирает обученный сегментатор.
Возвращает (основные, кодек): строки «Перевод/Озвучка …» и кодек-строки
«Аудио N: AC3 … (MVO, LostFilm)». Кодек-строки обычно дублируют основные
в другом написании, поэтому они второстепенный источник (`predict_release`);
если основных нет (nnm: дорожки только в «Аудио N:») — они и есть дорожки.
Нарезка внутри строки («Дублированный, многоголосый») в данных не Нарезка внутри строки («Дублированный, многоголосый») в данных не
размечена и пока делается правилами `_split_entries`. размечена и пока делается правилами `_split_entries`.
""" """
out = [] main, codec = [], []
for line in segmenter.select_lines(page_text): for line in segmenter.select_lines(page_text):
body = RE_LINE_PREFIX.sub("", line, count=1) if not RE_AUDIO_PREFIX.match(line):
for entry in _split_entries(body): main += _entries(line)
entry = re.sub(r"\s+", " ", entry).strip() elif codec_ok(line):
if entry: codec += _entries(line)
out.append(entry) return main, codec
return out
def predict_release(predictor, *, html: str | None = None, text: str | None = None, def predict_release(predictor, *, html: str | None = None, text: str | None = None,
@@ -246,17 +322,19 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
(если он загружен и нашёл хотя бы одну строку), иначе правила; "rules" — (если он загружен и нашёл хотя бы одну строку), иначе правила; "rules" —
только правила. только правила.
Заголовок раздачи используется как дополнительный источник: студии, Кодек-строки («Аудио N: … (MVO, LostFilm)») и заголовок раздачи —
названные только там («ПМ (LostFilm)»), добавляются как дорожки с дополнительные источники: студии, названные только там, добавляются как
пометкой source=title. дорожки с пометкой source=codec / source=title.
""" """
if (html is None) == (text is None): if (html is None) == (text is None):
raise ValueError("нужно ровно одно из: html, text") raise ValueError("нужно ровно одно из: html, text")
page_text = html_to_text(html) if html is not None else text page_text = html_to_text(html) if html is not None else text
segs_page, used = [], "rules" segs_page, segs_codec, used = [], [], "rules"
seg_model = getattr(predictor, "segmenter", None) seg_model = getattr(predictor, "segmenter", None)
if segmenter == "model" and seg_model is not None: if segmenter == "model" and seg_model is not None:
segs_page = model_segments(seg_model, page_text) segs_page, segs_codec = model_segments(seg_model, page_text)
if not segs_page:
segs_page, segs_codec = segs_codec, []
used = "model" if segs_page else "rules" used = "model" if segs_page else "rules"
if not segs_page: if not segs_page:
segs_page = extract_segments(page_text) segs_page = extract_segments(page_text)
@@ -272,6 +350,19 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
ep = parse_episodes(t["segment"]) ep = parse_episodes(t["segment"])
t["episode_ranges"] = ep["ranges"] t["episode_ranges"] = ep["ranges"]
t["episodes"] = format_ranges(ep["ranges"]) or None t["episodes"] = format_ranges(ep["ranges"]) or None
# кодек-строки — только если страница не назвала студию хотя бы у одной
# дорожки (kinozal: «Перевод: многоголосый» + «Аудио 3: … (MVO, LostFilm)»);
# иначе они дублируют «Перевод N:» в другом написании и дают ложные студии
seen = set(segs_page)
segs_codec = list(dict.fromkeys(x for x in segs_codec + codec_segments(page_text)
if x not in seen))
if segs_codec and (not tracks or any(not t.get("studio") for t in tracks)):
codec = predictor._predict_batch(segs_codec)
for t in codec:
ep = parse_episodes(t["segment"])
t["episode_ranges"] = ep["ranges"]
t["episodes"] = format_ranges(ep["ranges"]) or None
merge_extra_tracks(tracks, codec, "codec", match=same_studio, adopt_type=True)
if title is None: if title is None:
title = title_from_html(html) if html is not None else title_from_text(text) title = title_from_html(html) if html is not None else title_from_text(text)
segs = title_segments(title or "") segs = title_segments(title or "")
+82 -8
View File
@@ -11,8 +11,10 @@ Python API:
seg = LineSegmenter("models/segmenter") seg = LineSegmenter("models/segmenter")
seg.select_lines(page_text) -> list[str] # строки-дорожки (префикс не снят) seg.select_lines(page_text) -> list[str] # строки-дорожки (префикс не снят)
Разметка: строка страницы положительна, если содержит `raw` строки датасета Разметка: строка страницы положительна, если содержит `raw` (или `audio_raw`)
этого релиза (или наоборот). Положительные примеры добавляются синтетически: строки датасета этого релиза (или наоборот); неразмеченные кодек-строки русских
дорожок в обучение не идут (см. `label_page`). Текст kinozal собирается из
data/raw/kinozal/*.json (`kinozal_text`). Положительные примеры добавляются синтетически:
`raw` всех train-строк датасета (включая nnm/kinozal, у которых нет страниц) `raw` всех train-строк датасета (включая nnm/kinozal, у которых нет страниц)
вставляются в контекст реальных строк страниц. Нарезка ВНУТРИ строки вставляются в контекст реальных строк страниц. Нарезка ВНУТРИ строки
(«Дублированный, многоголосый») в данных не размечена и остаётся на правилах. («Дублированный, многоголосый») в данных не размечена и остаётся на правилах.
@@ -69,8 +71,34 @@ def page_lines(text: str) -> list[str]:
# ---------------------------------------------------------------- данные # ---------------------------------------------------------------- данные
RAW_KINOZAL = Path("data/raw/kinozal")
def kinozal_text(release_id: str, raw_dir: Path = RAW_KINOZAL) -> str | None:
"""Текст страницы kinozal так, как его видит сервис: заголовок + «Техданные»
(«Перевод: …») + «Релиз» («Аудио N: … (MVO, LostFilm)»).
В data/pages/kz-*.txt нет ни заголовка, ни «Техданных», а метки kinozal
наполовину взяты именно оттуда, поэтому для kinozal берётся raw JSON.
"""
path = raw_dir / f"{str(release_id).removeprefix('kz-')}.json"
if not path.exists():
return None
j = json.loads(path.read_text(encoding="utf-8"))
return "\n".join(x for x in (j.get("h1"), j.get("tech_tab"), j.get("release_tab")) if x)
def page_text(path: Path) -> str:
"""Текст сохранённой страницы релиза (kinozal — из raw JSON, если он есть)."""
if path.stem.startswith("kz-"):
text = kinozal_text(path.stem, path.parent.parent / "raw" / "kinozal")
if text:
return text
return read_input(path)
def load_pages(pages_dir: Path = Path("data/pages")) -> dict[str, list[str]]: def load_pages(pages_dir: Path = Path("data/pages")) -> dict[str, list[str]]:
return {p.stem: page_lines(read_input(p)) for p in sorted(pages_dir.glob("*.txt"))} return {p.stem: page_lines(page_text(p)) for p in sorted(pages_dir.glob("*.txt"))}
def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]: def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]:
@@ -78,12 +106,53 @@ def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]:
return list(csv.DictReader(f)) return list(csv.DictReader(f))
# кодек-строка русской/украинской дорожки: «Аудио 2: AC3 … Русский (MVO, X)»,
# «Аудио Rus: AC3 … | LostFilm», «Аудиодорожка 1: … | Russian | TVShows»;
# сводка «Аудио: Русский, английский (AC3 …)» без номера — не дорожка
RE_AUDIO_TRACK = re.compile(
r"(?i)^\s*(?:(?:Аудио|Audio)\s*(?:rus|ukr)\s*[:.]"
r"|(?:Аудиодорожка\s*\d*|(?:Аудио|Audio)\s*(?:№|#)?\s*\d+)\s*[:.]"
r".*(?:русск|russian|\brus\b|украин|ukrain|\bukr\b))")
RE_TYPE_WORD = re.compile(
r"(?i)\b(?:MVO|DVO|AVO|DUB|VO)\b|дубл|многоголос|двухголос|одноголос|закадр|авторск")
# технические слова кодек-строки: всё, что после их удаления осталось, — имя студии/автора
RE_TECH_WORD = re.compile(
r"(?i)^(?:аудио\w*|audio|e?-?ac-?3|eac3|ac|dts|hd|ma|mpeg|mp3|aac|lc|opus|flac|pcm|lpcm|truehd"
r"|atmos|joc|dolby|digital|surround|stereo|mono|cbr|vbr|avg|constant|core|bit|ch|сh|channels?"
r"|каналов|канала|kbps|kb|кбит|бит|сек|khz|кгц|hz|lfe|dn|db|rus|ukr|eng|russian|русск\w*"
r"|украин\w*|english|англ\w*|l|c|r|sl|sr|ls|rs|lr|cbr|a)$")
def _informative(line: str) -> bool:
"""Кодек-строка называет тип озвучки или студию/автора, а не только параметры.
«Аудио 1: AC3, 6 ch, 384 kbps - Русский» — нет; «… | LostFilm»,
«… - Русский, Юрий Живов», «… (MVO)» — да.
"""
if RE_TYPE_WORD.search(line):
return True
body = re.sub(r"(?i)^\s*(?:Аудио\w*|Audio)\s*(?:№|#)?\s*(?:\d+|rus|ukr)?\s*[:.]", "", line)
words = [w for w in re.findall(r"[^\W\d_]+", body) if not RE_TECH_WORD.match(w)]
return any(len(w) >= 3 for w in words)
def label_page(lines: list[str], raws: list[str]) -> list[int]: def label_page(lines: list[str], raws: list[str]) -> list[int]:
"""1, если строка содержит raw (или raw содержит строку ≥ 15 символов).""" """1 — строка содержит raw (или raw содержит строку ≥ 15 символов);
-1 — неразмеченная кодек-строка русской дорожки с типом или студией: в
обучение не идёт; пустая кодек-строка («AC3, 6 ch, 384 kbps - Русский») — 0.
Кодек-строка «Аудио N: … (MVO, LostFilm)» — дорожка, но у rutracker
`raw` — строка «Перевод N:», а кодек-строка в разметку не попадает. Как
негатив она учила модель отбрасывать такие строки (на kinozal студии
только в них), поэтому неразмеченные кодек-строки пропускаются.
"""
out = [] out = []
for line in lines: for line in lines:
hit = any(r and (r in line or (len(line) >= 15 and line in r)) for r in raws) hit = any(r and (r in line or (len(line) >= 15 and line in r)) for r in raws)
out.append(int(hit)) skip = RE_AUDIO_TRACK.match(line) and _informative(line)
out.append(1 if hit else (-1 if skip else 0))
return out return out
@@ -93,7 +162,10 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
rows = load_raws() rows = load_raws()
raws_by_rel: dict[str, list[str]] = {} raws_by_rel: dict[str, list[str]] = {}
for r in rows: for r in rows:
raws_by_rel.setdefault(r["release_id"], []).append(norm(r["raw"])) # audio_raw — кодек-строка той же дорожки («AC3 … Русский (MVO, X)»), тоже позитив
for text in (r["raw"], r.get("audio_raw")):
if norm(text):
raws_by_rel.setdefault(r["release_id"], []).append(norm(text))
want_test = split == "test" want_test = split == "test"
page_items = [] # (release_id, lines, labels) page_items = [] # (release_id, lines, labels)
@@ -105,7 +177,7 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
# все raw train-релизов (без тестовых страниц) — материал для синтетики # все raw train-релизов (без тестовых страниц) — материал для синтетики
test_pos = {l for rid, lines in pages.items() if is_test_release(rid) test_pos = {l for rid, lines in pages.items() if is_test_release(rid)
for l, y in zip(lines, label_page(lines, raws_by_rel.get(rid, []))) if y} for l, y in zip(lines, label_page(lines, raws_by_rel.get(rid, []))) if y == 1}
pool = sorted({norm(r["raw"]) for r in rows pool = sorted({norm(r["raw"]) for r in rows
if r["raw"] and not is_test_release(r["release_id"]) if r["raw"] and not is_test_release(r["release_id"])
and norm(r["raw"]) not in test_pos}) and norm(r["raw"]) not in test_pos})
@@ -113,13 +185,15 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
ex: list[tuple] = [] ex: list[tuple] = []
for rid, lines, labels in page_items: for rid, lines, labels in page_items:
for i, (line, y) in enumerate(zip(lines, labels)): for i, (line, y) in enumerate(zip(lines, labels)):
if y < 0:
continue # неразмеченная кодек-строка: ни позитив, ни негатив
prev = lines[i - 1] if i else "" prev = lines[i - 1] if i else ""
nxt = lines[i + 1] if i + 1 < len(lines) else "" nxt = lines[i + 1] if i + 1 < len(lines) else ""
if not y and not HINT.search(line) and rng.random() > 0.15: if not y and not HINT.search(line) and rng.random() > 0.15:
continue # простые негативы прореживаем, трудные (с намёками) берём все continue # простые негативы прореживаем, трудные (с намёками) берём все
ex.append((*make_input(prev, line, nxt), y)) ex.append((*make_input(prev, line, nxt), y))
if not want_test: if not want_test:
negs = [l for l, y in zip(lines, labels) if not y] negs = [l for l, y in zip(lines, labels) if y == 0]
for _ in range(synth_per_page if negs else 0): for _ in range(synth_per_page if negs else 0):
raw = rng.choice(pool) raw = rng.choice(pool)
p, n = rng.choice(negs), rng.choice(negs) p, n = rng.choice(negs), rng.choice(negs)
+26 -10
View File
@@ -2,24 +2,29 @@
Метрика — пары (тип, автор) на уровне релиза против меток датасета, только по Метрика — пары (тип, автор) на уровне релиза против меток датасета, только по
страницам, которых сегментатор не видел при обучении (is_test_release). страницам, которых сегментатор не видел при обучении (is_test_release).
Запуск: .venv/Scripts/python tests/e2e_segmenter.py Текст kinozal — из data/raw/kinozal/*.json (как у сервиса), см. segmenter.page_text.
На трекер берётся не больше E2E_MAX страниц (по умолчанию 400, детерминированно);
train-страницы считаются только с флагом --train-pages.
Запуск: .venv/Scripts/python tests/e2e_segmenter.py [каталог_сегментатора] [--train-pages]
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import os
import sys import sys
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.html_extract import read_input # noqa: E402
from src.predict import DubPredictor # noqa: E402 from src.predict import DubPredictor # noqa: E402
from src.release import predict_release # noqa: E402 from src.release import predict_release # noqa: E402
from src.segmenter import is_test_release # noqa: E402 from src.segmenter import is_test_release, page_text # noqa: E402
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
MAX_PER_SOURCE = int(os.environ.get("E2E_MAX", "400"))
def run(predictor, rows, mode: str, test_only: bool) -> dict: def run(predictor, rows, mode: str, test_only: bool) -> dict:
@@ -27,17 +32,25 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict:
for r in rows: for r in rows:
by_rel[r["release_id"]].append(r) by_rel[r["release_id"]].append(r)
per_src = defaultdict(list) per_src = defaultdict(list)
for page in sorted((ROOT / "data" / "pages").glob("*.txt")): pages = [p for p in (ROOT / "data" / "pages").glob("*.txt")
if is_test_release(p.stem) == test_only and p.stem in by_rel]
pages.sort(key=lambda p: hashlib.md5(p.stem.encode()).hexdigest())
for page in pages:
rid = page.stem rid = page.stem
if is_test_release(rid) != test_only or rid not in by_rel: src = by_rel[rid][0]["source"]
if len(per_src[src]) >= MAX_PER_SOURCE:
continue continue
gold = {(r["translation_type"], r["studio"] or None) for r in by_rel[rid]} gold = {(r["translation_type"], r["studio"] or None) for r in by_rel[rid]}
res = predict_release(predictor, text=read_input(page), segmenter=mode) # как aggregate: «тип без студии» не отдельная дорожка, если у типа есть студия
# (kinozal: «Перевод: многоголосый» + студии из заголовка)
named = {t for t, s in gold if s}
gold = {(t, s) for t, s in gold if s or t not in named}
res = predict_release(predictor, text=page_text(page), segmenter=mode)
pred = {(t["translation_type"], t["studio"]) for t in res["tracks"]} pred = {(t["translation_type"], t["studio"]) for t in res["tracks"]}
tp = len(gold & pred) tp = len(gold & pred)
p = tp / len(pred) if pred else 0.0 p = tp / len(pred) if pred else 0.0
r = tp / len(gold) if gold else 0.0 r = tp / len(gold) if gold else 0.0
per_src[by_rel[rid][0]["source"]].append((p, r)) per_src[src].append((p, r))
out = {} out = {}
for s, v in per_src.items(): for s, v in per_src.items():
out[s] = (len(v), sum(x[0] for x in v) / len(v), sum(x[1] for x in v) / len(v)) out[s] = (len(v), sum(x[0] for x in v) / len(v), sum(x[1] for x in v) / len(v))
@@ -46,13 +59,16 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict:
def main() -> None: def main() -> None:
rows = json.loads((ROOT / "data" / "processed" / "dataset.json").read_text(encoding="utf-8")) rows = json.loads((ROOT / "data" / "processed" / "dataset.json").read_text(encoding="utf-8"))
seg_dir = sys.argv[1] if len(sys.argv) > 1 else "models/segmenter" args = [a for a in sys.argv[1:] if not a.startswith("--")]
seg_dir = args[0] if args else "models/segmenter"
predictor = DubPredictor(segmenter_dir=seg_dir) predictor = DubPredictor(segmenter_dir=seg_dir)
print(f"сегментатор: {seg_dir}") print(f"сегментатор: {seg_dir}")
if predictor.segmenter is None: if predictor.segmenter is None:
sys.exit("нет models/segmenter: python -m src.segmenter --train") sys.exit("нет models/segmenter: python -m src.segmenter --train")
for test_only, name in ((True, "тестовые страницы (не виделись сегментатору)"), blocks = [(True, "тестовые страницы (не виделись сегментатору)")]
(False, "train-страницы (для справки, оптимистично)")): if "--train-pages" in sys.argv:
blocks.append((False, "train-страницы (для справки, оптимистично)"))
for test_only, name in blocks:
print(f"== {name} ==") print(f"== {name} ==")
for mode in ("rules", "model"): for mode in ("rules", "model"):
res = run(predictor, rows, mode, test_only) res = run(predictor, rows, mode, test_only)
+87
View File
@@ -219,6 +219,74 @@ check("title: студия вписана в дорожку без студии"
check("title: дубль студии пропущен, новая добавлена", check("title: дубль студии пропущен, новая добавлена",
len(_page) == 3 and _page[2]["source"] == "title") len(_page) == 3 and _page[2]["source"] == "title")
from src.fetch import inline_tabs, kinozal_tabs # noqa: E402
from src.release import codec_segments, merge_extra_tracks, same_studio # noqa: E402
_kz = ('<ul><li><a onclick="showtab(7,100); return false;" href="#">Техданные</a></li>'
'<li><a onclick="showtab(7,0); return false;" href="#">Релиз</a></li>'
'<li><a onclick="showtab(7,2); return false;" href="#">Скриншоты</a></li></ul>'
'<div id="tabs"><b>Перевод:</b> Профессиональный многоголосый</div>')
check("kinozal: вкладки без «Техданных» и скриншотов", kinozal_tabs(_kz) == [("7", 0, "Релиз")])
_kz_text = html_to_text(inline_tabs(_kz, [("Релиз", "Аудио 1: AC3 - Русский (MVO, LostFilm)<br>")]))
check("kinozal: вкладка «Релиз» попадает в текст",
"Перевод: Профессиональный многоголосый" in _kz_text and "(MVO, LostFilm)" in _kz_text)
check("codec_segments: русская дорожка с типом, без иноязычной",
codec_segments("Аудио 3: AC3, 2.0 ch - Русский (MVO, HDrezka Studio)\n"
"Аудио 6: E-AC3, 5.1 ch, 640 Kbps - English Dubbing\n"
"Перевод: Профессиональный") == ["AC3, 2.0 ch - Русский (MVO, HDrezka Studio)"])
check("same_studio: варианты написания",
same_studio("DVD", "DVD Магия") and same_studio("Михаил Яроцкий", "М.Яроцкий/Kyberpunk")
and not same_studio("LostFilm", "HDrezka"))
_page2 = [dict(_t("проф. многоголосый", None), studio_span=None, studio_confidence=None)]
merge_extra_tracks(_page2, [
dict(_t("проф. многоголосый", "HDrezka"), studio_span="HDrezka Studio", studio_confidence=1),
dict(_t("проф. многоголосый", "LostFilm"), studio_span="LostFilm", studio_confidence=1),
dict(_t("одноголосый", None), studio_span=None, studio_confidence=None)],
"codec", match=same_studio)
check("codec: студия вписана, новая добавлена, без студии — нет",
[(x["studio"], x["source"]) for x in _page2]
== [("HDrezka", "page+codec"), ("LostFilm", "codec")])
_page3 = [dict(_t("проф. многоголосый", None), studio_span=None, studio_confidence=None)]
merge_extra_tracks(_page3, [
dict(_t("неизвестно", "LostFilm"), studio_span="LostFilm", studio_confidence=1),
dict(_t("неизвестно", "TVShows"), studio_span="TVShows", studio_confidence=1)],
"codec", match=same_studio, adopt_type=True)
check("codec: «Аудио Rus: … | LostFilm» получает тип дорожки страницы",
[(x["translation_type"], x["studio"]) for x in _page3]
== [("проф. многоголосый", "LostFilm"), ("проф. многоголосый", "TVShows")])
from src.release import codec_ok, model_segments # noqa: E402
from src.segmenter import kinozal_text, label_page # noqa: E402
class _FakeSeg:
def select_lines(self, text):
return text.splitlines()
_main, _codec = model_segments(_FakeSeg(), "Перевод: Профессиональный многоголосый\n"
"Аудио 3: AC3 - Русский (MVO, LostFilm)\n"
"Аудио 6: AC3 - Казахский (Dub, TK Qazaqstan)")
check("model_segments: основные и кодек-строки раздельно, иноязычная отброшена",
_main == ["Профессиональный многоголосый"] and _codec == ["AC3 - Русский (MVO, LostFilm)"])
check("codec_ok: «English Dubbing» и «Original» — не русская дорожка",
not codec_ok("Аудио 6: E-AC3 - English Dubbing") and not codec_ok("Аудио 5: E-AC3 - Original (французский)")
and codec_ok("Аудио Rus: AC3, 2 ch | LostFilm"))
check("label_page: raw — 1, неразмеченная кодек-строка — -1, сводка «Аудио:» — 0",
label_page(["Перевод: Дублированный", "Аудио 2: AC3 - Русский (MVO, X)",
"Аудио: Русский, английский (AC3)", "Размер: 6 ГБ"], ["Перевод: Дублированный"])
== [1, -1, 0, 0])
import json # noqa: E402
import tempfile as _tmp # noqa: E402
with _tmp.TemporaryDirectory() as _d:
Path(_d, "123.json").write_text(json.dumps({"h1": "X / Y / 2026 / ПМ (LostFilm)",
"tech_tab": "Перевод: Профессиональный многоголосый",
"release_tab": "Аудио Rus: AC3 | LostFilm"}), encoding="utf-8")
check("kinozal_text: заголовок + Техданные + Релиз из raw JSON",
kinozal_text("kz-123", Path(_d)).splitlines()
== ["X / Y / 2026 / ПМ (LostFilm)", "Перевод: Профессиональный многоголосый", "Аудио Rus: AC3 | LostFilm"]
and kinozal_text("kz-404", Path(_d)) is None)
_dump = ("Меню\nГангстерленд / MobLand / 2025 / ПМ (LostFilm) / WEB\n" _dump = ("Меню\nГангстерленд / MobLand / 2025 / ПМ (LostFilm) / WEB\n"
"Гангстерленд / 2026 / ДБ (X) / WEB-DL") "Гангстерленд / 2026 / ДБ (X) / WEB-DL")
check("title_from_text: первая строка", check("title_from_text: первая строка",
@@ -353,6 +421,7 @@ check("series_info: сезон, серии, total и «студия -> сери
print("== сборщик датасета (tools/collect.py) ==") print("== сборщик датасета (tools/collect.py) ==")
import csv as _csv # noqa: E402 import csv as _csv # noqa: E402
import json as _json # noqa: E402 import json as _json # noqa: E402
import json # noqa: E402
import tempfile as _tmp # noqa: E402 import tempfile as _tmp # noqa: E402
from pathlib import Path as _P # noqa: E402 from pathlib import Path as _P # noqa: E402
@@ -857,6 +926,24 @@ check("prepare_data: у редкого языка метка пустая", [r["
print("== метки: тип, раскладка, строки из «Аудио», веса классов ==") print("== метки: тип, раскладка, строки из «Аудио», веса классов ==")
import harvest_core as _hc2 # noqa: E402 import harvest_core as _hc2 # noqa: E402
_ps = _hc2.nnm_part_studio
check("nnm «Перевод:»: студия из скобок и после тире",
_ps("Дублированный (Невафильм)") == "Невафильм" and _ps("Многоголосый профессиональный - Jaskier") == "Jaskier"
and _ps("Авторский (Живов") == "Юрий Живов")
check("nnm «Перевод:»: тип, кратность, лицензия, соседнее поле — не студия",
_ps("Двуголосый профессиональный") == "" and _ps("Одноголосый x2") == ""
and _ps("Дублированный (Лицензия BD CEE)") == ""
and _ps("одноголосый закадровый Дата мировой премьеры: 4 февраля 1983 (USA)") == "")
_kzr = _hc2.kz_title_rows(
{"id": "kz-1", "h1": "Что, если...? / What If...? / 2023 / ПМ (LostFilm, HDrezka Studio), СТ / WEB-DL"},
"Аудио Rus: AC3, 48 kHz, 2 ch, 384 Кбит/сек | LostFilm",
[{"studio": "(не указана)", "raw": "Перевод: Профессиональный многоголосый", "translation_type": "проф. многоголосый"}])
check("kinozal: студии из заголовка, строка «Аудио» — в audio_raw",
[(r["translation_type"], r["studio"], r["studio_source"], bool(r["audio_raw"])) for r in _kzr]
== [("проф. многоголосый", "LostFilm", "title", True), ("проф. многоголосый", "HDrezka", "title", False)])
check("kinozal: студия, уже названная на странице, из заголовка не дублируется",
_hc2.kz_title_rows({"id": "kz-1", "h1": "X / Y / 2023 / ПМ (LostFilm)"}, "",
[{"studio": "LostFilm", "raw": "Перевод: … LostFilm"}]) == [])
_tt = _hc2.translation_type _tt = _hc2.translation_type
check("тип: любительский дубляж — отдельный класс", _tt("Любительский (дублированный) Red Head Sound")[0] == "люб. дубляж") check("тип: любительский дубляж — отдельный класс", _tt("Любительский (дублированный) Red Head Sound")[0] == "люб. дубляж")
check("тип: псевдодубляж -> люб. дубляж", _tt("Любительский (псевдодублированный)")[0] == "люб. дубляж") check("тип: псевдодубляж -> люб. дубляж", _tt("Любительский (псевдодублированный)")[0] == "люб. дубляж")
+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() 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]: def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
post = rec.get("post") or "" post = rec.get("post") or ""
title = rec.get("page_title") or rec.get("title") 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 ("неизвестно", "оригинал", "субтитры"): if ttype in ("неизвестно", "оригинал", "субтитры"):
continue continue
perevod_types.append(part) perevod_types.append(part)
# без имён в скобках студия неизвестна, но тип перевода ценен # студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
rows.append({ rows.append({
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title, "source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
"year": year, "quality": quality, "is_series": is_series, "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": "", "professional": pro, "role_raw": part, "actors": "",
"note": mark_18("", has_18(line)), "age_18": int(has_18(line)), "note": mark_18("", has_18(line)), "age_18": int(has_18(line)),
"codec": "", "channels": "", "khz": "", "kbps": "", "codec": "", "channels": "", "khz": "", "kbps": "",
"subtitles": "", "studio_source": "tech", "raw": line, "subtitles": "", "studio_source": "tech", "raw": line,
}) })
# если построчных «Аудио N» с именами не было — оставляем только типы # есть построчные «Аудио N» с именами — строки «Перевод:» (tech) не нужны; иначе
if any(r["studio"] != "(не указана)" for r in rows): # оставляем tech-строки: типы и студии, названные в «Перевод:»
rows = [r for r in rows if r["studio"] != "(не указана)"] 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 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 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): def _with_audio(core, texts):
"""Обёртка парсера: lang/audio_raw у строк; число переводов в rel — по итоговым строкам.""" """Обёртка парсера: lang/audio_raw у строк; число переводов в rel — по итоговым строкам."""
def parse(rec: dict) -> tuple[list[dict], dict]: def parse(rec: dict) -> tuple[list[dict], dict]:
@@ -1725,6 +1794,8 @@ def _with_audio(core, texts):
rows += audio_only_rows(rec, text, rows) rows += audio_only_rows(rec, text, rows)
elif core is _kz_parse_core: elif core is _kz_parse_core:
rows = kz_audio_only_rows(rec, text) rows = kz_audio_only_rows(rec, text)
if core is _kz_parse_core:
rows += kz_title_rows(rec, text, rows)
if rows: if rows:
rel["n_translations"], rel["ok"] = len(rows), True rel["n_translations"], rel["ok"] = len(rows), True
rel["studios"] = " | ".join(sorted({r["studio"] for r in rows})) 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()