FlareSolverr-фолбэк для загрузки по ссылке; -ReleaseUrl в predict.ps1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc9ff36e61
commit
dce219aeee
6 files changed
+150
-10
No files matched your search
@@ -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 скачивает страницу сам
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user