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:
1 parent
fe509c25c3
commit
7b511d5d38
38 files changed
+2142
-545
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user