diff --git a/docs/plan.md b/docs/plan.md index ac69c6d..8ed19a3 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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 (`

`/``), из текстового дампа (первая строка вида «… / год / …»), либо передаётся явно (`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`) пропускается. e2e test rutracker без изменений (P 0.988 / R 0.982); train-страницы P 0.975 → 0.951 | | 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`) | diff --git a/src/fetch.py b/src/fetch.py index 2f174d6..607dc2f 100644 --- a/src/fetch.py +++ b/src/fetch.py @@ -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="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 = { @@ -121,32 +131,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 diff --git a/src/release.py b/src/release.py index 359a815..0b7f2f9 100644 --- a/src/release.py +++ b/src/release.py @@ -104,17 +104,39 @@ def aggregate(tracks: list[dict]) -> dict: } -def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None: - """Добавляет дорожки из заголовка к дорожкам страницы (на месте). +UNKNOWN_TYPE = "неизвестно" - * студия уже есть на странице -> пропуск; - * на странице есть дорожка того же типа без студии -> студия берётся - из заголовка (source=page+title): это та же дорожка; - * иначе -> новая дорожка (source=title). + +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: + """Одна студия в разном написании: «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()) -> None: + """Добавляет дорожки второстепенного источника к дорожкам страницы (на месте). + + * такая студия уже есть (по `match`) -> пропуск; + * есть дорожка того же типа без студии -> студия вписывается в неё + (source=page+<source>): это та же дорожка; + * иначе -> новая дорожка (source=<source>). + + Дорожки без студии или с типом «неизвестно» не берутся: тип без студии + страница уже даёт, а в кодек-строках он часто грубее («одноголосый» + вместо «авт. одноголосый»). """ - for t in title_tracks: - name = (t.get("studio") or "").casefold() - if any((x.get("studio") or "").casefold() == name for x in tracks): + for t in extra: + if not t.get("studio") or t["translation_type"] == UNKNOWN_TYPE: + continue + 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) @@ -122,12 +144,45 @@ 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 merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None: + """Дорожки из заголовка («ПМ (LostFilm)») -> к дорожкам страницы.""" + merge_extra_tracks(tracks, title_tracks, "title") + + +# кодек-строка дорожки с типом озвучки: «Аудио 3: AC3 … (MVO, HDrezka Studio)»; +# «Аудио 6: E-AC3 … English Dubbing» не подходит — нет русского маркера типа +RE_CODEC_TRACK = re.compile( + r"(?i)^\s*(?:Аудио|Audio)\s*(?:№|#)?\s*\d*\s*:.*(?:\b(?:MVO|DVO|AVO|DUB|VO)\b" + r"|дубл|многоголос|двухголос|одноголос|закадр|авторск)") + + +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)»). + + Сегментатор обучен на `raw` датасета, где у rutracker это строки + «Перевод N:», поэтому кодек-строки он отбрасывает. Они нужны там, где + студии есть только в них (kinozal: вкладка «Релиз» против «Перевод:» без + студий в «Техданных»), и идут второстепенным источником, см. + `merge_extra_tracks`. + """ + out = [] + for line in (page_text or "").splitlines(): + if RE_CODEC_TRACK.match(line): + out += _entries(line) + return out + + def model_segments(segmenter, page_text: str) -> list[str]: """Дорожки страницы: строки выбирает обученный сегментатор. @@ -136,11 +191,7 @@ def model_segments(segmenter, page_text: str) -> list[str]: """ out = [] 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) + out += _entries(line) return out @@ -152,9 +203,9 @@ 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") @@ -169,6 +220,11 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No tracks = predictor._predict_batch(segs_page) if segs_page else [] for t in tracks: t["source"] = "page" + seen = set(segs_page) + segs_codec = [x for x in codec_segments(page_text) if x not in seen] + if segs_codec: + merge_extra_tracks(tracks, predictor._predict_batch(segs_codec), "codec", + match=same_studio) if title is None: title = title_from_html(html) if html is not None else title_from_text(text) segs = title_segments(title or "") diff --git a/tests/run_tests.py b/tests/run_tests.py index e4de1ac..b0e1b43 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -219,6 +219,34 @@ 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")]) + _dump = ("Меню\nГангстерленд / MobLand / 2025 / ПМ (LostFilm) / WEB\n" "Гангстерленд / 2026 / ДБ (X) / WEB-DL") check("title_from_text: первая строка",