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
+130
-200
@@ -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
|
||||
Reference in new issue
Block a user