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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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

+18
View File
@@ -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 скачивает страницу сам
+20 -2
View File
@@ -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
+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
+3 -1
View File
@@ -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
+6
View File
@@ -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)
+46
View File
@@ -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": "<html>Перевод: Дублированный | MovieDalen</html>"}}
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)