Agent Sandbox Registry

awesome-templates

browser-automation/commands.py

无英文版

browser-automation/commands.py

"""browser-automation 模板命令 — 基于内置 BROWSER 能力组的高层浏览器自动化。

本模板利用 Server 提供的 CapabilityGroup.BROWSER 能力组(8 个内置端点),
并在此之上注册面向 AI Agent 的高层业务命令:

- browse   — 智能浏览(导航 + 截图 / 提取文本 / 提取链接 / 全量信息)
- scrape   — 按 CSS 选择器提取元素文本
- fill_form — 自动填写表单

内置 BROWSER 端点(由 CapabilityGroup.BROWSER 启用):
    POST /browser/navigate, POST /browser/screenshot, GET /browser/content,
    POST /browser/click, POST /browser/type, POST /browser/evaluate,
    POST /browser/pdf, GET /browser/console
"""
from __future__ import annotations

import json
import time
from typing import Any

from agent_sandbox.server import (
    CapabilityGroup,
    command,
    default_table,
    start,
)

# ---------------------------------------------------------------------------
# Opt-in capability group
# ---------------------------------------------------------------------------

table = default_table()
table.enable_group(CapabilityGroup.BROWSER)    # opt-in: 8 built-in browser endpoints

# ---------------------------------------------------------------------------
# Module-level Playwright singleton (lazy-initialised)
# ---------------------------------------------------------------------------

_playwright: Any = None
_browser: Any = None
_page: Any = None


def _ensure_browser() -> Any:
    """Return the shared Playwright *page*, creating the singleton on first call.

    The browser instance persists across command invocations for performance.
    """
    global _playwright, _browser, _page  # noqa: PLW0603

    if _page is not None and not _page.is_closed():
        return _page

    from playwright.sync_api import sync_playwright  # noqa: PLC0415

    if _playwright is None:
        _playwright = sync_playwright().start()
        _browser = _playwright.chromium.launch(headless=True)

    _page = _browser.new_page()
    return _page


# ---------------------------------------------------------------------------
# Named commands — high-level business commands
# ---------------------------------------------------------------------------

@command(
    "browse",
    description=(
        "Smart browse — navigate to a URL and perform an action. "
        "action: screenshot | extract | links | full"
    ),
)
def browse(url: str, action: str = "screenshot") -> dict[str, Any]:
    """Navigate to *url* and execute *action*.

    Actions:
        screenshot — take a screenshot, return the saved file path.
        extract    — return the page's visible text content.
        links      — extract all hyperlinks as a list of {href, text} objects.
        full       — screenshot + title + first 500 chars of text.
    """
    page = _ensure_browser()
    page.goto(url, wait_until="networkidle", timeout=30000)

    if action == "screenshot":
        ts = int(time.time() * 1000)
        path = f"/app/screenshot_{ts}.png"
        page.screenshot(path=path, full_page=True)
        return {"action": "screenshot", "url": page.url, "path": path}

    if action == "extract":
        text = page.inner_text("body")
        return {"action": "extract", "url": page.url, "title": page.title(), "text": text}

    if action == "links":
        raw_links = page.evaluate(
            """() => Array.from(document.querySelectorAll('a[href]')).map(a => ({
                href: a.href, text: a.innerText.trim()
            }))"""
        )
        return {"action": "links", "url": page.url, "count": len(raw_links), "links": raw_links}

    if action == "full":
        ts = int(time.time() * 1000)
        path = f"/app/screenshot_{ts}.png"
        page.screenshot(path=path, full_page=True)
        text = page.inner_text("body")
        return {
            "action": "full",
            "url": page.url,
            "title": page.title(),
            "screenshot": path,
            "text_preview": text[:500],
        }

    raise ValueError(f"Unknown action: {action!r}. Expected: screenshot, extract, links, full")


@command(
    "scrape",
    description="Scrape — navigate to a URL and extract text from a CSS selector.",
)
def scrape(url: str, selector: str) -> dict[str, Any]:
    """Navigate to *url* and return the text content of elements matching *selector*."""
    page = _ensure_browser()
    page.goto(url, wait_until="networkidle", timeout=30000)

    elements = page.query_selector_all(selector)
    if not elements:
        return {
            "url": page.url,
            "selector": selector,
            "count": 0,
            "texts": [],
        }

    texts = [el.inner_text() for el in elements]
    return {
        "url": page.url,
        "selector": selector,
        "count": len(texts),
        "texts": texts,
    }


@command(
    "fill_form",
    description=(
        "Auto-fill a form — navigate to a URL and fill fields. "
        'fields_json: \'[{"selector": "#email", "value": "test@example.com"}]\''
    ),
)
def fill_form(url: str, fields_json: str) -> dict[str, Any]:
    """Navigate to *url* and fill form fields described by *fields_json*.

    *fields_json* is a JSON array of objects, each with ``selector`` and ``value``::

        [{"selector": "#email", "value": "test@example.com"},
         {"selector": "#password", "value": "secret"}]
    """
    page = _ensure_browser()
    page.goto(url, wait_until="networkidle", timeout=30000)

    fields: list[dict[str, str]] = json.loads(fields_json)
    filled: list[dict[str, str]] = []

    for field in fields:
        sel = field["selector"]
        val = field["value"]
        page.fill(sel, val)
        filled.append({"selector": sel, "value": val})

    return {
        "url": page.url,
        "fields_filled": len(filled),
        "details": filled,
    }


# ---------------------------------------------------------------------------
# Hidden diagnostic command
# ---------------------------------------------------------------------------


@command(hidden=True)
def _browser_status() -> dict[str, Any]:
    """Return the current browser singleton status (not listed in /commands)."""
    running = _browser is not None and _browser.is_connected()
    current_url: str | None = None
    pages = 0

    if running and _browser is not None:
        contexts = _browser.contexts
        for ctx in contexts:
            pages += len(ctx.pages)
        if _page is not None and not _page.is_closed():
            current_url = _page.url

    return {
        "browser_running": running,
        "current_url": current_url,
        "pages": pages,
    }


# ---------------------------------------------------------------------------
# Freeze & serve
# ---------------------------------------------------------------------------


def _run(argv: list[str], *, timeout: int, cwd: str = "/app") -> str:
    """Run *argv* in ``/app`` when that directory exists and return stdout."""
    import os
    import subprocess

    workdir = cwd if os.path.isdir(cwd) else (os.environ.get("WORKSPACE") or os.getcwd())
    result = subprocess.run(
        argv, cwd=workdir, capture_output=True, text=True, timeout=timeout,
    )
    if result.returncode != 0:
        detail = (result.stderr or result.stdout or "").strip()
        raise RuntimeError(detail or f"command exited {result.returncode}")
    return result.stdout



@command("run", description="Run a browser automation script.")
def run(script: str = "main.py") -> str:
    """Run a Python script (``python3 {script}``)."""
    return _run(["python3", script], timeout=120)


if __name__ == "__main__":
    import os

    start(int(os.environ.get("ABX_SERVER_PORT", "9000")))