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

+130 -200
View File
@@ -1,237 +1,167 @@
"""
Сервис api (#78): регистрирует задачи в очереди RabbitMQ и отдаёт результаты из Redis. Chrome здесь
нет — задачи выполняет сервис worker (src/worker).
* POST /v2/tasks, GET /v2/tasks/{id}, DELETE /v2/sessions/current — асинхронный API (#80, #82).
* POST /v1/solve — прежний синхронный контракт поверх той же очереди (#84).
"""
import asyncio
import base64
import json
import logging
import os
import time
import uuid
from pathlib import Path
from typing import Annotated
from urllib.parse import urlsplit
from fastapi import Cookie
from fastapi import FastAPI
from fastapi import Response
from fastapi import Cookie, FastAPI, HTTPException, Response
from fastapi.responses import JSONResponse
from patchright.async_api import Page, ProxySettings
from starlette.concurrency import run_in_threadpool
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
from api import actions, profile # noqa: E402 — модули ниже логируют при импорте, конфиг логов должен быть раньше
from api.geoip import resolve_geo # noqa: E402
from api.schemas import SolveRequest, SolveResponse # noqa: E402
from engine import AsyncStealthySession # noqa: E402
from api.schemas import SolveRequest, SolveResponse, TaskCreated, TaskRequest, TaskResponse # noqa: E402
from tasks import broker as queue # noqa: E402
from tasks.sessions import new_session_id, parse_session_id # noqa: E402
from tasks.store import Status, TaskStore, connect # noqa: E402
logger = logging.getLogger(__name__)
# сколько Chrome запускается параллельно; подбирать ≈ числу ядер (см. docs/Deployment.md)
BROWSER_SLOTS = int(os.getenv("BROWSER_SLOTS", "10"))
RETRY_AFTER = os.getenv("NO_SLOTS_RETRY_AFTER", "5")
_slots = asyncio.Semaphore(BROWSER_SLOTS)
USER_DATA_ROOT = (Path(__file__).parent / "../extra/user_data_dir").resolve()
def parse_proxy(proxy_url: str) -> ProxySettings:
parts = urlsplit(proxy_url)
server = f"{parts.scheme}://{parts.hostname}:{parts.port}"
settings: ProxySettings = {"server": server}
if parts.username:
settings["username"] = parts.username
if parts.password:
settings["password"] = parts.password
return settings
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:
"""Запрос для лога без секретов клиента: креды прокси, ключ 2captcha, вводимые значения, cookies."""
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:
"""Ответ для лога: без значений cookies, html и скриншота."""
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)
def session_dir(session_id: str | None) -> tuple[str, Path]:
"""Каталог профиля по session_id из cookie. Принимается только UUID — иначе (в т.ч. попытка
подсунуть путь вида ../..) выдаётся новая сессия."""
try:
session_id = str(uuid.UUID(session_id)) if session_id else None
except ValueError:
logger.warning("invalid session_id cookie, issuing a new session")
session_id = None
session_id = session_id or str(uuid.uuid4())
user_data_dir = (USER_DATA_ROOT / session_id).resolve()
if user_data_dir.parent != USER_DATA_ROOT:
raise ValueError("invalid session path")
return session_id, user_data_dir
def make_page_action(payload: SolveRequest):
"""Замыкание для AsyncStealthySession.fetch(page_action=...): выполняет очередь действий
(включая прохождение капчи/антибота через api.actions.execute) и достаёт из уже
навигированной страницы всё, что нужно для ответа API. Возврат этой функции — то, что
вернёт fetch() (см. engine/stealthy.py).
Непройденный антибот не бросается наружу, а возвращается в 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
# сколько /v1/solve ждёт, пока воркер возьмёт задачу, прежде чем ответить 503 no_slots
V1_QUEUE_WAIT = float(os.getenv("V1_QUEUE_WAIT", "30"))
POLL_INTERVAL = 0.2
store = TaskStore(connect())
app = FastAPI()
NO_SLOTS = {"status": "err", "code": "no_slots", "error": "No slots available"}
def no_slots_response() -> JSONResponse:
return JSONResponse(status_code=503, headers={"Retry-After": RETRY_AFTER}, content=NO_SLOTS)
def use_session(response: Response, cookie: str | None) -> str:
"""session_id из cookie или новый; cookie ставится в ответ."""
session_id = parse_session_id(cookie)
if cookie and not session_id:
logger.warning("invalid session_id cookie, issuing a new session")
session_id = session_id or new_session_id()
response.set_cookie("session_id", session_id)
return session_id
async def dispatch(message) -> None:
# pika блокирующий — отправка в пуле потоков, чтобы не останавливать event loop
await run_in_threadpool(queue.broker.enqueue, message)
async def submit(payload: TaskRequest, session_id: str, window: dict | None) -> str:
"""Зарегистрировать задачу и поставить её в очередь: в персональную очередь воркера, где открыто
окно сессии (#83), иначе — в общую."""
task_id = str(uuid.uuid4())
await store.create(task_id, session_id, payload.url)
target = queue.personal_queue(window["worker_id"]) if window else queue.COMMON_QUEUE
await dispatch(queue.task_message(
task_id, session_id, payload.model_dump(mode="json"), payload.browser_idle_timeout, target,
))
logger.info("task %s [%s] -> %s: %s", task_id, session_id, target, payload.url or "<current page>")
return task_id
async def free_slots() -> int:
return sum(w["free_slots"] for w in await store.workers())
@app.get("/")
@app.get("/health")
async def health():
return {"status": "ok", "browser_slots": BROWSER_SLOTS, "free_slots": _slots._value} # noqa
workers = await store.workers()
return {
"status": "ok",
"browser_slots": sum(w["browser_slots"] for w in workers),
"free_slots": sum(w["free_slots"] for w in workers),
"workers": workers,
}
@app.post("/v2/tasks", status_code=202, response_model=TaskCreated)
async def create_task(
payload: TaskRequest,
response: Response,
session_id: Annotated[str | None, Cookie()] = None,
):
# TODO: добавить поддержку api ключей (#4)
session_id = use_session(response, session_id)
window = await store.live_window(session_id)
if payload.url is None and window is None:
raise HTTPException(422, "url is required: the session has no open browser window")
task_id = await submit(payload, session_id, window)
return {"task_id": task_id, "status": Status.QUEUED, "url": f"/v2/tasks/{task_id}"}
@app.get("/v2/tasks/{task_id}", response_model=TaskResponse, response_model_exclude_none=True)
async def get_task(task_id: str, session_id: Annotated[str | None, Cookie()] = None):
# задача видна только своей сессии; чужая и несуществующая неразличимы
task = await store.get(task_id) if parse_session_id(task_id) else None
if not task or task["session_id"] != parse_session_id(session_id):
raise HTTPException(404, "task not found")
return {"task_id": task_id, "status": task["status"], "error": task.get("error"), "result": task.get("result")}
@app.delete("/v2/sessions/current")
async def close_session(session_id: Annotated[str | None, Cookie()] = None):
"""Закрыть окно сессии досрочно и освободить слот."""
session_id = parse_session_id(session_id)
window = await store.get_session(session_id) if session_id else None
if not window:
return {"closed": False}
await store.delete_session(session_id)
if await store.worker_alive(window["worker_id"]):
await dispatch(queue.close_message(session_id, queue.personal_queue(window["worker_id"])))
return {"closed": True}
@app.post("/v1/solve", response_model=SolveResponse, response_model_exclude_none=True)
async def solve(
payload: SolveRequest,
response: Response,
session_id: Annotated[str | None, Cookie()] = None
session_id: Annotated[str | None, Cookie()] = None,
):
"""Синхронный контракт v1: задача ставится в ту же очередь, ответ — когда воркер её выполнит.
Если свободных слотов нет или воркер не взял задачу за V1_QUEUE_WAIT — 503, как и раньше."""
# TODO: добавить поддержку api ключей (#4)
started = time.monotonic()
session_id = use_session(response, session_id)
window = await store.live_window(session_id)
if window is None and await free_slots() <= 0:
logger.warning("no free browser slots, rejecting %s", payload.url)
return no_slots_response()
if _slots.locked():
logger.warning("no free browser slots (%d), rejecting %s", BROWSER_SLOTS, payload.url)
return JSONResponse(
status_code=503,
headers={"Retry-After": RETRY_AFTER},
content={"status": "err", "code": "no_slots", "error": "No slots available"},
)
task_id = await submit(TaskRequest.model_validate(payload.model_dump()), session_id, window)
while True:
task = await store.get(task_id)
if task is None:
result = {"status": "err", "code": "request_failed", "error": "task expired"}
break
if task["status"] == Status.DONE:
result = task["result"]
break
if task["status"] in (Status.FAILED, Status.CANCELLED):
result = {"status": "err", "code": "request_failed", "error": task.get("error") or task["status"]}
break
if (
task["status"] == Status.QUEUED
and time.monotonic() - started > V1_QUEUE_WAIT
and await store.cancel(task_id)
):
logger.warning("task %s was not picked up in %ss, rejecting", task_id, V1_QUEUE_WAIT)
return no_slots_response()
await asyncio.sleep(POLL_INTERVAL)
async with _slots:
session_id, user_data_dir = session_dir(session_id)
response.set_cookie("session_id", session_id)
logger.info("Incoming request [%s]: %s", session_id, payload.url)
logger.debug("Incoming payload [%s]:\n%s", session_id, safe_request_dump(payload))
try:
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
locale = geo.get("locale") if geo else None
nav_timeout_ms = payload.timeout * 1000 if payload.timeout else 60_000
blocked_domains = set(payload.blocked_domains) if payload.blocked_domains else None
kwargs = dict(
headless=False,
proxy=proxy,
user_data_dir=str(user_data_dir),
real_chrome=True,
locale=locale,
timezone_id=geo.get("timezone_id") if geo else None,
cookies=payload.cookies,
# переданные cookies заменяют cookies профиля, а не дополняют их
clear_cookies=payload.cookies is not None,
disable_resources=payload.disable_resources,
blocked_domains=blocked_domains,
timeout=nav_timeout_ms,
retries=max(1, payload.retries),
retry_delay=payload.retry_delay,
wait=payload.wait,
google_search=payload.google_search,
**profile_kwargs,
)
async with AsyncStealthySession(**kwargs) as engine:
result = await engine.fetch(payload.url, page_action=make_page_action(payload))
except Exception as e:
logger.exception("request failed [%s]", session_id)
result_payload = {
"status": "err",
"code": "request_failed",
"error": str(e) or type(e).__name__,
}
else:
failed = [a for a in result["actions"] if a.get("ok") is False]
result_payload = {
"status": "ok",
"user_agent": result["user_agent"],
"cookies": result["cookies"],
"actions": result["actions"],
}
if result["challenge_error"]:
result_payload.update(status="err", code="challenge_failed", error=result["challenge_error"])
elif failed:
result_payload.update(
status="err", code="action_failed",
error=f"{failed[0]['type']}: {failed[0].get('result')}",
)
if payload.html:
result_payload["html"] = result["html"]
if payload.screenshot:
result_payload["screenshot"] = result["screenshot"]
result_payload["duration_ms"] = int((time.monotonic() - started) * 1000)
logger.info(
"Outgoing [%s]: %s %s %dms", session_id, result_payload["status"],
result_payload.get("error", ""), result_payload["duration_ms"],
)
logger.debug("Outgoing payload:\n%s", safe_response_dump(result_payload))
return result_payload
result.setdefault("actions", [])
result["duration_ms"] = int((time.monotonic() - started) * 1000)
return result