Files
grabber-ai/src/fetch.py
T
DenozordecandClaude Opus 5.5 099d33bdaa Слияние claude/advisor-opus-78114c: модели на пересобранном датасете, Docker, язык и серии
Конфликт в 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]>
2026-11-02 11:50:44 +07:00

225 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Загрузка страницы релиза по ссылке.
Только известные трекеры (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)