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