import asyncio import base64 import gzip import json import re import time import uuid from collections.abc import Awaitable, Callable from pathlib import Path from playwright.async_api import ( Browser, BrowserContext, Error as PlaywrightError, Page, Playwright, TimeoutError as PlaywrightTimeoutError, async_playwright, ) from .config import Settings class ArenaBridgeError(RuntimeError): def __init__(self, message: str, *, code: str, status_code: int = 502) -> None: super().__init__(message) self.code = code self.status_code = status_code class ArenaPlaywrightBridge: """Drive Arena's normal web UI with Playwright and expose text results to FastAPI.""" def __init__(self, settings: Settings) -> None: self.settings = settings self._playwright: Playwright | None = None self._browser: Browser | None = None self._context: BrowserContext | None = None self._semaphore = asyncio.Semaphore(settings.max_concurrency) self._model_lock = asyncio.Lock() self._models: list[str] = [] self._models_cached_at = 0.0 self._storage_state: dict | None = self._decode_storage_state( settings.arena_storage_state_b64 ) @property def ready(self) -> bool: return bool(self._browser and self._browser.is_connected() and self._context) @staticmethod def _decode_storage_state(encoded: str) -> dict | None: if not encoded.strip(): return None try: raw = base64.b64decode(encoded.strip(), validate=True) if raw.startswith(b"\x1f\x8b"): raw = gzip.decompress(raw) state = json.loads(raw.decode("utf-8")) except Exception as exc: raise RuntimeError("ARENA_STORAGE_STATE_B64 is not valid base64 JSON") from exc if not isinstance(state, dict) or not isinstance(state.get("cookies", []), list): raise RuntimeError("ARENA_STORAGE_STATE_B64 does not contain Playwright storage_state") return state def _storage_state_without_auth_cookie(self) -> dict | None: """Keep account-owner Local Storage consent while forcing a fresh Arena login.""" if not self._storage_state: return None return { **self._storage_state, "cookies": [ cookie for cookie in self._storage_state.get("cookies", []) if not str(cookie.get("name") or "").startswith("arena-auth-prod-v1") ], } async def start(self) -> None: if not self.settings.bridge_api_key: raise RuntimeError("BRIDGE_API_KEY must be configured") has_cookie = bool(self.settings.arena_auth_token) has_storage_state = bool(self._storage_state) has_credentials = bool(self.settings.arena_email and self.settings.arena_password) if not has_cookie and not has_storage_state and not has_credentials: raise RuntimeError( "Configure ARENA_STORAGE_STATE_B64, ARENA_AUTH_TOKEN, or both " "ARENA_EMAIL and ARENA_PASSWORD" ) self._playwright = await async_playwright().start() self._browser = await self._playwright.chromium.launch( headless=True, args=["--disable-dev-shm-usage"], ) try: if has_storage_state and has_credentials: # Re-login with a fresh Cookie while retaining the account owner's # manually established Local Storage/consent state. await self.login_with_credentials() elif has_storage_state: self._context = await self._create_context(storage_state=self._storage_state) if has_cookie and not await self._read_auth_cookie(self._context): await self._add_auth_cookie(self._context, self.settings.arena_auth_token) elif has_credentials: await self.login_with_credentials() else: self._context = await self._create_context() await self._add_auth_cookie(self._context, self.settings.arena_auth_token) except Exception: await self.stop() raise async def _create_context(self, *, storage_state: dict | None = None) -> BrowserContext: if not self._browser or not self._browser.is_connected(): raise ArenaBridgeError( "Chromium is not available", code="browser_not_ready", status_code=503 ) kwargs = { "viewport": { "width": self.settings.viewport_width, "height": self.settings.viewport_height, }, "device_scale_factor": 1.0, "locale": "en-US", } if storage_state: kwargs["storage_state"] = storage_state return await self._browser.new_context(**kwargs) @staticmethod async def _add_auth_cookie(context: BrowserContext, token: str) -> None: # Large auth values may be split into numbered cookie chunks by web frameworks. chunk_size = 3500 values = [token[index : index + chunk_size] for index in range(0, len(token), chunk_size)] cookies = [] for index, value in enumerate(values): name = "arena-auth-prod-v1" if len(values) == 1 else f"arena-auth-prod-v1.{index}" cookies.append( { "name": name, "value": value, "url": "https://arena.ai/", "httpOnly": True, "secure": True, "sameSite": "Lax", } ) if cookies: await context.add_cookies(cookies) @staticmethod async def _read_auth_cookie(context: BrowserContext) -> str: cookies = await context.cookies(["https://arena.ai/"]) for cookie in cookies: if cookie.get("name") == "arena-auth-prod-v1" and cookie.get("value"): return str(cookie["value"]) chunks: list[tuple[int, str]] = [] for cookie in cookies: match = re.fullmatch(r"arena-auth-prod-v1\.(\d+)", str(cookie.get("name") or "")) if match and cookie.get("value"): chunks.append((int(match.group(1)), str(cookie["value"]))) return "".join(value for _, value in sorted(chunks)) @staticmethod async def _first_visible(scope, selector: str): candidates = scope.locator(selector) for index in range(await candidates.count()): candidate = candidates.nth(index) if await candidate.is_visible(): return candidate return None @classmethod async def _button_by_accessible_names(cls, scope, names: list[str]): """Prefer a semantically named button instead of an unrelated submit icon.""" for name in names: candidates = scope.get_by_role( "button", name=re.compile(rf"^\s*{re.escape(name)}\s*$", re.IGNORECASE) ) for index in range(await candidates.count()): candidate = candidates.nth(index) if await candidate.is_visible(): return candidate return None @staticmethod async def _scope_near_input(page: Page, input_locator): """Use the input's form/dialog before ever searching the entire Arena page.""" form = input_locator.locator("xpath=ancestor::form[1]") if await form.count(): return form dialog = input_locator.locator("xpath=ancestor::*[@role='dialog'][1]") if await dialog.count(): return dialog # Some React auth widgets do not use