Files
robotex/src/api/schemas.py
T

178 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any, Literal
from urllib.parse import urlsplit
from pydantic import BaseModel, Field, field_validator, model_validator
CAPTCHA_TYPES = ("cloudflare", "recaptcha", "hcaptcha")
# максимум browser_idle_timeout в минутах: открытое окно держит слот браузера (#78)
MAX_BROWSER_IDLE_TIMEOUT = 120
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",),
"inner_text": ("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 | None = None
timeout: int | None = None
cookies: list[dict[str, Any]] | None = None
# List[{name: str, value: str, domain: str, path: str, expires: float, httpOnly: bool, secure: bool, sameSite: Union["Lax", "None", "Strict"], partitionKey: Union[str, None]}]
actions: list[ActionRequest]
html: bool = False
screenshot: bool = False
captcha: str | None = None
captcha_detect_locator: str | None = None
captcha_success_locator: str | None = None
captcha_type: str | None = None
# ниже — опции навигации, портированные из scrapling.engines._browsers (StealthySession.fetch),
# все выключены/нейтральны по умолчанию, чтобы поведение для текущих вызывающих не менялось
disable_resources: bool = False
blocked_domains: list[str] | None = None
retries: int = 1
retry_delay: float = 1.0
wait: int = 0
google_search: bool = False
@model_validator(mode="before")
@classmethod
def reject_browser_identity(cls, data):
# UA и размер окна задаёт сам браузер: подмена расходится с userAgentData и движком,
# и Cloudflare такой браузер не пропускает (см. docs/Usage.md)
if isinstance(data, dict) and "user_agent" in data:
raise ValueError(
"user_agent is not supported: take user_agent from the response instead (see docs/Usage.md)"
)
return data
@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
class TaskRequest(SolveRequest):
"""
Задача POST /v2/tasks: тело как у /v1/solve плюс удержание окна браузера.
url необязателен, если окно сессии открыто: тогда действия выполняются на текущей странице.
"""
url: str | None = None
# минуты простоя, через которые окно сессии закроется; 0 — закрыть сразу после задачи
browser_idle_timeout: int = Field(0, ge=0, le=MAX_BROWSER_IDLE_TIMEOUT)
class TaskCreated(BaseModel):
task_id: str
status: str
url: str
class TaskResponse(BaseModel):
"""
GET /v2/tasks/{id}. status: queued → running → done | failed.
done — результат в result (в нём свой status ok/err, как в /v1/solve);
failed — задачу не удалось выполнить (воркер упал), причина в error.
"""
task_id: str
status: Literal["queued", "running", "done", "failed", "cancelled"]
error: str | None = None
result: SolveResponse | None = None