emo-online-api / emo /backend /browser_control.py
Emo Online
Deploy Emo API
dd87944
Raw
History Blame Contribute Delete
25.1 kB
"""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}