feature: архитектура v2 — api + очередь + worker, удержание окна браузера (#78)

- Сервис api (FastAPI, без Chrome): POST /v2/tasks, GET /v2/tasks/{id} (доступ по cookie
  сессии), DELETE /v2/sessions/current; /v1/solve — прежний контракт поверх очереди.
- Сервис worker (dramatiq + RabbitMQ): слоты браузера, окна сессий между задачами
  (browser_idle_timeout 0–120 мин, простой — TTL ключа в Redis), heartbeat.
- Маршрутизация задач сессии в персональную очередь воркера с открытым окном.
- Redis: задачи, результаты (RESULT_TTL_HOURS), окна, воркеры. Прокси и ключ 2captcha
  в Redis не сохраняются.
- Движок: AsyncStealthySession.run() — действия на текущей странице без навигации.
- Два образа (robotex-api, robotex-worker), compose с rabbitmq и redis,
  make up/down/logs/smoke, релиз собирает все образы.
- Тесты: api + worker в одном процессе (fakeredis), настоящий воркер dramatiq на
  StubBroker, браузерные тесты на новой схеме, smoke для запущенного стенда.
- Документация: README, API (v2), Deployment, Usage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-13 19:13:52 +04:00
1 parent fe509c25c3
commit 7b511d5d38
38 files changed
+2142 -545

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
"""Запросы и ответы для лога без секретов клиента: креды прокси, ключ 2captcha, вводимые значения,
значения cookies, html и скриншот."""
import json
from urllib.parse import urlsplit
from api.schemas import SolveRequest
def mask_proxy(proxy_url: str | None) -> str | None:
if not proxy_url:
return proxy_url
parts = urlsplit(proxy_url)
return f"{parts.scheme}://{'***@' if parts.username else ''}{parts.hostname}:{parts.port}"
def safe_request_dump(payload: SolveRequest) -> str:
data = payload.model_dump(exclude_none=True)
data["proxy"] = mask_proxy(payload.proxy)
if payload.captcha:
data["captcha"] = "***"
if payload.cookies:
data["cookies"] = [c.get("name") for c in payload.cookies]
for act in data["actions"]:
if act["type"] == "fill" and "value" in act:
act["value"] = "***"
return json.dumps(data, indent=2, ensure_ascii=False)
def safe_response_dump(result: dict) -> str:
data = dict(result)
if data.get("cookies"):
data["cookies"] = [c.get("name") for c in data["cookies"]]
for key in ("html", "screenshot"):
if data.get(key):
data[key] = f"<{len(data[key])} chars>"
return json.dumps(data, indent=2, ensure_ascii=False)
+32 -1
View File
@@ -1,9 +1,11 @@
from typing import Any, Literal
from urllib.parse import urlsplit
from pydantic import BaseModel, field_validator, model_validator
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)
@@ -143,3 +145,32 @@ class SolveResponse(BaseModel):
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