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

+348
View File
@@ -0,0 +1,348 @@
"""
Слоты браузера и окна сессий воркера (#81, #82).
Слот — один открытый Chrome. `BROWSER_SLOTS` ограничивает число окон воркера: и тех, где идёт задача,
и удерживаемых между задачами. Окно сессии остаётся открытым после задачи, если в ней задан
`browser_idle_timeout > 0`: следующая задача с той же cookie `session_id` попадает в это окно
(маршрутизация — в api, #83) и продолжает работу без перезапуска Chrome. Время простоя считает
Redis: TTL ключа `session:<id>`, каждая задача в окно ставит его заново. Ключ исчез — окно
закрывается фоновой проверкой (`expire_idle`).
Задачи одной сессии выполняются строго по очереди: профиль Chrome нельзя открыть дважды.
Всё здесь работает в одном event loop (поток AsyncIO-middleware dramatiq), поэтому счётчик слотов
и словарь окон не требуют блокировок между потоками.
"""
import asyncio
import base64
import logging
import time
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from patchright.async_api import Page, ProxySettings
from api import actions, profile
from api.geoip import resolve_geo
from api.logs import safe_request_dump, safe_response_dump
from api.schemas import TaskRequest
from engine import AsyncStealthySession
from tasks.store import RUNNING_TTL, Status, TaskStore
log = logging.getLogger(__name__)
USER_DATA_ROOT = (Path(__file__).parent / "../../extra/user_data_dir").resolve()
class Outcome:
DONE = "done"
# свободного слота нет — задачу нужно вернуть в очередь
NO_SLOT = "no_slot"
# задачу выполнять не нужно: отменена, уже выполнена или выполняется
SKIPPED = "skipped"
class NoWindow(Exception):
pass
@dataclass
class Window:
session_id: str
engine: Any = None
# прокси, с которым запущен Chrome: он задаётся при запуске, и задача с другим прокси перезапускает окно
proxy: str | None = None
# окно закрыть, как только закончится текущая задача (DELETE /v2/sessions/current)
closing: bool = False
@dataclass
class _SessionLock:
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
users: int = 0
def parse_proxy(proxy_url: str) -> ProxySettings:
parts = urlsplit(proxy_url)
settings: ProxySettings = {"server": f"{parts.scheme}://{parts.hostname}:{parts.port}"}
if parts.username:
settings["username"] = parts.username
if parts.password:
settings["password"] = parts.password
return settings
def session_dir(session_id: str) -> Path:
user_data_dir = (USER_DATA_ROOT / session_id).resolve()
if user_data_dir.parent != USER_DATA_ROOT:
raise ValueError("invalid session path")
return user_data_dir
def make_page_action(payload: TaskRequest):
"""page_action для AsyncStealthySession.fetch()/run(): выполняет очередь действий (вместе с
прохождением антибота, api.actions.execute) и достаёт со страницы всё, что нужно для ответа.
Непройденный антибот не бросается наружу, а возвращается в challenge_error — чтобы в ответ
всё равно попали cookies, html и скриншот страницы-заглушки."""
async def page_action(page: Page) -> dict:
challenge_error = None
try:
actions_result = await actions.execute(
page, payload.actions, payload.captcha,
antibot_name=payload.captcha_type,
success_locator=payload.captcha_success_locator,
detect_locator=payload.captcha_detect_locator,
)
except actions.ChallengeFailed as e:
challenge_error = str(e)
actions_result = []
screenshot = None
if payload.screenshot:
screenshot = base64.b64encode(await page.screenshot(type="png")).decode()
return {
"challenge_error": challenge_error,
"actions": actions_result,
"cookies": await page.context.cookies(),
"html": await page.inner_html("html"),
"screenshot": screenshot,
"user_agent": await page.evaluate("() => navigator.userAgent"),
}
return page_action
def build_result(payload: TaskRequest, result: dict) -> dict:
"""Ответ в формате SolveResponse из результата page_action."""
failed = [a for a in result["actions"] if a.get("ok") is False]
response = {
"status": "ok",
"user_agent": result["user_agent"],
"cookies": result["cookies"],
"actions": result["actions"],
}
if result["challenge_error"]:
response.update(status="err", code="challenge_failed", error=result["challenge_error"])
elif failed:
response.update(status="err", code="action_failed", error=f"{failed[0]['type']}: {failed[0].get('result')}")
if payload.html:
response["html"] = result["html"]
if payload.screenshot:
response["screenshot"] = result["screenshot"]
return response
class WindowPool:
def __init__(self, store: TaskStore, worker_id: str, slots: int):
self.store = store
self.worker_id = worker_id
self.slots = slots
self.used = 0
self.windows: dict[str, Window] = {}
self.running: set[str] = set()
self._locks: dict[str, _SessionLock] = {}
def info(self) -> dict:
return {
"browser_slots": self.slots,
"free_slots": self.slots - self.used,
"windows": len(self.windows),
"running": len(self.running),
}
@asynccontextmanager
async def _session_lock(self, session_id: str):
entry = self._locks.setdefault(session_id, _SessionLock())
entry.users += 1
try:
async with entry.lock:
yield
finally:
entry.users -= 1
if not entry.users:
del self._locks[session_id]
async def report(self) -> None:
"""heartbeat со слотами сразу после их изменения: по нему api решает, отвечать ли 503 на /v1/solve."""
try:
await self.store.heartbeat(self.worker_id, self.info())
except Exception:
log.warning("heartbeat failed", exc_info=True)
def _busy(self, session_id: str) -> bool:
entry = self._locks.get(session_id)
return bool(entry and entry.lock.locked())
async def handle(self, task_id: str, session_id: str, payload: dict, idle_timeout: int) -> str:
"""Выполнить задачу. Результат — в Redis; возврат — что делать с сообщением очереди."""
async with self._session_lock(session_id):
window = self.windows.get(session_id)
new_window = window is None
if new_window:
if self.used >= self.slots:
task = await self.store.get(task_id)
# отменённую или истёкшую задачу незачем возвращать в очередь
return Outcome.NO_SLOT if task and task["status"] == Status.QUEUED else Outcome.SKIPPED
# слот занимается до первого await, иначе его может перехватить другая задача
self.used += 1
window = self.windows[session_id] = Window(session_id)
await self.report()
if not await self.store.claim(task_id, self.worker_id, running_here=task_id in self.running):
if new_window:
await self._close(window)
return Outcome.SKIPPED
self.running.add(task_id)
try:
await self.store.set_session(session_id, self.worker_id, idle_timeout, RUNNING_TTL)
result = await self._execute(window, TaskRequest.model_validate(payload))
await self.store.finish(task_id, result)
except Exception as e:
log.exception("task %s failed", task_id)
with suppress(Exception):
await self.store.fail(task_id, str(e) or type(e).__name__)
except asyncio.CancelledError:
# dramatiq прервал задачу по time limit или при остановке воркера
with suppress(Exception):
await self.store.fail(task_id, "task interrupted")
raise
finally:
self.running.discard(task_id)
if idle_timeout and window.engine and not window.closing:
await self.store.set_session(session_id, self.worker_id, idle_timeout, idle_timeout * 60)
else:
await self._close(window)
with suppress(Exception):
await self.store.delete_session(session_id)
return Outcome.DONE
async def _execute(self, window: Window, payload: TaskRequest) -> dict:
started = time.monotonic()
session_id = window.session_id
log.info("Incoming request [%s]: %s", session_id, payload.url or "<current page>")
log.debug("Incoming payload [%s]:\n%s", session_id, safe_request_dump(payload))
page_action = make_page_action(payload)
nav_kwargs = dict(
timeout=payload.timeout * 1000 if payload.timeout else 60_000,
disable_resources=payload.disable_resources,
blocked_domains=set(payload.blocked_domains) if payload.blocked_domains else None,
)
try:
if window.engine and window.proxy != payload.proxy:
log.info("proxy changed, restarting browser [%s]", session_id)
await self._close_engine(window)
if window.engine is None:
if not payload.url:
raise NoWindow("url is required: the session has no open browser window")
window.engine = await self._open(session_id, payload, nav_kwargs)
window.proxy = payload.proxy
elif payload.cookies is not None:
# переданные cookies заменяют cookies профиля, как и при запуске окна
await window.engine.context.clear_cookies()
if payload.cookies:
await window.engine.context.add_cookies(payload.cookies)
if payload.url:
raw = await window.engine.fetch(
payload.url, page_action=page_action,
wait=payload.wait, google_search=payload.google_search, **nav_kwargs,
)
else:
raw = await window.engine.run(page_action, **nav_kwargs)
except Exception as e:
if not isinstance(e, NoWindow):
log.exception("request failed [%s]", session_id)
# окно могло остаться в непонятном состоянии: следующая задача откроет его заново из профиля
await self._close_engine(window)
result = {"status": "err", "code": "request_failed", "error": str(e) or type(e).__name__}
else:
result = build_result(payload, raw)
result["duration_ms"] = int((time.monotonic() - started) * 1000)
log.info("Outgoing [%s]: %s %s %dms", session_id, result["status"], result.get("error", ""), result["duration_ms"])
log.debug("Outgoing payload:\n%s", safe_response_dump(result))
return result
async def _open(self, session_id: str, payload: TaskRequest, nav_kwargs: dict):
user_data_dir = session_dir(session_id)
user_data_dir.mkdir(parents=True, exist_ok=True)
# отпечаток задаётся профилю один раз (см. api/profile.py)
profile_kwargs = profile.browser_kwargs(profile.load_or_create(user_data_dir))
proxy = parse_proxy(payload.proxy) if payload.proxy else None
# geo — по exit-IP прокси (см. api/geoip.py); без прокси оставляем настройки браузера
geo = await resolve_geo(proxy) if proxy else None
engine = AsyncStealthySession(
headless=False,
proxy=proxy,
user_data_dir=str(user_data_dir),
real_chrome=True,
locale=geo.get("locale") if geo else None,
timezone_id=geo.get("timezone_id") if geo else None,
cookies=payload.cookies,
# переданные cookies заменяют cookies профиля, а не дополняют их
clear_cookies=payload.cookies is not None,
retries=max(1, payload.retries),
retry_delay=payload.retry_delay,
wait=payload.wait,
google_search=payload.google_search,
**nav_kwargs,
**profile_kwargs,
)
await engine.start()
return engine
async def _close_engine(self, window: Window) -> None:
engine, window.engine = window.engine, None
if engine is not None:
try:
await engine.close()
except Exception:
log.warning("failed to close browser [%s]", window.session_id, exc_info=True)
async def _close(self, window: Window) -> None:
"""Закрыть окно и освободить его слот."""
await self._close_engine(window)
if self.windows.get(window.session_id) is window:
del self.windows[window.session_id]
self.used -= 1
log.info("window closed [%s]", window.session_id)
await self.report()
async def close(self, session_id: str) -> None:
"""Закрыть окно сессии досрочно. Если в нём идёт задача — сразу после неё."""
window = self.windows.get(session_id)
if window is None:
return
window.closing = True
if self._busy(session_id):
return
async with self._session_lock(session_id):
if self.windows.get(session_id) is window:
await self._close(window)
async def expire_idle(self) -> None:
"""Закрыть окна, чей ключ сессии в Redis истёк (простой дольше browser_idle_timeout)."""
for session_id, window in list(self.windows.items()):
if self._busy(session_id) or await self.store.get_session(session_id):
continue
async with self._session_lock(session_id):
if self.windows.get(session_id) is window and not await self.store.get_session(session_id):
log.info("browser idle timeout [%s]", session_id)
await self._close(window)
async def close_all(self) -> None:
for session_id, window in list(self.windows.items()):
await self._close(window)
with suppress(Exception):
await self.store.delete_session(session_id)