"""Contrôle navigateur headless pour Émo — clics, saisie, navigation (Playwright).""" from __future__ import annotations import asyncio import base64 import logging import os import time from typing import Any, Optional logger = logging.getLogger("emo.browser") PLAYWRIGHT_AVAILABLE = False try: from playwright.async_api import async_playwright, Browser, BrowserContext, Page, Playwright PLAYWRIGHT_AVAILABLE = True except ImportError: async_playwright = None # type: ignore Browser = BrowserContext = Page = Playwright = None # type: ignore SESSION_TTL_SEC = 300 MAX_SESSIONS_PER_USER = 3 MAX_ELEMENTS = 45 MAX_TEXT = 6000 FAST_SETTLE_SEC = 0.04 AGENT_SETTLE_SEC = 0.35 class _Session: __slots__ = ("context", "page", "last_used") def __init__(self, context: "BrowserContext", page: "Page"): self.context = context self.page = page self.last_used = time.monotonic() class BrowserController: def __init__(self) -> None: self._pw: Optional["Playwright"] = None self._browser: Optional["Browser"] = None self._sessions: dict[str, _Session] = {} self._lock = asyncio.Lock() async def _ensure_browser(self) -> None: if os.environ.get("EMO_BROWSER_HARD_DISABLE", "").lower() in ("1", "true", "yes"): raise RuntimeError( "Navigateur desactive sur ce serveur (EMO_BROWSER_HARD_DISABLE)." ) if not PLAYWRIGHT_AVAILABLE: raise RuntimeError( "Playwright non installé sur le serveur. " "browser_open indisponible — utilise web_fetch pour du HTML statique." ) if self._browser: return async with self._lock: if self._browser: return self._pw = await async_playwright().start() self._browser = await asyncio.wait_for( self._pw.chromium.launch( headless=True, args=[ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", ], ), timeout=25.0, ) logger.info("Playwright Chromium démarré") def _session_key(self, user_id: str, session_id: str) -> str: sid = (session_id or "default").strip()[:32] or "default" return f"{user_id}:{sid}" async def _cleanup_idle(self) -> None: now = time.monotonic() stale = [k for k, s in self._sessions.items() if now - s.last_used > SESSION_TTL_SEC] for key in stale: sess = self._sessions.pop(key, None) if sess: try: await sess.context.close() except Exception: pass async def _get_session(self, user_id: str, session_id: str, *, create: bool = True) -> _Session: await self._cleanup_idle() key = self._session_key(user_id, session_id) if key in self._sessions: self._sessions[key].last_used = time.monotonic() return self._sessions[key] if not create: raise RuntimeError(f"Session navigateur '{session_id or 'default'}' introuvable. Appelle browser_open d'abord.") user_prefix = f"{user_id}:" user_count = sum(1 for k in self._sessions if k.startswith(user_prefix)) if user_count >= MAX_SESSIONS_PER_USER: oldest = min( (k for k in self._sessions if k.startswith(user_prefix)), key=lambda k: self._sessions[k].last_used, ) old = self._sessions.pop(oldest, None) if old: try: await old.context.close() except Exception: pass await self._ensure_browser() context = await self._browser.new_context( user_agent=( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ), viewport={"width": 1280, "height": 900}, ignore_https_errors=True, ) page = await context.new_page() page.set_default_timeout(25000) sess = _Session(context, page) self._sessions[key] = sess return sess async def _snapshot(self, page: "Page", session_id: str, *, fast: bool = False) -> dict[str, Any]: url = page.url title = await page.title() elements: list[dict] = [] text = "" if not fast: try: elements = await page.evaluate( """() => { const out = []; let ref = 0; const sel = 'a, button, input, textarea, select, [role=button], [onclick]'; for (const el of document.querySelectorAll(sel)) { if (!el || el.offsetParent === null) continue; const tag = el.tagName.toLowerCase(); const text = ( el.innerText || el.value || el.placeholder || el.getAttribute('aria-label') || el.name || '' ).trim().slice(0, 100); const inputType = el.type || null; let label = text || `[${tag}]`; if (tag === 'input' && inputType === 'password') label = `[password] ${label}`.trim(); if (!text && !['input', 'textarea', 'select'].includes(tag)) continue; ref += 1; el.setAttribute('data-emo-ref', String(ref)); out.push({ ref, tag, text: label, type: inputType, name: el.name || null, href: el.href ? el.href.slice(0, 200) : null, }); if (out.length >= 45) break; } return out; }""" ) except Exception as e: logger.warning("snapshot elements: %s", e) try: text = (await page.inner_text("body"))[:MAX_TEXT] except Exception: pass screenshot_b64 = None vp = page.viewport_size or {"width": 1280, "height": 900} try: quality = 42 if fast else 55 raw = await page.screenshot(type="jpeg", quality=quality, full_page=False) if len(raw) < 400_000: screenshot_b64 = base64.b64encode(raw).decode("ascii") except Exception as e: logger.warning("screenshot: %s", e) out: dict[str, Any] = { "ok": True, "session_id": session_id or "default", "url": url, "title": title, "screenshot_base64": screenshot_b64, "viewport_width": int(vp.get("width") or 1280), "viewport_height": int(vp.get("height") or 900), } if not fast: out["text"] = text out["elements"] = elements[:MAX_ELEMENTS] out["hint"] = "browser_click(ref=N|x,y) · browser_fill(ref=N, text=...) · browser_type · browser_snapshot()" return out async def _settle(self, page: "Page", *, fast: bool) -> None: if fast: await asyncio.sleep(FAST_SETTLE_SEC) return await asyncio.sleep(AGENT_SETTLE_SEC) async def open(self, user_id: str, url: str, session_id: str = "default", *, fast: bool = False) -> dict[str, Any]: from web_tools import normalize_url url = normalize_url(url) if not url.strip(): return {"ok": False, "error": "URL manquante"} sess = await self._get_session(user_id, session_id) try: await sess.page.goto(url, wait_until="domcontentloaded", timeout=45000) if not fast: try: await sess.page.wait_for_load_state("networkidle", timeout=8000) except Exception: pass await asyncio.sleep(0.8) else: await asyncio.sleep(0.12) if "youtube.com" in url or "youtu.be" in url: await asyncio.sleep(0.8 if fast else 1.5) except Exception as e: return {"ok": False, "error": f"Navigation échouée: {e}", "url": url} snap = await self._snapshot(sess.page, session_id, fast=fast) snap["action"] = "navigate" return snap async def snapshot(self, user_id: str, session_id: str = "default", *, fast: bool = False) -> dict[str, Any]: try: sess = await self._get_session(user_id, session_id, create=False) except RuntimeError as e: return {"ok": False, "error": str(e)} snap = await self._snapshot(sess.page, session_id, fast=fast) snap["action"] = "snapshot" return snap async def click( self, user_id: str, session_id: str = "default", ref: Optional[int] = None, selector: Optional[str] = None, x: Optional[float] = None, y: Optional[float] = None, *, fast: bool = False, ) -> dict[str, Any]: try: sess = await self._get_session(user_id, session_id, create=False) except RuntimeError as e: return {"ok": False, "error": str(e)} page = sess.page click_timeout = 5000 if fast else 12000 try: if x is not None and y is not None: await page.mouse.click(float(x), float(y)) elif ref is not None: loc = page.locator(f'[data-emo-ref="{int(ref)}"]') await loc.first.click(timeout=click_timeout) elif selector: await page.click(selector, timeout=click_timeout) else: return {"ok": False, "error": "Indique x,y ou ref ou selector CSS."} await self._settle(page, fast=fast) except Exception as e: return {"ok": False, "error": f"Clic échoué: {e}"} snap = await self._snapshot(page, session_id, fast=fast) snap["action"] = "click" snap["clicked_ref"] = ref snap["clicked_selector"] = selector if x is not None and y is not None: snap["clicked_x"] = float(x) snap["clicked_y"] = float(y) return snap async def type_text( self, user_id: str, text: str, session_id: str = "default", ref: Optional[int] = None, selector: Optional[str] = None, clear: bool = False, press_enter: bool = False, *, fast: bool = False, ) -> dict[str, Any]: try: sess = await self._get_session(user_id, session_id, create=False) except RuntimeError as e: return {"ok": False, "error": str(e)} page = sess.page type_timeout = 5000 if fast else 12000 key_delay = 0 if fast else 20 try: if ref is not None: loc = page.locator(f'[data-emo-ref="{int(ref)}"]') if clear: await loc.first.fill(text, timeout=type_timeout) else: await loc.first.click(timeout=type_timeout) await loc.first.type(text, timeout=type_timeout, delay=key_delay) elif selector: loc = page.locator(selector) if clear: await loc.first.fill(text, timeout=type_timeout) else: await loc.first.click(timeout=type_timeout) await loc.first.type(text, timeout=type_timeout, delay=key_delay) elif text: await page.keyboard.type(text, delay=key_delay) else: return {"ok": False, "error": "Indique ref, selector, ou text seul (clavier page)."} if press_enter: await page.keyboard.press("Enter") await self._settle(page, fast=fast) except Exception as e: return {"ok": False, "error": f"Saisie échouée: {e}"} snap = await self._snapshot(page, session_id, fast=fast) snap["action"] = "type" return snap async def fill_text( self, user_id: str, text: str, session_id: str = "default", ref: Optional[int] = None, selector: Optional[str] = None, press_enter: bool = False, *, fast: bool = False, ) -> dict[str, Any]: """Remplit un champ (login, password) — préféré à browser_type pour formulaires.""" try: sess = await self._get_session(user_id, session_id, create=False) except RuntimeError as e: return {"ok": False, "error": str(e)} page = sess.page fill_timeout = 5000 if fast else 12000 try: if ref is not None: loc = page.locator(f'[data-emo-ref="{int(ref)}"]') await loc.first.click(timeout=fill_timeout) await loc.first.fill(text, timeout=fill_timeout) elif selector: loc = page.locator(selector) await loc.first.click(timeout=fill_timeout) await loc.first.fill(text, timeout=fill_timeout) else: return {"ok": False, "error": "Indique ref ou selector pour browser_fill."} if press_enter: await page.keyboard.press("Enter") await self._settle(page, fast=fast) except Exception as e: return {"ok": False, "error": f"Remplissage échoué: {e}"} snap = await self._snapshot(page, session_id, fast=fast) snap["action"] = "fill" return snap async def scroll( self, user_id: str, direction: str = "down", amount: int = 600, session_id: str = "default", *, fast: bool = False, ) -> dict[str, Any]: try: sess = await self._get_session(user_id, session_id, create=False) except RuntimeError as e: return {"ok": False, "error": str(e)} delta = amount if direction.lower() != "up" else -amount try: await sess.page.mouse.wheel(0, delta) await self._settle(sess.page, fast=fast) except Exception as e: return {"ok": False, "error": f"Scroll échoué: {e}"} snap = await self._snapshot(sess.page, session_id, fast=fast) snap["action"] = "scroll" return snap async def press_key(self, user_id: str, key: str, session_id: str = "default", *, fast: bool = False) -> dict[str, Any]: try: sess = await self._get_session(user_id, session_id, create=False) except RuntimeError as e: return {"ok": False, "error": str(e)} try: await sess.page.keyboard.press(key) await self._settle(sess.page, fast=fast) except Exception as e: return {"ok": False, "error": f"Touche échouée: {e}"} snap = await self._snapshot(sess.page, session_id, fast=fast) snap["action"] = "press" return snap async def keyboard_input( self, user_id: str, session_id: str = "default", *, key: Optional[str] = None, text: Optional[str] = None, fast: bool = False, snapshot: bool = True, ) -> dict[str, Any]: """Saisie clavier directe sur la page (focus utilisateur sur la capture).""" try: sess = await self._get_session(user_id, session_id, create=False) except RuntimeError as e: return {"ok": False, "error": str(e)} try: key_delay = 0 if fast else 15 if text: await sess.page.keyboard.type(text, delay=key_delay) elif key: if len(key) == 1: await sess.page.keyboard.type(key, delay=key_delay) else: await sess.page.keyboard.press(key) else: return {"ok": False, "error": "Indique key ou text."} if snapshot: await self._settle(sess.page, fast=fast) except Exception as e: return {"ok": False, "error": f"Saisie clavier échouée: {e}"} if not snapshot: return {"ok": True, "session_id": session_id or "default", "action": "keyboard"} snap = await self._snapshot(sess.page, session_id, fast=fast) snap["action"] = "keyboard" return snap async def close(self, user_id: str, session_id: str = "default") -> dict[str, Any]: key = self._session_key(user_id, session_id) sess = self._sessions.pop(key, None) if sess: try: await sess.context.close() except Exception: pass return {"ok": True, "closed": session_id or "default"} browser_controller = BrowserController() async def browser_open(user_id: str, url: str, session_id: str = "default", *, fast: bool = False) -> dict: return await browser_controller.open(user_id, url, session_id, fast=fast) async def browser_snapshot(user_id: str, session_id: str = "default", *, fast: bool = False) -> dict: return await browser_controller.snapshot(user_id, session_id, fast=fast) async def browser_click( user_id: str, session_id: str = "default", ref: Optional[int] = None, selector: Optional[str] = None, x: Optional[float] = None, y: Optional[float] = None, *, fast: bool = False, ) -> dict: return await browser_controller.click( user_id, session_id, ref=ref, selector=selector, x=x, y=y, fast=fast, ) async def browser_type( user_id: str, text: str, session_id: str = "default", ref: Optional[int] = None, selector: Optional[str] = None, clear: bool = False, press_enter: bool = False, *, fast: bool = False, ) -> dict: return await browser_controller.type_text( user_id, text, session_id, ref=ref, selector=selector, clear=clear, press_enter=press_enter, fast=fast, ) async def browser_fill( user_id: str, text: str, session_id: str = "default", ref: Optional[int] = None, selector: Optional[str] = None, press_enter: bool = False, *, fast: bool = False, ) -> dict: return await browser_controller.fill_text( user_id, text, session_id, ref=ref, selector=selector, press_enter=press_enter, fast=fast, ) async def browser_scroll( user_id: str, direction: str = "down", amount: int = 600, session_id: str = "default", *, fast: bool = False, ) -> dict: return await browser_controller.scroll(user_id, direction, amount, session_id, fast=fast) async def browser_press(user_id: str, key: str, session_id: str = "default", *, fast: bool = False) -> dict: return await browser_controller.press_key(user_id, key, session_id, fast=fast) async def browser_keyboard( user_id: str, session_id: str = "default", *, key: Optional[str] = None, text: Optional[str] = None, fast: bool = False, snapshot: bool = True, ) -> dict: return await browser_controller.keyboard_input( user_id, session_id, key=key, text=text, fast=fast, snapshot=snapshot, ) async def browser_close(user_id: str, session_id: str = "default") -> dict: return await browser_controller.close(user_id, session_id) BROWSER_CONTROL_TOOLS = [ { "type": "function", "function": { "name": "browser_open", "description": ( "Ouvre une page dans le navigateur contrôlé d'Émo (headless Chromium). " "Retourne screenshot, éléments cliquables numérotés (ref), texte. " "Utilise pour sites JS, formulaires, clics. Enchaîne browser_click/browser_type." ), "parameters": { "type": "object", "properties": { "url": {"type": "string", "description": "URL https://…"}, "session_id": {"type": "string", "description": "ID session (défaut default)."}, }, "required": ["url"], }, }, }, { "type": "function", "function": { "name": "browser_snapshot", "description": "Capture l'état actuel de la page (screenshot + refs cliquables).", "parameters": { "type": "object", "properties": { "session_id": {"type": "string"}, }, }, }, }, { "type": "function", "function": { "name": "browser_click", "description": "Clique sur un élément (ref depuis snapshot, selector CSS, ou coordonnées x,y viewport).", "parameters": { "type": "object", "properties": { "ref": {"type": "integer", "description": "Numéro ref de l'élément."}, "selector": {"type": "string", "description": "Selector CSS alternatif."}, "x": {"type": "number", "description": "Coordonnée X viewport (pixels)."}, "y": {"type": "number", "description": "Coordonnée Y viewport (pixels)."}, "session_id": {"type": "string"}, }, }, }, }, { "type": "function", "function": { "name": "browser_type", "description": "Tape du texte dans un champ (ref ou selector). clear=true remplace le contenu.", "parameters": { "type": "object", "properties": { "text": {"type": "string"}, "ref": {"type": "integer"}, "selector": {"type": "string"}, "clear": {"type": "boolean"}, "press_enter": {"type": "boolean"}, "session_id": {"type": "string"}, }, "required": ["text"], }, }, }, { "type": "function", "function": { "name": "browser_fill", "description": ( "Remplit un champ de formulaire (email, login, password). " "Préféré à browser_type pour connexion — remplace le contenu du champ." ), "parameters": { "type": "object", "properties": { "text": {"type": "string", "description": "Valeur à saisir (identifiant, mot de passe, etc.)."}, "ref": {"type": "integer", "description": "Ref du champ depuis browser_snapshot."}, "selector": {"type": "string", "description": "Selector CSS alternatif (ex: input[type=password])."}, "press_enter": {"type": "boolean", "description": "Appuyer sur Entrée après saisie."}, "session_id": {"type": "string"}, }, "required": ["text"], }, }, }, { "type": "function", "function": { "name": "browser_scroll", "description": "Scroll la page (up/down).", "parameters": { "type": "object", "properties": { "direction": {"type": "string", "enum": ["up", "down"]}, "amount": {"type": "integer"}, "session_id": {"type": "string"}, }, }, }, }, { "type": "function", "function": { "name": "browser_press", "description": "Appuie sur une touche (Enter, Tab, Escape, etc.).", "parameters": { "type": "object", "properties": { "key": {"type": "string"}, "session_id": {"type": "string"}, }, "required": ["key"], }, }, }, { "type": "function", "function": { "name": "browser_close", "description": "Ferme une session navigateur.", "parameters": { "type": "object", "properties": { "session_id": {"type": "string"}, }, }, }, }, ] BROWSER_CONTROL_TOOL_NAMES = {t["function"]["name"] for t in BROWSER_CONTROL_TOOLS}