FlareSolverr-фолбэк для загрузки по ссылке; -ReleaseUrl в predict.ps1

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 17:29:58 +07:00
1 parent bc9ff36e61
commit dce219aeee
6 files changed
+150 -10

No files matched your search

+57 -7
View File
@@ -11,10 +11,19 @@
kinozal отдаёт описание только авторизованным пользователям, поэтому для него
передавайте cookie (`cookie="uid=...; pass=..."`) либо скачивайте страницу сами
(grabber уже умеет) и отдавайте сервису готовый `html`.
Cloudflare / защита от ботов: если задан FlareSolverr (ваш собственный
экземпляр, как у Jackett/Prowlarr) — `flaresolverr="http://host:8191"` или
переменная окружения DUB_FLARESOLVERR_URL, — то при ответе 403/503 или
странице-заглушке страница запрашивается через него. Адрес FlareSolverr берётся
только из настроек сервиса, но не из запроса клиента.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from urllib.parse import urlparse
@@ -68,24 +77,65 @@ def blocked_reason(html: str) -> str | None:
return None
def fetch_html(url: str, *, timeout: float = 15.0,
cookie: str | None = None) -> tuple[str, str]:
"""Скачивает страницу релиза. Возвращает (html, tracker)."""
tracker = detect_tracker(url)
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)
# редиректы на чужие хосты не нужны: открываем без follow-за-пределы allowlist
# редиректы только внутри 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("страница слишком большая")
html = decode_bytes(data, resp.headers.get("Content-Type", ""))
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"})
with urllib.request.urlopen(req, timeout=timeout + 15) as resp:
data = json.loads(resp.read(MAX_BYTES * 2).decode("utf-8"))
if data.get("status") != "ok":
raise ValueError(f"FlareSolverr: {data.get('message', 'ошибка')}")
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
try:
html = _fetch_direct(url, timeout, cookie)
except urllib.error.HTTPError as e:
if e.code not in (403, 429, 503) 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)
reason = blocked_reason(html)
if reason:
raise ValueError(f"страница недоступна: {reason}")
hint = "" if flaresolverr else " (задайте FlareSolverr: DUB_FLARESOLVERR_URL)"
raise ValueError(f"страница недоступна: {reason}{hint}")
return html, tracker