"""Загрузка страницы релиза по ссылке.
Только известные трекеры (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 "
Один момент" in head or "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)