Compare commits
9
Commits
5bd3b94b46
...
666caa60d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
666caa60d5 | ||
|
|
83bab13893 | ||
|
|
f91f6cb48e | ||
|
|
6470f882f1 | ||
|
|
099d33bdaa | ||
|
|
666a266b2f | ||
|
|
1f9ee46a0d | ||
|
|
6a87bc993c | ||
|
|
2eb4f8b72e |
No files matched your search
+7270
-3051
File diff suppressed because it is too large.
Load diff
+17
-7
@@ -73,13 +73,23 @@ BIO-разметка строится по символьным спанам ч
|
||||
используются, если `models/segmenter` нет или модель не нашла ни одной строки;
|
||||
режим можно форсировать: `predict_release(..., segmenter="rules")`.
|
||||
|
||||
* Обучение: `python -m src.segmenter --train` (~1 мин). Разметка строк — по
|
||||
`raw` из `labels.csv` (362 страницы в `data/pages`); синтетика: `raw` всех
|
||||
train-строк (в т.ч. nnm/kinozal без страниц) вставляются в контекст реальных
|
||||
строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест).
|
||||
* Метрики: строки (test) P 0.936 / R 0.994; e2e на непросмотренных страницах
|
||||
rutracker (n=64) — precision 0.904 → 0.946 против правил, recall 0.975 → 0.975
|
||||
(`tests/e2e_segmenter.py`; выборка мала, разброс большой).
|
||||
* Обучение: `python -m src.segmenter --train --out models/segmenter_v2` (или меню
|
||||
`tools/collect.py` → «2»). Разметка строк — по `raw` и `audio_raw` из `labels.csv`
|
||||
(страницы `data/pages`); синтетика: `raw` всех train-строк вставляются в контекст
|
||||
реальных строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест).
|
||||
* Текст kinozal берётся из `data/raw/kinozal/<id>.json` (`h1` + «Техданные» +
|
||||
«Релиз», `segmenter.page_text`): в `data/pages/kz-*.txt` нет ни заголовка, ни
|
||||
«Техданных», откуда взята половина меток 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`, но только на
|
||||
строках, выбранных моделью.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Списки разделов идут от новых к старым, поэтому чтение подряд даёт только свежие
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@
|
||||
| 1 baseline | сделано |
|
||||
| 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) — в разметке датасета студия из заголовка почти не нужна |
|
||||
| 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 Автор` и особенности разметки |
|
||||
| 9 переобучение | датасет вырос 3286 → 6864 строк; обе модели переобучены (см. ниже) |
|
||||
| 6 tiny2 + int8 | сделано: 29.3 МБ, parity 99.8% тип / 99.2% спан / 100% 18+ (`reports/onnx_parity_tiny2_int8.md`) |
|
||||
|
||||
+18
-18
@@ -1,28 +1,28 @@
|
||||
# Аудит датасета: data/labels.csv
|
||||
строк в CSV: 44677
|
||||
строк после подготовки: 35383 (train 31343 / test 4040)
|
||||
источники: {'kinozal': 6382, 'rutracker': 14342, 'nnm': 14659}
|
||||
уникальных нормализованных текстов: 18870
|
||||
групп (релиз ∪ одинаковый текст): 6681
|
||||
удалено: пустой текст 0, мусорный studio 155, не-озвучка (оригинал/субтитры) 6, противоречивые дубли 0 (групп-противоречий: 0), несколько дорожек в одной строке 20, лишние повторы (> 40 одинаковых) 16950
|
||||
спан автора найден: 24004 (67.8%), не найден: 231
|
||||
аугментировано морфологических вариантов: 7658
|
||||
дорожек 18+: 701 (из них без упоминания «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}
|
||||
строк в CSV: 48896
|
||||
строк после подготовки: 39418 (train 34183 / test 5235)
|
||||
источники: {'kinozal': 10412, 'rutracker': 14347, 'nnm': 14659}
|
||||
уникальных нормализованных текстов: 20260
|
||||
групп (релиз ∪ одинаковый текст): 6947
|
||||
удалено: пустой текст 0, мусорный studio 155, не-озвучка (оригинал/субтитры) 6, противоречивые дубли 0 (групп-противоречий: 0), несколько дорожек в одной строке 20, лишние повторы (> 40 одинаковых) 18196
|
||||
спан автора найден: 31214 (79.2%), не найден: 237
|
||||
аугментировано морфологических вариантов: 8720
|
||||
дорожек 18+: 781 (из них без упоминания «18» в тексте: 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
|
||||
- проф. дубляж: 9936
|
||||
- проф. многоголосый: 9831
|
||||
- авт. одноголосый: 4265
|
||||
- проф. многоголосый: 11399
|
||||
- проф. дубляж: 10468
|
||||
- авт. одноголосый: 5409
|
||||
- неизвестно: 2800
|
||||
- проф. двухголосый: 2153
|
||||
- проф. двухголосый: 2339
|
||||
- одноголосый: 2126
|
||||
- люб. многоголосый: 1381
|
||||
- люб. многоголосый: 1701
|
||||
- многоголосый: 824
|
||||
- люб. одноголосый: 646
|
||||
- люб. двухголосый: 551
|
||||
- люб. двухголосый: 731
|
||||
- люб. одноголосый: 694
|
||||
- люб. дубляж: 380
|
||||
- проф. одноголосый: 293
|
||||
- проф. одноголосый: 350
|
||||
- двухголосый: 192
|
||||
- авт. двухголосый: 3
|
||||
- авт. многоголосый: 2
|
||||
|
||||
+73
-11
@@ -6,12 +6,19 @@
|
||||
Публичный API:
|
||||
detect_tracker(url) — 'rutracker' | 'kinozal' | 'nnm' | ValueError
|
||||
fetch_html(url, ...) — скачать страницу, вернуть (html, tracker)
|
||||
kinozal_tabs(html) — вкладки kinozal, которые грузятся JS (Релиз, …)
|
||||
inline_tabs(html, t) — вписать скачанные вкладки в блок #tabs
|
||||
decode_bytes(data) — байты -> текст (utf-8 -> cp1251, чинит «двойную»)
|
||||
|
||||
kinozal: описание многих раздач открывается и без входа (cookie необязателен). Если трекер
|
||||
вернул форму входа, передайте cookie (`cookie="uid=...; pass=..."`) либо скачайте страницу сами
|
||||
(grabber уже умеет) и отдайте сервису готовый `html`.
|
||||
|
||||
На kinozal во встроенном блоке #tabs только «Техданные» (там «Перевод:» без
|
||||
студий), а «Релиз» со строками «Аудио N: … (MVO, LostFilm)» грузится JS:
|
||||
`showtab(id, N)` -> `get_srv_details.php?id=…&pagesd=N`. fetch_html скачивает
|
||||
эти вкладки и дописывает их в #tabs, чтобы описание разбиралось целиком.
|
||||
|
||||
Cloudflare / защита от ботов: если задан FlareSolverr (ваш собственный
|
||||
экземпляр, как у Jackett/Prowlarr) — `flaresolverr="http://host:8191"` или
|
||||
переменная окружения DUB_FLARESOLVERR_URL, — то при ответе 403/503 или
|
||||
@@ -23,10 +30,13 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .html_extract import _fix_mojibake
|
||||
|
||||
TRACKER_HOSTS = {
|
||||
@@ -127,32 +137,84 @@ def _fetch_flaresolverr(endpoint: str, url: str, timeout: float,
|
||||
return data["solution"]["response"]
|
||||
|
||||
|
||||
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 — повтор через него.
|
||||
"""
|
||||
tracker = detect_tracker(url)
|
||||
flaresolverr = flaresolverr or os.environ.get("DUB_FLARESOLVERR_URL") or None
|
||||
html, direct_error = None, None
|
||||
def _get(url: str, timeout: float, cookie: str | None,
|
||||
flaresolverr: str | None) -> str:
|
||||
"""Прямой запрос; при 403/503 или странице-заглушке — через FlareSolverr."""
|
||||
html = None
|
||||
try:
|
||||
html = _fetch_direct(url, timeout, cookie)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code not in RETRY_CODES or not flaresolverr:
|
||||
raise
|
||||
direct_error = e
|
||||
if html is not None and blocked_reason(html) and flaresolverr:
|
||||
html = None
|
||||
if html is None:
|
||||
if not flaresolverr:
|
||||
raise ValueError("страница недоступна")
|
||||
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)
|
||||
if reason:
|
||||
hint = "" if flaresolverr else " (задайте FlareSolverr: DUB_FLARESOLVERR_URL)"
|
||||
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
|
||||
|
||||
|
||||
|
||||
+116
-25
@@ -149,7 +149,6 @@ def aggregate(tracks: list[dict]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
|
||||
def original_info(lines: list[dict]) -> dict | None:
|
||||
"""Оригинальная дорожка из строки «Аудио … |Original|» (без студии и комментариев); нет — None."""
|
||||
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")})}
|
||||
|
||||
|
||||
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}
|
||||
|
||||
* студия уже есть на странице -> пропуск;
|
||||
* на странице есть дорожка того же типа без студии -> студия берётся
|
||||
из заголовка (source=page+title): это та же дорожка;
|
||||
* иначе -> новая дорожка (source=title).
|
||||
|
||||
def same_studio(a: str | None, b: str | None) -> bool:
|
||||
"""Одна студия в разном написании: «DVD» ~ «DVD Магия», «НТВ» ~ «НТВ+»,
|
||||
«Михаил Яроцкий» ~ «М.Яроцкий/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:
|
||||
name = (t.get("studio") or "").casefold()
|
||||
if any((x.get("studio") or "").casefold() == name for x in tracks):
|
||||
bare = {x["translation_type"] for x in tracks
|
||||
if not x.get("studio") and x["translation_type"] != UNKNOWN_TYPE}
|
||||
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
|
||||
same = next((x for x in tracks if not x.get("studio")
|
||||
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"],
|
||||
studio_confidence=t["studio_confidence"],
|
||||
is_18plus=same["is_18plus"] or t["is_18plus"],
|
||||
source="page+title")
|
||||
source=f"page+{source}")
|
||||
else:
|
||||
t["source"] = "title"
|
||||
t["source"] = source
|
||||
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`.
|
||||
"""
|
||||
out = []
|
||||
main, codec = [], []
|
||||
for line in segmenter.select_lines(page_text):
|
||||
body = RE_LINE_PREFIX.sub("", line, count=1)
|
||||
for entry in _split_entries(body):
|
||||
entry = re.sub(r"\s+", " ", entry).strip()
|
||||
if entry:
|
||||
out.append(entry)
|
||||
return out
|
||||
if not RE_AUDIO_PREFIX.match(line):
|
||||
main += _entries(line)
|
||||
elif codec_ok(line):
|
||||
codec += _entries(line)
|
||||
return main, codec
|
||||
|
||||
|
||||
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" —
|
||||
только правила.
|
||||
|
||||
Заголовок раздачи используется как дополнительный источник: студии,
|
||||
названные только там («ПМ (LostFilm)»), добавляются как дорожки с
|
||||
пометкой source=title.
|
||||
Кодек-строки («Аудио N: … (MVO, LostFilm)») и заголовок раздачи —
|
||||
дополнительные источники: студии, названные только там, добавляются как
|
||||
дорожки с пометкой source=codec / source=title.
|
||||
"""
|
||||
if (html is None) == (text is None):
|
||||
raise ValueError("нужно ровно одно из: html, 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)
|
||||
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"
|
||||
if not segs_page:
|
||||
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"])
|
||||
t["episode_ranges"] = ep["ranges"]
|
||||
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:
|
||||
title = title_from_html(html) if html is not None else title_from_text(text)
|
||||
segs = title_segments(title or "")
|
||||
|
||||
+82
-8
@@ -11,8 +11,10 @@ Python API:
|
||||
seg = LineSegmenter("models/segmenter")
|
||||
seg.select_lines(page_text) -> list[str] # строки-дорожки (префикс не снят)
|
||||
|
||||
Разметка: строка страницы положительна, если содержит `raw` строки датасета
|
||||
этого релиза (или наоборот). Положительные примеры добавляются синтетически:
|
||||
Разметка: строка страницы положительна, если содержит `raw` (или `audio_raw`)
|
||||
строки датасета этого релиза (или наоборот); неразмеченные кодек-строки русских
|
||||
дорожок в обучение не идут (см. `label_page`). Текст kinozal собирается из
|
||||
data/raw/kinozal/*.json (`kinozal_text`). Положительные примеры добавляются синтетически:
|
||||
`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]]:
|
||||
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]:
|
||||
@@ -78,12 +106,53 @@ def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]:
|
||||
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]:
|
||||
"""1, если строка содержит raw (или raw содержит строку ≥ 15 символов)."""
|
||||
"""1 — строка содержит raw (или raw содержит строку ≥ 15 символов);
|
||||
-1 — неразмеченная кодек-строка русской дорожки с типом или студией: в
|
||||
обучение не идёт; пустая кодек-строка («AC3, 6 ch, 384 kbps - Русский») — 0.
|
||||
|
||||
Кодек-строка «Аудио N: … (MVO, LostFilm)» — дорожка, но у rutracker
|
||||
`raw` — строка «Перевод N:», а кодек-строка в разметку не попадает. Как
|
||||
негатив она учила модель отбрасывать такие строки (на kinozal студии
|
||||
только в них), поэтому неразмеченные кодек-строки пропускаются.
|
||||
"""
|
||||
out = []
|
||||
for line in lines:
|
||||
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
|
||||
|
||||
|
||||
@@ -93,7 +162,10 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
|
||||
rows = load_raws()
|
||||
raws_by_rel: dict[str, list[str]] = {}
|
||||
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"
|
||||
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-релизов (без тестовых страниц) — материал для синтетики
|
||||
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
|
||||
if r["raw"] and not is_test_release(r["release_id"])
|
||||
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] = []
|
||||
for rid, lines, labels in page_items:
|
||||
for i, (line, y) in enumerate(zip(lines, labels)):
|
||||
if y < 0:
|
||||
continue # неразмеченная кодек-строка: ни позитив, ни негатив
|
||||
prev = lines[i - 1] if i else ""
|
||||
nxt = lines[i + 1] if i + 1 < len(lines) else ""
|
||||
if not y and not HINT.search(line) and rng.random() > 0.15:
|
||||
continue # простые негативы прореживаем, трудные (с намёками) берём все
|
||||
ex.append((*make_input(prev, line, nxt), y))
|
||||
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):
|
||||
raw = rng.choice(pool)
|
||||
p, n = rng.choice(negs), rng.choice(negs)
|
||||
|
||||
+26
-10
@@ -2,24 +2,29 @@
|
||||
|
||||
Метрика — пары (тип, автор) на уровне релиза против меток датасета, только по
|
||||
страницам, которых сегментатор не видел при обучении (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
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
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.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]
|
||||
MAX_PER_SOURCE = int(os.environ.get("E2E_MAX", "400"))
|
||||
|
||||
|
||||
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:
|
||||
by_rel[r["release_id"]].append(r)
|
||||
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
|
||||
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
|
||||
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"]}
|
||||
tp = len(gold & pred)
|
||||
p = tp / len(pred) if pred 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 = {}
|
||||
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))
|
||||
@@ -46,13 +59,16 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict:
|
||||
|
||||
def main() -> None:
|
||||
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)
|
||||
print(f"сегментатор: {seg_dir}")
|
||||
if predictor.segmenter is None:
|
||||
sys.exit("нет models/segmenter: python -m src.segmenter --train")
|
||||
for test_only, name in ((True, "тестовые страницы (не виделись сегментатору)"),
|
||||
(False, "train-страницы (для справки, оптимистично)")):
|
||||
blocks = [(True, "тестовые страницы (не виделись сегментатору)")]
|
||||
if "--train-pages" in sys.argv:
|
||||
blocks.append((False, "train-страницы (для справки, оптимистично)"))
|
||||
for test_only, name in blocks:
|
||||
print(f"== {name} ==")
|
||||
for mode in ("rules", "model"):
|
||||
res = run(predictor, rows, mode, test_only)
|
||||
|
||||
@@ -219,6 +219,74 @@ check("title: студия вписана в дорожку без студии"
|
||||
check("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"
|
||||
"Гангстерленд / 2026 / ДБ (X) / WEB-DL")
|
||||
check("title_from_text: первая строка",
|
||||
@@ -353,6 +421,7 @@ check("series_info: сезон, серии, total и «студия -> сери
|
||||
print("== сборщик датасета (tools/collect.py) ==")
|
||||
import csv as _csv # noqa: E402
|
||||
import json as _json # noqa: E402
|
||||
import json # noqa: E402
|
||||
import tempfile as _tmp # noqa: E402
|
||||
from pathlib import Path as _P # noqa: E402
|
||||
|
||||
@@ -857,6 +926,24 @@ check("prepare_data: у редкого языка метка пустая", [r["
|
||||
|
||||
print("== метки: тип, раскладка, строки из «Аудио», веса классов ==")
|
||||
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
|
||||
check("тип: любительский дубляж — отдельный класс", _tt("Любительский (дублированный) Red Head Sound")[0] == "люб. дубляж")
|
||||
check("тип: псевдодубляж -> люб. дубляж", _tt("Любительский (псевдодублированный)")[0] == "люб. дубляж")
|
||||
|
||||
@@ -1460,6 +1460,29 @@ def nnm_studio(name: str) -> tuple[str, str]:
|
||||
return cleaned, " ".join(x for x in (note, extra) if x).strip()
|
||||
|
||||
|
||||
# слова, которые в «Перевод: <тип> - <студия>» не студия: тип, кратность, язык
|
||||
NNM_PART_TYPEISH = re.compile(
|
||||
r"(?i)голос|дубл|закадр|авторск|любител|профессион|русск|оригинал|субтитр|лицензи"
|
||||
r"|^\W*[xх]?\d+[xх]?\W*$")
|
||||
|
||||
|
||||
def nnm_part_studio(part: str) -> str:
|
||||
"""Студия из части строки «Перевод:» nnm: «Многоголосый профессиональный - Jaskier»,
|
||||
«Дублированный (Невафильм)», «Авторский (Живов» -> имя; «Дублированный (Лицензия)»,
|
||||
«Одноголосый x2», «Двуголосый профессиональный» -> ""."""
|
||||
if len(part) > 90 or re.search(r"(?i)премьер|\bдата\b", part):
|
||||
return "" # склеилось соседнее поле описания
|
||||
m = re.search(r"\s[-–—:]\s*(.+)$", part)
|
||||
cands = ([m.group(1)] if m else []) + [c for c in re.findall(r"\(([^()]*)\)?", part) if c]
|
||||
for cand in cands:
|
||||
studio, _ = clean_studio(cand)
|
||||
studio, _ = canonical_studio(studio)
|
||||
if len(studio) >= 2 and not NNM_PART_TYPEISH.search(studio) \
|
||||
and studio.lower() not in SOURCE_LIKE:
|
||||
return studio
|
||||
return ""
|
||||
|
||||
|
||||
def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
post = rec.get("post") or ""
|
||||
title = rec.get("page_title") or rec.get("title") or ""
|
||||
@@ -1539,20 +1562,21 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
if ttype in ("неизвестно", "оригинал", "субтитры"):
|
||||
continue
|
||||
perevod_types.append(part)
|
||||
# без имён в скобках студия неизвестна, но тип перевода ценен
|
||||
# студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
|
||||
rows.append({
|
||||
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
|
||||
"year": year, "quality": quality, "is_series": is_series,
|
||||
"studio": "(не указана)", "translation_type": ttype,
|
||||
"studio": nnm_part_studio(part) or "(не указана)", "translation_type": ttype,
|
||||
"professional": pro, "role_raw": part, "actors": "",
|
||||
"note": mark_18("", has_18(line)), "age_18": int(has_18(line)),
|
||||
"codec": "", "channels": "", "khz": "", "kbps": "",
|
||||
"subtitles": "", "studio_source": "tech", "raw": line,
|
||||
})
|
||||
|
||||
# если построчных «Аудио N» с именами не было — оставляем только типы
|
||||
if any(r["studio"] != "(не указана)" for r in rows):
|
||||
rows = [r for r in rows if r["studio"] != "(не указана)"]
|
||||
# есть построчные «Аудио N» с именами — строки «Перевод:» (tech) не нужны; иначе
|
||||
# оставляем tech-строки: типы и студии, названные в «Перевод:»
|
||||
if any(r["studio"] != "(не указана)" and r["studio_source"] == "release" for r in rows):
|
||||
rows = [r for r in rows if r["studio_source"] == "release" and r["studio"] != "(не указана)"]
|
||||
|
||||
# дорожка без имени и без типа («- Русский») ничего не даёт датасету
|
||||
rows = [r for r in rows
|
||||
@@ -1715,6 +1739,51 @@ def audio_only_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def kz_title_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
|
||||
"""kinozal: студии из заголовка раздачи «… / ПМ (LostFilm, HDrezka Studio), ДБ (X) / …».
|
||||
|
||||
На kinozal студии часто названы только в заголовке и в строках «Аудио Rus: … | LostFilm»
|
||||
(без типа), а «Перевод:» во «Техданных» — без студий. Строка-вход — тот же синтетический
|
||||
сегмент, что строит сервис из заголовка (src.release.title_segments); строка «Аудио» с этой
|
||||
студией (если есть) — в audio_raw (позитив для сегментатора).
|
||||
"""
|
||||
from src.release import RE_TITLE_TRACK, TITLE_TYPES
|
||||
|
||||
def key(x: str) -> str:
|
||||
return re.sub(r"[\W_]+", "", (x or "").casefold())
|
||||
|
||||
title = (rec.get("h1") or rec.get("page_title") or "").split(" :: ")[0]
|
||||
have = key(" ".join(((r.get("studio") or "") + " " + (r.get("raw") or "")) for r in rows))
|
||||
lines = [ln for ln in audio_lines_all(text) if not ln["original"] and not ln["commentary"]]
|
||||
base = rows[0] if rows else {}
|
||||
info = kz_split_title(title)
|
||||
out = []
|
||||
for abbr, names in RE_TITLE_TRACK.findall(title):
|
||||
for name in re.split(r"\s*[,;]\s*", names.strip()):
|
||||
studio, note = clean_studio(name)
|
||||
studio, extra = canonical_studio(studio)
|
||||
k = key(studio)
|
||||
if len(k) < 2 or k in have or studio.lower() in SOURCE_LIKE:
|
||||
continue
|
||||
kind, pro = translation_type(TITLE_TYPES[abbr])
|
||||
a = next((ln for ln in lines if k in key(ln["raw"])), None)
|
||||
out.append({
|
||||
"source": "kinozal", "release_id": rec.get("id", ""),
|
||||
"ru_title": base.get("ru_title") or info["ru_title"],
|
||||
"year": base.get("year") or info["year"], "quality": base.get("quality", ""),
|
||||
"is_series": base.get("is_series", bool(re.search(r"сезон|серии", title, re.I))),
|
||||
"studio": studio, "translation_type": kind, "professional": pro,
|
||||
"role_raw": TITLE_TYPES[abbr], "actors": "",
|
||||
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), bool(a and a["adult"])),
|
||||
"age_18": int(bool(a and a["adult"])),
|
||||
"codec": a["codec"] if a else "", "channels": a["channels"] if a else "", "khz": "",
|
||||
"kbps": a["kbps"] if a else "", "subtitles": "", "studio_source": "title",
|
||||
"raw": f"{TITLE_TYPES[abbr]} | {name.strip()}",
|
||||
"lang": (a["lang"] or "RUS") if a else "RUS", "audio_raw": a["raw"] if a else ""})
|
||||
have += k
|
||||
return out
|
||||
|
||||
|
||||
def _with_audio(core, texts):
|
||||
"""Обёртка парсера: lang/audio_raw у строк; число переводов в rel — по итоговым строкам."""
|
||||
def parse(rec: dict) -> tuple[list[dict], dict]:
|
||||
@@ -1725,6 +1794,8 @@ def _with_audio(core, texts):
|
||||
rows += audio_only_rows(rec, text, rows)
|
||||
elif core is _kz_parse_core:
|
||||
rows = kz_audio_only_rows(rec, text)
|
||||
if core is _kz_parse_core:
|
||||
rows += kz_title_rows(rec, text, rows)
|
||||
if rows:
|
||||
rel["n_translations"], rel["ok"] = len(rows), True
|
||||
rel["studios"] = " | ".join(sorted({r["studio"] for r in rows}))
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user