Конфликт в src/release.py: взяты original_info, lostfilm_release, series_info и серии дорожек из advisor; сохранены same_studio, merge_extra_tracks и codec_segments. Кодек-дорожкам тоже проставляются серии. Кодек-строки теперь подключаются, только если у какой-то дорожки страницы нет студии: с новыми моделями безусловное подключение роняло precision студий на rutracker (0.908 -> 0.793). С условием, test-сплит, по 300 релизов, студии P/R: rutracker 0.908/0.882 -> 0.840/0.900, nnm 0.875/0.915 -> 0.875/0.940, kinozal 0.816/0.845 -> 0.800/0.863. Тесты: 240 ok. Co-Authored-By: Claude Opus 5.5 <[email protected]>
225 lines
11 KiB
Python
225 lines
11 KiB
Python
"""Загрузка страницы релиза по ссылке.
|
||
|
||
Только известные трекеры (allowlist): сервис принимает URL от клиента, поэтому
|
||
произвольные хосты запрещены (защита от SSRF — localhost, внутренняя сеть).
|
||
|
||
Публичный 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 или
|
||
странице-заглушке страница запрашивается через него. Адрес FlareSolverr берётся
|
||
только из настроек сервиса, но не из запроса клиента.
|
||
"""
|
||
|
||
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 = {
|
||
"rutracker": ("rutracker.org", "rutracker.net", "rutracker.nl"),
|
||
"kinozal": ("kinozal.tv", "kinozal.guru", "kinozal.me"),
|
||
"nnm": ("nnmclub.to", "nnm-club.name", "nnm-club.me", "nnm-club.ws"),
|
||
}
|
||
|
||
USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
||
MAX_BYTES = 5_000_000
|
||
# коды, при которых имеет смысл повтор через FlareSolverr (отказ/Cloudflare)
|
||
RETRY_CODES = (403, 429, 500, 502, 503, 520, 521, 522, 523, 524)
|
||
|
||
|
||
def is_lostfilm(url: str) -> bool:
|
||
"""Ссылка на сайт LostFilm (lostfilm.tv и зеркала): озвучка известна без загрузки."""
|
||
host = (urlparse(url).hostname or "").lower()
|
||
return "lostfilm" in host.split(".")[-2:-1] or host.startswith("lostfilm.") or ".lostfilm." in host
|
||
|
||
|
||
def detect_tracker(url: str) -> str:
|
||
"""Определяет трекер по домену; неизвестный хост -> ValueError."""
|
||
parsed = urlparse(url)
|
||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||
raise ValueError(f"некорректная ссылка: {url!r}")
|
||
host = parsed.hostname.lower()
|
||
for tracker, domains in TRACKER_HOSTS.items():
|
||
if any(host == d or host.endswith("." + d) for d in domains):
|
||
return tracker
|
||
raise ValueError(f"неподдерживаемый хост: {host}")
|
||
|
||
|
||
def decode_bytes(data: bytes, content_type: str = "") -> str:
|
||
"""Байты -> текст. Приоритет: charset из заголовка, utf-8, cp1251."""
|
||
charset = ""
|
||
if "charset=" in content_type.lower():
|
||
charset = content_type.lower().split("charset=")[1].split(";")[0].strip()
|
||
for enc in filter(None, (charset, "utf-8", "cp1251")):
|
||
try:
|
||
return _fix_mojibake(data.decode(enc))
|
||
except (UnicodeDecodeError, LookupError):
|
||
continue
|
||
return _fix_mojibake(data.decode("utf-8", errors="replace"))
|
||
|
||
|
||
def blocked_reason(html: str) -> str | None:
|
||
"""Распознаёт страницу-заглушку: Cloudflare challenge или форма логина."""
|
||
head = html[:6000]
|
||
if "challenges.cloudflare.com" in head or "<title>Один момент" in head or "<title>Just a moment" in head:
|
||
return "Cloudflare challenge"
|
||
if "takelogin.php" in html and "Перевод" not in html:
|
||
return "нужна авторизация (передайте cookie)"
|
||
# у rutracker форма логина есть в шапке и у обычной страницы темы
|
||
if "login.php" in head and "name=\"login_username\"" in html and "post_body" not in html and "Перевод" not in html:
|
||
return "нужна авторизация (передайте cookie)"
|
||
return None
|
||
|
||
|
||
def _fetch_direct(url: str, timeout: float, cookie: str | None) -> str:
|
||
headers = {"User-Agent": USER_AGENT, "Accept-Language": "ru,en;q=0.8"}
|
||
if cookie:
|
||
headers["Cookie"] = cookie
|
||
req = urllib.request.Request(url, headers=headers)
|
||
# редиректы только внутри allowlist трекеров
|
||
opener = urllib.request.build_opener(_AllowlistRedirect())
|
||
with opener.open(req, timeout=timeout) as resp:
|
||
data = resp.read(MAX_BYTES + 1)
|
||
if len(data) > MAX_BYTES:
|
||
raise ValueError("страница слишком большая")
|
||
return decode_bytes(data, resp.headers.get("Content-Type", ""))
|
||
|
||
|
||
def _fetch_flaresolverr(endpoint: str, url: str, timeout: float,
|
||
cookie: str | None) -> str:
|
||
"""Запрос через FlareSolverr (POST {endpoint}/v1, cmd=request.get)."""
|
||
host = urlparse(url).hostname or ""
|
||
body = {"cmd": "request.get", "url": url, "maxTimeout": int(timeout * 1000)}
|
||
if cookie:
|
||
body["cookies"] = [
|
||
{"name": k.strip(), "value": v.strip(), "domain": host}
|
||
for k, _, v in (c.partition("=") for c in cookie.split(";") if "=" in c)]
|
||
req = urllib.request.Request(
|
||
endpoint.rstrip("/") + "/v1", data=json.dumps(body).encode(),
|
||
headers={"Content-Type": "application/json"})
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout + 15) as resp:
|
||
data = json.loads(resp.read(MAX_BYTES * 2).decode("utf-8"))
|
||
except urllib.error.HTTPError as e:
|
||
# FlareSolverr при неудаче решения отвечает HTTP 500 с JSON-описанием
|
||
try:
|
||
msg = json.loads(e.read().decode("utf-8")).get("message", "")
|
||
except Exception: # noqa: BLE001
|
||
msg = ""
|
||
raise ValueError(f"FlareSolverr: HTTP {e.code} {msg}".strip()) from e
|
||
if data.get("status") != "ok":
|
||
raise ValueError(f"FlareSolverr: {data.get('message', 'ошибка')}")
|
||
return data["solution"]["response"]
|
||
|
||
|
||
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
|
||
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
|
||
|
||
|
||
class _AllowlistRedirect(urllib.request.HTTPRedirectHandler):
|
||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||
detect_tracker(newurl) # ValueError, если редирект уводит с трекеров
|
||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|