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:
protokeyandClaude Opus 5.5 committed 2026-10-13 14:52:12 +04:00
1 parent f58306cbb4
commit 437ea27be7
14 files changed
+360 -201

No files matched your search

+30 -23
View File
@@ -1,8 +1,8 @@
import asyncio
import time
from patchright.async_api import Page
from antibot.base import click_and_race
from antibot.orchestrator import pass_challenges
from captcha import load_captcha
from api.schemas import ActionRequest
@@ -10,50 +10,52 @@ from api.schemas import ActionRequest
_actions: dict = {}
class ChallengeFailed(Exception):
pass
def register(fun) -> None:
_actions[fun.__name__] = fun
return fun
async def _query(page: Page, selector: str):
element = await page.query_selector(selector)
if element is None:
raise LookupError(f"selector not found: {selector}")
return element
@register
async def fill(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
assert act.value
await page.fill(act.selector, act.value)
@register
async def captcha(page: Page, act: ActionRequest, captcha_key: str, **kwargs) -> str | None:
assert act.selector
assert act.value
assert captcha_key
captcha_solver = load_captcha(captcha_key, act.value)
await captcha_solver.solve(act.selector, page)
@register
async def delay(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.value
await asyncio.sleep(int(act.value))
@register
async def mouse_move(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
element = await page.query_selector(act.selector)
element = await _query(page, act.selector)
await element.hover(scroll="auto")
@register
async def click(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
element = page.locator(act.selector)
await element.click()
@register
async def submit(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
element = page.locator(act.selector)
async with page.expect_event(
@@ -65,15 +67,12 @@ async def submit(page: Page, act: ActionRequest, **kwargs) -> str | None:
@register
async def inner_html(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
element = await page.query_selector(act.selector)
element = await _query(page, act.selector)
return await element.inner_html()
@register
async def wait_for(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
assert 1 <= act.timeout <= 7200
await page.wait_for_selector(act.selector, timeout=act.timeout * 1000)
@@ -99,7 +98,10 @@ async def execute(
captcha_key: str | None = None, antibot_name: str | None = None,
success_locator: str | None = None,
detect_locator: str | None = None) -> list[dict]:
"""Выполнение очереди автоматизации"""
"""Выполнение очереди автоматизации.
Если антибот не пройден — бросает ChallengeFailed, действия не выполняются: на странице-заглушке
они бы упали с невнятной ошибкой селектора."""
actions_result = []
ctx = {
"captcha_key": captcha_key,
@@ -107,27 +109,32 @@ async def execute(
"success_locator": success_locator,
"detect_locator": detect_locator,
}
await pass_challenges(
outcome = await pass_challenges(
antibot_name, page,
captcha=captcha_key,
success_locator=success_locator,
detect_locator=detect_locator,
)
if not outcome.cleared:
stage = outcome.last_stage.value if outcome.last_stage else None
raise ChallengeFailed(f"{antibot_name} not passed: {outcome.reason} (stage: {stage})")
for act in actions:
new_line = {
"type": act.type,
}
started = time.monotonic()
try:
# TODO: добавить время выполнения
result = await _actions[act.type](page, act, **ctx)
except Exception as e:
new_line["ok"] = False
new_line["result"] = str(e)
actions_result.append(new_line)
break
new_line["result"] = str(e) or type(e).__name__
else:
new_line["ok"] = True
if result:
new_line["result"] = result
actions_result.append(new_line)
return actions_result if len(actions_result) else [{"status": "pass"}]
new_line["duration_ms"] = int((time.monotonic() - started) * 1000)
actions_result.append(new_line)
if not new_line["ok"]:
break
return actions_result
+94 -4
View File
@@ -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