fix: слоты, безопасность, единый формат ответа и валидация
- слоты браузеров: asyncio.Semaphore, размер из BROWSER_SLOTS, при нехватке — 503 + Retry-After; устранена утечка счётчика при отказе (#12, #49) - session_id принимается только как UUID, путь профиля проверяется (#5) - секреты клиента в логах маскируются, полный дамп — только на DEBUG (#6) - ответ: status всегда "ok"/"err", поле code (no_slots / request_failed / challenge_failed / action_failed), response_model (#7) - непройденный антибот → challenge_failed, действия не выполняются (#8) - таймауты провайдеров в мс вынесены в константы; detect переживает навигацию посреди проверки; пауза между неудачными solve вместо холостого цикла (#9) - обязательные поля действий и формат прокси проверяются схемой → 422 до запуска браузера (#13) - прокси необязателен (#15) - user_agent сохраняется в профиле сессии и реально передаётся в браузер (#16) - duration_ms у действий и у запроса (#18), опция screenshot (#19) - общий конфиг логирования (LOG_LEVEL), print → логгер, CloudflareProvider, удалён cached_persistent, GET /health (#22) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f58306cbb4
commit
437ea27be7
14 files changed
+360
-201
No files matched your search
+94
-4
@@ -1,25 +1,70 @@
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
CAPTCHA_TYPES = ("cloudflare", "recaptcha", "hcaptcha")
|
||||
PROXY_SCHEMES = ("http", "https", "socks5")
|
||||
|
||||
# тип действия -> обязательные поля (кроме type)
|
||||
_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = {
|
||||
"fill": ("selector", "value"),
|
||||
"captcha": ("selector", "value"),
|
||||
"delay": ("value",),
|
||||
"mouse_move": ("selector",),
|
||||
"click": ("selector",),
|
||||
"submit": ("selector",),
|
||||
"inner_html": ("selector",),
|
||||
"wait_for": ("selector",),
|
||||
"pass_challenge": (),
|
||||
}
|
||||
WAIT_FOR_DEFAULT_TIMEOUT = 30
|
||||
|
||||
|
||||
class ActionRequest(BaseModel):
|
||||
"""
|
||||
Действие автоматизации. Характеризуется типом и производится над селектором.
|
||||
Набор параметров зависит от типа, этот класс перечисляет все параметры.
|
||||
Обязательные поля для каждого типа проверяются здесь же — неверное действие
|
||||
отклоняется с 422 до запуска браузера.
|
||||
"""
|
||||
type: str
|
||||
selector: str | None = None
|
||||
value: str | None = None
|
||||
timeout: int | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_fields(self):
|
||||
if self.type not in _REQUIRED_FIELDS:
|
||||
raise ValueError(f"unknown action type {self.type!r}, expected one of: {', '.join(_REQUIRED_FIELDS)}")
|
||||
|
||||
missing = [f for f in _REQUIRED_FIELDS[self.type] if getattr(self, f) in (None, "")]
|
||||
if missing:
|
||||
raise ValueError(f"action {self.type!r} requires: {', '.join(missing)}")
|
||||
|
||||
if self.type == "wait_for":
|
||||
if self.timeout is None:
|
||||
self.timeout = WAIT_FOR_DEFAULT_TIMEOUT
|
||||
if not 1 <= self.timeout <= 7200:
|
||||
raise ValueError("wait_for timeout must be between 1 and 7200 seconds")
|
||||
elif self.type == "delay":
|
||||
try:
|
||||
delay = int(self.value)
|
||||
except ValueError:
|
||||
raise ValueError("delay value must be an integer number of seconds") from None
|
||||
if not 0 <= delay <= 600:
|
||||
raise ValueError("delay value must be between 0 and 600 seconds")
|
||||
elif self.type == "captcha" and self.value not in CAPTCHA_TYPES:
|
||||
raise ValueError(f"captcha value must be one of: {', '.join(CAPTCHA_TYPES)}")
|
||||
return self
|
||||
|
||||
|
||||
class SolveRequest(BaseModel):
|
||||
"""
|
||||
Объект запроса для автоматизации на сайте.
|
||||
"""
|
||||
url: str
|
||||
proxy: str
|
||||
proxy: str | None = None
|
||||
timeout: int | None = None
|
||||
user_agent: str | None = None
|
||||
cookies: list[dict[str, Any]] | None = None
|
||||
@@ -28,7 +73,7 @@ class SolveRequest(BaseModel):
|
||||
actions: list[ActionRequest]
|
||||
|
||||
html: bool = False
|
||||
# TODO: screenshot: bool = False
|
||||
screenshot: bool = False
|
||||
|
||||
captcha: str | None = None
|
||||
captcha_detect_locator: str | None = None
|
||||
@@ -43,3 +88,48 @@ class SolveRequest(BaseModel):
|
||||
retry_delay: float = 1.0
|
||||
wait: int = 0
|
||||
google_search: bool = False
|
||||
|
||||
@field_validator("proxy")
|
||||
@classmethod
|
||||
def check_proxy(cls, proxy: str | None) -> str | None:
|
||||
if not proxy:
|
||||
return None
|
||||
try:
|
||||
parts = urlsplit(proxy)
|
||||
port = parts.port
|
||||
except ValueError as e:
|
||||
raise ValueError(f"invalid proxy url: {e}") from None
|
||||
if parts.scheme not in PROXY_SCHEMES:
|
||||
raise ValueError(f"proxy scheme must be one of: {', '.join(PROXY_SCHEMES)}")
|
||||
if not parts.hostname or not port:
|
||||
raise ValueError("proxy must be in form scheme://[user:pass@]host:port")
|
||||
return proxy
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_captcha_key(self):
|
||||
if not self.captcha and any(a.type == "captcha" for a in self.actions):
|
||||
raise ValueError("action 'captcha' requires request field 'captcha' (2captcha key)")
|
||||
return self
|
||||
|
||||
|
||||
class ActionResult(BaseModel):
|
||||
type: str
|
||||
ok: bool
|
||||
result: str | None = None
|
||||
duration_ms: int
|
||||
|
||||
|
||||
class SolveResponse(BaseModel):
|
||||
"""
|
||||
Ответ /v1/solve. status — "ok" (антибот пройден, все действия выполнены) или "err".
|
||||
code — машиночитаемая причина ошибки, error — текст для человека.
|
||||
"""
|
||||
status: Literal["ok", "err"]
|
||||
code: Literal["no_slots", "request_failed", "challenge_failed", "action_failed"] | None = None
|
||||
error: str | None = None
|
||||
user_agent: str | None = None
|
||||
cookies: list[dict[str, Any]] | None = None
|
||||
actions: list[ActionResult] = []
|
||||
html: str | None = None
|
||||
screenshot: str | None = None
|
||||
duration_ms: int | None = None
|
||||
Reference in new issue
Block a user