Files
robotex/src/worker/windows.py
T
protokeyandClaude Opus 5.5 f21483489a feat: url текущей страницы в ответе задачи (#116)
SPA меняет адрес без навигации (страница созданной заявки и т.п.): ответ v1/v2 отдаёт page.url
после действий, клиент 0.2.1 - Result.url. Документация и unit-тесты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:02:08 +04:00

450 lines
18 KiB
Python

"""
Слоты браузера и окна сессий воркера (#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 os
import shutil
import socket
import time
import uuid
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
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_data, safe_request_dump, safe_response_dump
from api.schemas import TaskRequest
from db.journal import Journal
from engine import AsyncStealthySession
from tasks.sessions import parse_session_id
from tasks.store import RUNNING_TTL, Status, TaskStore
log = logging.getLogger(__name__)
USER_DATA_ROOT = (Path(__file__).parent / "../../extra/user_data_dir").resolve()
# профиль, которым не пользовались столько дней, удаляется (#20)
PROFILE_TTL_DAYS = float(os.getenv("PROFILE_TTL_DAYS", "7"))
# время последнего использования профиля — mtime этого файла
LAST_USED_FILE = ".robotex_last_used"
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
client_id: int | None = None
# строка окна в журнале (#17): открыто — закрыто
journal_id: int | None = None
@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 touch_profile(user_data_dir: Path) -> None:
(user_data_dir / LAST_USED_FILE).touch()
def profile_last_used(user_data_dir: Path) -> float:
try:
return (user_data_dir / LAST_USED_FILE).stat().st_mtime
except OSError:
# профиль из версии без отметки — по времени изменения каталога
return user_data_dir.stat().st_mtime
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"),
# адрес страницы после действий: SPA меняет его без навигации (номер заявки и т.п.)
"url": page.url,
}
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"],
"url": result.get("url"),
"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 | None, slots: int, journal: Journal | None = None,
on_lost: Callable[[], None] | None = None,
):
"""worker_id None — имя берётся в Redis при register() (#110).
on_lost — вызывается, если имя занял другой процесс: такому воркеру надо остановиться."""
self.store = store
self.journal = journal or Journal(None)
self.worker_id = worker_id
self.instance = uuid.uuid4().hex
self.on_lost = on_lost
self.lost = False
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),
"instance": self.instance,
"host": socket.gethostname(),
"pid": os.getpid(),
}
async def register(self) -> str:
"""Занять свободное имя worker-N: оно же — персональная очередь воркера."""
self.worker_id = await self.store.acquire_worker_name(self.info())
return self.worker_id
async def unregister(self) -> None:
await self.store.remove_worker(self.worker_id, self.instance)
@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."""
if self.lost:
return
try:
owned = await self.store.heartbeat(self.worker_id, self.info())
except Exception:
log.warning("heartbeat failed", exc_info=True)
return
if not owned:
# Redis был недоступен дольше HEARTBEAT_TTL, и имя занял другой процесс: задачи его очереди
# теперь не наши — останавливаемся, после перезапуска возьмём свободное имя
self.lost = True
log.error("worker name %s is taken by another process, stopping", self.worker_id)
if self.on_lost:
self.on_lost()
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, meta: dict | None = None,
) -> str:
"""Выполнить задачу. Результат — в Redis; возврат — что делать с сообщением очереди.
meta: client_id и api (v1/v2) — для журнала."""
meta = meta or {}
client_id = meta.get("client_id")
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, client_id=client_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)
task = TaskRequest.model_validate(payload)
result = None
queued = await self.store.get(task_id) or {}
await self.journal.request_started(
task_id, client_id, session_id, meta.get("api", "v2"),
float(queued["created_at"]) if queued.get("created_at") else None, safe_request_data(task),
)
try:
await self.store.set_session(session_id, self.worker_id, idle_timeout, RUNNING_TTL, client_id)
result = await self._execute(window, task, task_id)
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)
await self.journal.request_finished(task_id, window.journal_id, result)
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, client_id)
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, task_id: str) -> 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
window.journal_id = await self.journal.window_opened(window.client_id, session_id, task_id, self.worker_id)
else:
await self.journal.window_used(window.journal_id)
if 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)
touch_profile(user_data_dir)
# отпечаток задаётся профилю один раз (см. api/profile.py)
profile_kwargs = profile.browser_kwargs(profile.load_or_create(user_data_dir))
# без менеджера паролей, перевода и автозаполнения (#111)
profile.apply_chrome_prefs(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
journal_id, window.journal_id = window.journal_id, None
await self.journal.window_closed(journal_id)
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:
"""Закрыть окно и освободить его слот."""
if window.engine is not None:
with suppress(OSError):
touch_profile(session_dir(window.session_id))
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)
async def cleanup_profiles(self, ttl_days: float = PROFILE_TTL_DAYS) -> list[str]:
"""Удалить профили, которыми не пользовались ttl_days дней (#20). Профиль с открытым окном —
на этом воркере или на другом (ключ session:<id> в Redis) — не трогается."""
if not USER_DATA_ROOT.is_dir():
return []
deadline = time.time() - ttl_days * 86400
removed = []
for user_data_dir in USER_DATA_ROOT.iterdir():
session_id = parse_session_id(user_data_dir.name)
if not session_id or not user_data_dir.is_dir() or profile_last_used(user_data_dir) > deadline:
continue
if session_id in self.windows or self._busy(session_id) or await self.store.get_session(session_id):
continue
await asyncio.to_thread(shutil.rmtree, user_data_dir, True)
removed.append(session_id)
if removed:
log.info("removed %d browser profiles unused for %s days", len(removed), ttl_days)
return removed