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>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-02 11:33:01 +07:00
1 parent 6a87bc993c
commit 1f9ee46a0d
4 files changed
+177 -31

No files matched your search

+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`) пропускается. 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 Автор` и особенности разметки | | 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`) |
+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 отдаёт описание только авторизованным пользователям, поэтому для него kinozal отдаёт описание только авторизованным пользователям, поэтому для него
передавайте 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 = {
@@ -121,32 +131,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
+75 -19
View File
@@ -104,17 +104,39 @@ def aggregate(tracks: list[dict]) -> dict:
} }
def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None: UNKNOWN_TYPE = "неизвестно"
"""Добавляет дорожки из заголовка к дорожкам страницы (на месте).
* студия уже есть на странице -> пропуск;
* на странице есть дорожка того же типа без студии -> студия берётся def _studio_tokens(name: str) -> set[str]:
из заголовка (source=page+title): это та же дорожка; return {w for w in re.findall(r"\w+", name.casefold()) if len(w) >= 4}
* иначе -> новая дорожка (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()) -> None:
"""Добавляет дорожки второстепенного источника к дорожкам страницы (на месте).
* такая студия уже есть (по `match`) -> пропуск;
* есть дорожка того же типа без студии -> студия вписывается в неё
(source=page+<source>): это та же дорожка;
* иначе -> новая дорожка (source=<source>).
Дорожки без студии или с типом «неизвестно» не берутся: тип без студии
страница уже даёт, а в кодек-строках он часто грубее («одноголосый»
вместо «авт. одноголосый»).
""" """
for t in title_tracks: for t in extra:
name = (t.get("studio") or "").casefold() if not t.get("studio") or t["translation_type"] == UNKNOWN_TYPE:
if any((x.get("studio") or "").casefold() == name for x in tracks): continue
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)
@@ -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"], 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 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]: def model_segments(segmenter, page_text: str) -> list[str]:
"""Дорожки страницы: строки выбирает обученный сегментатор. """Дорожки страницы: строки выбирает обученный сегментатор.
@@ -136,11 +191,7 @@ def model_segments(segmenter, page_text: str) -> list[str]:
""" """
out = [] out = []
for line in segmenter.select_lines(page_text): for line in segmenter.select_lines(page_text):
body = RE_LINE_PREFIX.sub("", line, count=1) out += _entries(line)
for entry in _split_entries(body):
entry = re.sub(r"\s+", " ", entry).strip()
if entry:
out.append(entry)
return out return out
@@ -152,9 +203,9 @@ 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")
@@ -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 [] tracks = predictor._predict_batch(segs_page) if segs_page else []
for t in tracks: for t in tracks:
t["source"] = "page" 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: 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 "")
+28
View File
@@ -219,6 +219,34 @@ 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")])
_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: первая строка",