""" BrowserSession — async Playwright wrapper for the Tripplanner browser-automation layer. AI subagents (claude-haiku with tool_use) call the exposed methods to navigate delta.com, ihg.com, and resy.com. A persistent Chromium profile keeps users logged in between runs. Usage: async with BrowserSession(user_data_dir="/tmp/tripplanner-profile") as browser: await browser.navigate("https://www.delta.com") text = await browser.get_page_text() """ from __future__ import annotations import base64 import os from pathlib import Path from typing import Any from playwright.async_api import async_playwright, BrowserContext, Page, TimeoutError as PWTimeoutError class BrowserSession: """Async context manager wrapping a Chromium browser. On Render (or any headless environment), set BROWSER_USER_DATA_DIR to a persistent disk path and upload sessions.json via /api/session/upload. Locally, run scripts/session_setup.py once to log in interactively. """ def __init__( self, user_data_dir: str, headless: bool | None = None, viewport: dict[str, int] | None = None, slow_mo: float = 0, ) -> None: self._user_data_dir = user_data_dir # Auto-detect headless: if no DISPLAY env var, force headless (i.e. on Render) if headless is None: self._headless = not bool(os.getenv("DISPLAY", "")) or os.getenv("RENDER") == "true" else: self._headless = headless self._viewport = viewport or {"width": 1280, "height": 900} self._slow_mo = slow_mo self._pw = None self._context: BrowserContext | None = None self._page: Page | None = None def _session_file(self) -> Path | None: """Return path to sessions.json if available. Priority: 1. BROWSER_SESSION_JSON env var (base64-encoded) — for HF Spaces / ephemeral envs 2. sessions.json on disk (uploaded via /api/session/upload) """ # 1. Decode from env var (HF Spaces stores secrets as env vars) b64 = os.getenv("BROWSER_SESSION_JSON", "") if b64: out = Path(self._user_data_dir) / "sessions.json" out.parent.mkdir(parents=True, exist_ok=True) out.write_bytes(base64.b64decode(b64)) return out # 2. File on disk (Render persistent disk or local upload) p = Path(self._user_data_dir) / "sessions.json" return p if p.exists() else None # ------------------------------------------------------------------ # Async context manager # ------------------------------------------------------------------ async def __aenter__(self) -> "BrowserSession": self._pw = await async_playwright().start() session_file = self._session_file() if session_file: # Render / deployed: use a regular (non-persistent) context with saved storage state. browser = await self._pw.chromium.launch( headless=self._headless, slow_mo=self._slow_mo, ) self._context = await browser.new_context( storage_state=str(session_file), viewport=self._viewport, ) else: # Local: use a persistent profile so the user stays logged in. self._context = await self._pw.chromium.launch_persistent_context( self._user_data_dir, headless=self._headless, viewport=self._viewport, slow_mo=self._slow_mo, ) if self._context.pages: self._page = self._context.pages[0] else: self._page = await self._context.new_page() return self async def __aexit__(self, *_: Any) -> None: if self._context: await self._context.close() if self._pw: await self._pw.stop() # ------------------------------------------------------------------ # Browser action methods # ------------------------------------------------------------------ async def navigate(self, url: str) -> str: """Navigate to a URL and return the page title.""" try: await self._page.goto(url, wait_until="domcontentloaded") title = await self._page.title() return title or "(no title)" except Exception as exc: return f"Error: {exc}" async def click(self, selector: str | None = None, text: str | None = None) -> str: """Click an element by CSS selector OR by visible text (not both). Returns a confirmation string or an error message. """ try: if selector and text: return "Error: provide selector OR text, not both." if selector: await self._page.click(selector) return f"Clicked selector: {selector}" if text: await self._page.get_by_text(text, exact=False).first.click() return f"Clicked element with text: {text!r}" return "Error: provide selector or text." except Exception as exc: return f"Error: {exc}" async def fill(self, selector: str, value: str) -> str: """Clear and fill an input field, then return confirmation.""" try: await self._page.fill(selector, value) return f"Filled {selector!r} with value." except Exception as exc: return f"Error: {exc}" async def get_text(self, selector: str, limit: int = 20) -> list[str]: """Return the inner text of all elements matching *selector*, up to *limit* items.""" try: texts = await self._page.locator(selector).all_inner_texts() return texts[:limit] except Exception as exc: return [f"Error: {exc}"] async def get_page_text(self) -> str: """Return visible page text (body), truncated to 8000 characters.""" try: text = await self._page.inner_text("body") return text[:8000] except Exception as exc: return f"Error: {exc}" async def wait_for(self, selector: str, timeout: int = 10000) -> bool: """Wait until *selector* appears in the DOM. Returns True on success, False on timeout.""" try: await self._page.wait_for_selector(selector, timeout=timeout) return True except PWTimeoutError: return False except Exception: return False async def select_option(self, selector: str, value: str) -> str: """Select an