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 <[email protected]>
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 | сделано |
| 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`) пропускается. 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`) |
+73 -11
View File
@@ -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
+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 = "неизвестно"
* студия уже есть на странице -> пропуск;
* на странице есть дорожка того же типа без студии -> студия берётся
из заголовка (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 "")
+28
View File
@@ -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: первая строка",