From dce219aeee21ffe64e26a4218e1ac27b74fb61cb Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 27 Oct 2026 17:29:58 +0700 Subject: [PATCH] =?UTF-8?q?FlareSolverr-=D1=84=D0=BE=D0=BB=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=20=D0=B4=D0=BB=D1=8F=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D1=81=D1=81=D1=8B=D0=BB?= =?UTF-8?q?=D0=BA=D0=B5;=20-ReleaseUrl=20=D0=B2=20predict.ps1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/integration.md | 18 ++++++++++ examples/powershell/predict.ps1 | 22 ++++++++++-- src/fetch.py | 64 +++++++++++++++++++++++++++++---- src/predict.py | 4 ++- src/service.py | 6 ++++ tests/run_tests.py | 46 ++++++++++++++++++++++++ 6 files changed, 150 insertions(+), 10 deletions(-) diff --git a/docs/integration.md b/docs/integration.md index 05a32d1..5f92089 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -107,6 +107,24 @@ $b = [Text.Encoding]::UTF8.GetBytes((@{ text = "Перевод: Дублиров Ответ сервис отдаёт с `charset=utf-8`, поэтому кириллица в ответе корректно декодируется и в PS 5.1. +### Cloudflare / 403 на трекерах (FlareSolverr) + +Как и Jackett/Prowlarr, сервис умеет ходить через **ваш** экземпляр +[FlareSolverr](https://github.com/FlareSolverr/FlareSolverr): при ответе трекера +403/429/503 или странице-заглушке («Один момент…») запрос повторяется через него. + +```bash +docker run -d --name flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest +python -m src.service --flaresolverr http://127.0.0.1:8191 # или env DUB_FLARESOLVERR_URL +python -m src.predict --url <ссылка> --flaresolverr http://127.0.0.1:8191 +``` + +Адрес FlareSolverr задаётся только настройкой сервиса, не полем запроса. +Если grabber уже работает через Prowlarr/FlareSolverr, проще скачать страницу +там и прислать её в поле `html`. Ограничения: FlareSolverr не гарантирует +обход (rutracker дополнительно требует авторизацию / может блокировать по +региону), cookie сессии передаются через `cookie`. + ### grabber (PHP) kinozal требует авторизации, поэтому grabber скачивает страницу сам diff --git a/examples/powershell/predict.ps1 b/examples/powershell/predict.ps1 index c37986f..58c9b96 100644 --- a/examples/powershell/predict.ps1 +++ b/examples/powershell/predict.ps1 @@ -3,6 +3,8 @@ # Примеры (из корня проекта): # powershell -NoProfile -ExecutionPolicy Bypass -File examples\powershell\predict.ps1 -Text "Перевод: Дублированный | MovieDalen" # powershell -NoProfile -ExecutionPolicy Bypass -File examples\powershell\predict.ps1 -HtmlFile page.html +# powershell -NoProfile -ExecutionPolicy Bypass -File examples\powershell\predict.ps1 -ReleaseUrl "https://rutracker.org/forum/viewtopic.php?t=123" +# (kinozal: добавьте -Cookie "uid=..; pass=..") # pwsh -File examples\powershell\predict.ps1 -Text "..." -Url http://host:8000 # # Тело запроса отправляется UTF-8 байтами — это одинаково работает и в @@ -14,7 +16,11 @@ param( [Parameter(Mandatory = $true, ParameterSetName = 'Text')] [string]$Text, [Parameter(Mandatory = $true, ParameterSetName = 'Html')] - [string]$HtmlFile + [string]$HtmlFile, + [Parameter(Mandatory = $true, ParameterSetName = 'Release')] + [string]$ReleaseUrl, + [Parameter(ParameterSetName = 'Release')] + [string]$Cookie ) $ErrorActionPreference = 'Stop' @@ -29,6 +35,9 @@ if ($HtmlFile) { $html = [IO.File]::ReadAllText($path, [Text.Encoding]::GetEncoding(1251)) } $payload = @{ html = $html } +} elseif ($ReleaseUrl) { + $payload = @{ url = $ReleaseUrl } + if ($Cookie) { $payload.cookie = $Cookie } } else { $payload = @{ text = $Text } } @@ -40,10 +49,19 @@ try { $r = Invoke-RestMethod -Uri "$Url/predict" -Method Post ` -ContentType 'application/json' -Body $bytes } catch { - Write-Error "Сервис недоступен ($Url). Запуск: python -m src.service`n$_" + if ($_.Exception.Response) { + # сервис ответил ошибкой (например, трекер вернул 403) — показываем его текст + $detail = $_.ErrorDetails.Message + Write-Error "Сервис вернул ошибку: $detail" + } else { + Write-Error "Сервис недоступен ($Url). Запуск: python -m src.service`n$_" + } exit 1 } +if ($r.studios) { + "18+ релиза: $($r.is_18plus) типы: $($r.translation_types -join ', ')" +} if ($r.tracks) { $r.tracks | Format-Table translation_type, studio, is_18plus, type_confidence -AutoSize diff --git a/src/fetch.py b/src/fetch.py index fdebdc3..9d99e06 100644 --- a/src/fetch.py +++ b/src/fetch.py @@ -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 diff --git a/src/predict.py b/src/predict.py index 8004f98..d57a110 100644 --- a/src/predict.py +++ b/src/predict.py @@ -149,6 +149,7 @@ def main() -> None: src.add_argument("--file", help="путь к текстовому дампу страницы") src.add_argument("--url", help="ссылка на раздачу (rutracker / kinozal / nnm)") ap.add_argument("--cookie", help="Cookie для --url (нужен kinozal)") + ap.add_argument("--flaresolverr", help="адрес FlareSolverr (обход Cloudflare), напр. http://127.0.0.1:8191") ap.add_argument("--out", help="записать JSON в файл вместо stdout") args = ap.parse_args() @@ -158,7 +159,8 @@ def main() -> None: elif args.url: from .fetch import fetch_html from .release import predict_release - html, tracker = fetch_html(args.url, cookie=args.cookie) + html, tracker = fetch_html(args.url, cookie=args.cookie, + flaresolverr=args.flaresolverr) payload = {**predict_release(predictor, html=html), "tracker": tracker} else: from .release import predict_release diff --git a/src/service.py b/src/service.py index 43faba0..556978a 100644 --- a/src/service.py +++ b/src/service.py @@ -85,9 +85,15 @@ def main() -> None: ap.add_argument("--model", default="models/best") ap.add_argument("--host", default="127.0.0.1") ap.add_argument("--port", type=int, default=8000) + ap.add_argument("--flaresolverr", default=None, + help="адрес FlareSolverr для обхода Cloudflare (или env DUB_FLARESOLVERR_URL)") args = ap.parse_args() + import os + import uvicorn + if args.flaresolverr: + os.environ["DUB_FLARESOLVERR_URL"] = args.flaresolverr predictor = DubPredictor(args.model) uvicorn.run(create_app(predictor), host=args.host, port=args.port) diff --git a/tests/run_tests.py b/tests/run_tests.py index f9bdde1..1a7c774 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -236,5 +236,51 @@ _t5 = "Русский MVO HDRezka Studio" check("span: расширение до границ слова", _t5[slice(*refine_span(_t5, (12, 14)))] == "HDRezka") check("span: один символ -> None", refine_span("М. Чадов", (0, 1)) is None) +import json as _json # noqa: E402 +import threading # noqa: E402 +import urllib.error # noqa: E402 +from http.server import BaseHTTPRequestHandler, HTTPServer # noqa: E402 + +import src.fetch as _fetch # noqa: E402 + + +class _FakeFlare(BaseHTTPRequestHandler): + def do_POST(self): + req = _json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + ok = req.get("cmd") == "request.get" and "rutracker.org" in req.get("url", "") + body = _json.dumps({"status": "ok", "solution": {"response": "Перевод: Дублированный | MovieDalen"}} + if ok else {"status": "error", "message": "bad"}).encode() + self.send_response(200) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a): + pass + + +_srv = HTTPServer(("127.0.0.1", 0), _FakeFlare) +threading.Thread(target=_srv.serve_forever, daemon=True).start() +_flare = f"http://127.0.0.1:{_srv.server_port}" + + +def _deny(url, timeout, cookie): + raise urllib.error.HTTPError(url, 403, "Forbidden", {}, None) + + +_orig = _fetch._fetch_direct +_fetch._fetch_direct = _deny +try: + h, tr = _fetch.fetch_html("https://rutracker.org/forum/viewtopic.php?t=1", flaresolverr=_flare) + check("FlareSolverr: fallback при 403", "MovieDalen" in h and tr == "rutracker") + try: + _fetch.fetch_html("https://rutracker.org/forum/viewtopic.php?t=1") + check("без FlareSolverr 403 пробрасывается", False) + except urllib.error.HTTPError: + check("без FlareSolverr 403 пробрасывается", True) +finally: + _fetch._fetch_direct = _orig + _srv.shutdown() + + print(f"\nитог: {PASS} ok, {FAIL} failed") sys.exit(1 if FAIL else 0)