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`, поэтому кириллица в ответе
|
Ответ сервис отдаёт с `charset=utf-8`, поэтому кириллица в ответе
|
||||||
корректно декодируется и в PS 5.1.
|
корректно декодируется и в 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)
|
### grabber (PHP)
|
||||||
|
|
||||||
kinozal требует авторизации, поэтому grabber скачивает страницу сам
|
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 -Text "Перевод: Дублированный | MovieDalen"
|
||||||
# powershell -NoProfile -ExecutionPolicy Bypass -File examples\powershell\predict.ps1 -HtmlFile page.html
|
# 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
|
# pwsh -File examples\powershell\predict.ps1 -Text "..." -Url http://host:8000
|
||||||
#
|
#
|
||||||
# Тело запроса отправляется UTF-8 байтами — это одинаково работает и в
|
# Тело запроса отправляется UTF-8 байтами — это одинаково работает и в
|
||||||
@@ -14,7 +16,11 @@ param(
|
|||||||
[Parameter(Mandatory = $true, ParameterSetName = 'Text')]
|
[Parameter(Mandatory = $true, ParameterSetName = 'Text')]
|
||||||
[string]$Text,
|
[string]$Text,
|
||||||
[Parameter(Mandatory = $true, ParameterSetName = 'Html')]
|
[Parameter(Mandatory = $true, ParameterSetName = 'Html')]
|
||||||
[string]$HtmlFile
|
[string]$HtmlFile,
|
||||||
|
[Parameter(Mandatory = $true, ParameterSetName = 'Release')]
|
||||||
|
[string]$ReleaseUrl,
|
||||||
|
[Parameter(ParameterSetName = 'Release')]
|
||||||
|
[string]$Cookie
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
@@ -29,6 +35,9 @@ if ($HtmlFile) {
|
|||||||
$html = [IO.File]::ReadAllText($path, [Text.Encoding]::GetEncoding(1251))
|
$html = [IO.File]::ReadAllText($path, [Text.Encoding]::GetEncoding(1251))
|
||||||
}
|
}
|
||||||
$payload = @{ html = $html }
|
$payload = @{ html = $html }
|
||||||
|
} elseif ($ReleaseUrl) {
|
||||||
|
$payload = @{ url = $ReleaseUrl }
|
||||||
|
if ($Cookie) { $payload.cookie = $Cookie }
|
||||||
} else {
|
} else {
|
||||||
$payload = @{ text = $Text }
|
$payload = @{ text = $Text }
|
||||||
}
|
}
|
||||||
@@ -40,10 +49,19 @@ try {
|
|||||||
$r = Invoke-RestMethod -Uri "$Url/predict" -Method Post `
|
$r = Invoke-RestMethod -Uri "$Url/predict" -Method Post `
|
||||||
-ContentType 'application/json' -Body $bytes
|
-ContentType 'application/json' -Body $bytes
|
||||||
} catch {
|
} 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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($r.studios) {
|
||||||
|
"18+ релиза: $($r.is_18plus) типы: $($r.translation_types -join ', ')"
|
||||||
|
}
|
||||||
if ($r.tracks) {
|
if ($r.tracks) {
|
||||||
$r.tracks | Format-Table translation_type, studio, is_18plus,
|
$r.tracks | Format-Table translation_type, studio, is_18plus,
|
||||||
type_confidence -AutoSize
|
type_confidence -AutoSize
|
||||||
|
|||||||
+57
-7
@@ -11,10 +11,19 @@
|
|||||||
kinozal отдаёт описание только авторизованным пользователям, поэтому для него
|
kinozal отдаёт описание только авторизованным пользователям, поэтому для него
|
||||||
передавайте cookie (`cookie="uid=...; pass=..."`) либо скачивайте страницу сами
|
передавайте cookie (`cookie="uid=...; pass=..."`) либо скачивайте страницу сами
|
||||||
(grabber уже умеет) и отдавайте сервису готовый `html`.
|
(grabber уже умеет) и отдавайте сервису готовый `html`.
|
||||||
|
|
||||||
|
Cloudflare / защита от ботов: если задан FlareSolverr (ваш собственный
|
||||||
|
экземпляр, как у Jackett/Prowlarr) — `flaresolverr="http://host:8191"` или
|
||||||
|
переменная окружения DUB_FLARESOLVERR_URL, — то при ответе 403/503 или
|
||||||
|
странице-заглушке страница запрашивается через него. Адрес FlareSolverr берётся
|
||||||
|
только из настроек сервиса, но не из запроса клиента.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -68,24 +77,65 @@ def blocked_reason(html: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def fetch_html(url: str, *, timeout: float = 15.0,
|
def _fetch_direct(url: str, timeout: float, cookie: str | None) -> str:
|
||||||
cookie: str | None = None) -> tuple[str, str]:
|
|
||||||
"""Скачивает страницу релиза. Возвращает (html, tracker)."""
|
|
||||||
tracker = detect_tracker(url)
|
|
||||||
headers = {"User-Agent": USER_AGENT, "Accept-Language": "ru,en;q=0.8"}
|
headers = {"User-Agent": USER_AGENT, "Accept-Language": "ru,en;q=0.8"}
|
||||||
if cookie:
|
if cookie:
|
||||||
headers["Cookie"] = cookie
|
headers["Cookie"] = cookie
|
||||||
req = urllib.request.Request(url, headers=headers)
|
req = urllib.request.Request(url, headers=headers)
|
||||||
# редиректы на чужие хосты не нужны: открываем без follow-за-пределы allowlist
|
# редиректы только внутри allowlist трекеров
|
||||||
opener = urllib.request.build_opener(_AllowlistRedirect())
|
opener = urllib.request.build_opener(_AllowlistRedirect())
|
||||||
with opener.open(req, timeout=timeout) as resp:
|
with opener.open(req, timeout=timeout) as resp:
|
||||||
data = resp.read(MAX_BYTES + 1)
|
data = resp.read(MAX_BYTES + 1)
|
||||||
if len(data) > MAX_BYTES:
|
if len(data) > MAX_BYTES:
|
||||||
raise ValueError("страница слишком большая")
|
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)
|
reason = blocked_reason(html)
|
||||||
if reason:
|
if reason:
|
||||||
raise ValueError(f"страница недоступна: {reason}")
|
hint = "" if flaresolverr else " (задайте FlareSolverr: DUB_FLARESOLVERR_URL)"
|
||||||
|
raise ValueError(f"страница недоступна: {reason}{hint}")
|
||||||
return html, tracker
|
return html, tracker
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -149,6 +149,7 @@ def main() -> None:
|
|||||||
src.add_argument("--file", help="путь к текстовому дампу страницы")
|
src.add_argument("--file", help="путь к текстовому дампу страницы")
|
||||||
src.add_argument("--url", help="ссылка на раздачу (rutracker / kinozal / nnm)")
|
src.add_argument("--url", help="ссылка на раздачу (rutracker / kinozal / nnm)")
|
||||||
ap.add_argument("--cookie", help="Cookie для --url (нужен kinozal)")
|
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")
|
ap.add_argument("--out", help="записать JSON в файл вместо stdout")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
@@ -158,7 +159,8 @@ def main() -> None:
|
|||||||
elif args.url:
|
elif args.url:
|
||||||
from .fetch import fetch_html
|
from .fetch import fetch_html
|
||||||
from .release import predict_release
|
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}
|
payload = {**predict_release(predictor, html=html), "tracker": tracker}
|
||||||
else:
|
else:
|
||||||
from .release import predict_release
|
from .release import predict_release
|
||||||
|
|||||||
@@ -85,9 +85,15 @@ def main() -> None:
|
|||||||
ap.add_argument("--model", default="models/best")
|
ap.add_argument("--model", default="models/best")
|
||||||
ap.add_argument("--host", default="127.0.0.1")
|
ap.add_argument("--host", default="127.0.0.1")
|
||||||
ap.add_argument("--port", type=int, default=8000)
|
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()
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
if args.flaresolverr:
|
||||||
|
os.environ["DUB_FLARESOLVERR_URL"] = args.flaresolverr
|
||||||
predictor = DubPredictor(args.model)
|
predictor = DubPredictor(args.model)
|
||||||
uvicorn.run(create_app(predictor), host=args.host, port=args.port)
|
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: расширение до границ слова", _t5[slice(*refine_span(_t5, (12, 14)))] == "HDRezka")
|
||||||
check("span: один символ -> None", refine_span("М. Чадов", (0, 1)) is None)
|
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")
|
print(f"\nитог: {PASS} ok, {FAIL} failed")
|
||||||
sys.exit(1 if FAIL else 0)
|
sys.exit(1 if FAIL else 0)
|
||||||
Reference in new issue
Block a user