# Browser Use 浏览器自动化

使用 `system/browser-use` 模板在 UCloud Sandbox 中运行网页 Agent。模板已预装 Chromium、Playwright、browser-use 和 CDP 反向代理，可以直接复用。

## 常用场景

- 自动阅读网页，提取和整理页面内容
- 按步骤点击页面、填写表单或完成固定操作流程
- 检查页面能否打开、是否报错，以及图片、脚本和接口是否加载成功
- 批量保存页面截图，用于页面回归检查或留档
- 在隔离环境中运行来源不明的网页自动化任务

> `9223` CDP 端口本身没有身份认证。本文示例在沙箱内运行 Agent，并在创建沙箱时关闭公开访问；不要改用公网 CDP 连接来源不明的页面，也不要用它登录重要账号。如果需要在宿主机运行 browser-use，请查看[从宿主机安全控制沙箱浏览器](#从宿主机安全控制沙箱浏览器)。

## 前置准备

在宿主机安装 UCloud Sandbox Python SDK：

```bash
pip install ucloud-sandbox
```

然后按照[前置条件与认证](/docs/agent-sandbox/product/prerequisites.md)配置 UCloud Sandbox API Key。

## 示例 1：读取星图 Agent Sandbox 文档

下面的 demo 会创建 `system/browser-use` 沙箱，打开星图 Agent Sandbox 文档，依次点击“前置条件与认证”“SDK 快速开始”和“概览”，最后输出文档要点并保存每一步截图。

### 操作步骤

#### 1. 保存 Agent 脚本

这个脚本在沙箱内运行：连接模板已启动的 Chromium，打开星图 Agent Sandbox 文档，按顺序点击“前置条件与认证”“SDK 快速开始”和“概览”，并为每个操作步骤保存截图。任务结束后，它会把执行结果、访问过的 URL 和截图清单写入 `/home/user/browser-agent-run/result.json`。

创建 `browser_agent_task.py`：

```python
import asyncio
import json
import os
from pathlib import Path

from browser_use import Agent, BrowserSession
from browser_use.llm import ChatOpenAI as LLM


TASK = (
    "页面已经由脚本打开，站点固定为 AstraFlow Agent Sandbox 产品文档。"
    "为了演示浏览器操作，请按顺序执行，并确保每次操作后页面发生变化："
    "1. 点击左侧导航“前置条件与认证”；"
    "2. 点击左侧导航“SDK 快速开始”；"
    "3. 点击左侧导航“概览”，然后滚动到“核心能力”区域。"
    "最后用 JSON 输出 UCloud Sandbox 的定位、认证方式要点、SDK 快速开始步骤"
    "和核心能力列表。不要登录、不要提交任何内容。"
)
START_URL = "https://astraflow.ucloud.cn/docs/agent-sandbox"
RUN_DIR = Path("/home/user/browser-agent-run")


async def main():
    RUN_DIR.mkdir(parents=True, exist_ok=True)

    # 复用模板已经启动的 Chromium，而不是再次启动一个本地浏览器。
    browser = BrowserSession(
        cdp_url="http://127.0.0.1:9222",
        keep_alive=True,
    )
    await browser.start()

    try:
        await browser.navigate_to(START_URL)
        await asyncio.sleep(2)
        await browser.take_screenshot(
            path=str(RUN_DIR / "step-000-start.png"),
            full_page=False,
        )

        step = 0

        async def on_step_end(agent):
            nonlocal step
            step += 1
            await asyncio.sleep(1)
            await browser.take_screenshot(
                path=str(RUN_DIR / f"step-{step:03d}.png"),
                full_page=False,
            )

        llm = LLM(
            model="deepseek-v4.1-flash-1017",
            api_key=os.environ["UCLOUD_API_KEY"],
            base_url=(
                os.environ.get("LLM_BASE_URL")
                or "https://api.modelverse.cn/v1"
            ),
        )
        agent = Agent(
            task=TASK,
            llm=llm,
            browser_session=browser,
            # DeepSeek 文本模型不读取截图；使用多模态模型时改为 True。
            use_vision=False,
            max_actions_per_step=1,
            max_failures=3,
            step_timeout=120,
            enable_signal_handler=False,
            use_judge=False,
        )
        history = await agent.run(max_steps=15, on_step_end=on_step_end)

        await browser.take_screenshot(
            path=str(RUN_DIR / "step-final.png"),
            full_page=False,
        )
        summary = {
            "done": history.is_done(),
            "successful": history.is_successful(),
            "urls": history.urls(),
            "final_result": history.final_result(),
            "steps": step,
            "screenshots": sorted(
                path.name for path in RUN_DIR.glob("*.png")
            ),
        }
        (RUN_DIR / "result.json").write_text(
            json.dumps(summary, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )
        print(json.dumps(summary, ensure_ascii=False, indent=2))
    finally:
        await browser.kill()


if __name__ == "__main__":
    asyncio.run(main())
```

#### 2. 保存宿主机运行脚本

创建 `run_browser_agent.py`：

```python
import os
from pathlib import Path

from ucloud_sandbox import Sandbox
from ucloud_sandbox.sandbox.commands.command_handle import CommandExitException


OUTPUT_DIR = Path("browser-agent-run")
OUTPUT_DIR.mkdir(exist_ok=True)

sandbox = Sandbox.create(
    "system/browser-use",
    timeout=1800,
    network={
        "allow_public_traffic": False,
    },
)
try:
    # 先确认沙箱可以访问目标文档站。
    sandbox.commands.run(
        "curl -sS -o /dev/null --max-time 15 "
        "https://astraflow.ucloud.cn/docs/agent-sandbox",
        timeout=25,
    )

    sandbox.files.write(
        "/home/user/browser_agent_task.py",
        Path("browser_agent_task.py").read_text(encoding="utf-8"),
    )

    command_error = None
    try:
        result = sandbox.commands.run(
            "/opt/browser-venv/bin/python /home/user/browser_agent_task.py",
            envs={
                "UCLOUD_API_KEY": os.environ["UCLOUD_API_KEY"],
                "LLM_BASE_URL": os.environ.get(
                    "LLM_BASE_URL",
                    "https://api.modelverse.cn/v1",
                ),
            },
            timeout=1200,
        )
    except CommandExitException as exc:
        result = exc
        command_error = exc

    print(result.stdout)
    if result.stderr:
        print(result.stderr)

    # 沙箱销毁前，把截图和结果下载到本地。
    for entry in sandbox.files.list(
        "/home/user/browser-agent-run",
        depth=1,
    ):
        if entry.name.endswith((".json", ".png")):
            (OUTPUT_DIR / entry.name).write_bytes(
                sandbox.files.read(entry.path, format="bytes")
            )

    if command_error is not None:
        raise SystemExit(
            f"Agent 执行失败（exit {command_error.exit_code}），"
            f"产物已保存到 {OUTPUT_DIR.resolve()}"
        )
finally:
    sandbox.kill()

print(f"结果已保存到 {OUTPUT_DIR.resolve()}")
```

这里将 `allow_public_traffic` 设置为 `False`，外部不能直接访问沙箱的公开 URL，包括 `9223` CDP 端口。示例中的 Agent 在沙箱内运行，通过 `127.0.0.1:9222` 连接浏览器，因此不需要公网访问；沙箱访问模型服务和目标文档站不受影响。如果确实需要从宿主机调试 CDP，请使用受控的 SSH 隧道或为公开 URL 配置访问令牌，参见[限制公开访问](/docs/agent-sandbox/network/restrict-public-access.md)。

#### 3. 运行

Agent 示例需要调用 UCloud ModelVerse 模型服务。设置模型 API Key 和服务地址后执行宿主机脚本：

```bash
export UCLOUD_API_KEY=<model-api-key>
export LLM_BASE_URL=https://api.modelverse.cn/v1

python run_browser_agent.py
```

> `UCLOUD_API_KEY` 是模型服务的 API Key，不是 UCloud Sandbox API Key。它只通过命令环境变量传入，不会写进脚本文件。

#### 4. 查看结果

任务产物保存在本地 `browser-agent-run/`：

```text
browser-agent-run/
├── result.json
├── step-000-start.png
├── step-001.png
├── step-002.png
├── step-003.png
└── step-final.png
```

`result.json` 包含：

| 字段 | 说明 |
| --- | --- |
| `done` | Agent 是否结束 |
| `successful` | 任务是否成功 |
| `urls` | 执行过程中访问过的页面 |
| `final_result` | Agent 最终输出的文档要点 |
| `steps` | 实际执行的步数 |
| `screenshots` | 已保存的截图文件 |

### 修改 Agent 示例

以下配置用于修改上面的读取文档示例：

| 配置 | 位置 | 说明 |
| --- | --- | --- |
| `START_URL` | `browser_agent_task.py` | 要打开的页面 |
| `TASK` | `browser_agent_task.py` | 交给 Agent 的任务 |
| `model` | `browser_agent_task.py` | 模型名称 |
| `LLM_BASE_URL` | 运行环境变量 | 模型服务地址，默认 `https://api.modelverse.cn/v1` |
| `use_vision` | `browser_agent_task.py` | DeepSeek 文本模型保持 `False`；多模态模型改为 `True` |
| `max_steps` | `browser_agent_task.py` | Agent 最大步数 |

沙箱内运行继续使用 `http://127.0.0.1:9222`。如果确实需要在宿主机运行 browser-use，必须使用 `9223` 的受控访问方式，参见[从宿主机安全控制沙箱浏览器](#从宿主机安全控制沙箱浏览器)，不要把公网 `9223` 直接作为 `cdp_url`。

## 示例 2：页面检测

这个示例会打开你指定的网页，分别按电脑和手机屏幕各检查一次。它可以告诉你网页能不能打开、有没有报错、资源有没有加载失败，以及大致加载速度，并保存两张整页截图。

这个示例不调用模型，不需要模型 Key。

创建 `site_audit.py`：

```python
import json
import sys
from pathlib import Path
from urllib.parse import urlparse

from ucloud_sandbox import Sandbox


SITE_AUDIT_SCRIPT = r'''
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");

const TARGET_URL = process.env.AUDIT_URL;
const OUTPUT_DIR = "/home/user/site-audit";

const VIEWPORTS = {
  desktop: {
    viewport: { width: 1440, height: 900 },
  },
  mobile: {
    viewport: { width: 390, height: 844 },
    deviceScaleFactor: 2,
    isMobile: true,
    hasTouch: true,
  },
};

const METRIC_KEYS = new Set([
  "DomContentLoaded",
  "LoadEventEnd",
  "JSHeapUsedSize",
  "Nodes",
  "TaskDuration",
  "ScriptDuration",
  "LayoutCount",
  "RecalcStyleCount",
]);


async function auditViewport(browser, label, options) {
  const context = await browser.newContext(options);
  const page = await context.newPage();
  const consoleErrors = [];
  const pageErrors = [];
  const failedRequests = [];

  page.on("console", (message) => {
    if (message.type() === "error") {
      consoleErrors.push(message.text());
    }
  });
  page.on("pageerror", (error) => pageErrors.push(String(error)));
  page.on("requestfailed", (request) => {
    failedRequests.push({
      url: request.url(),
      error: request.failure()?.errorText || "",
    });
  });

  const started = Date.now();

  try {
    const response = await page.goto(TARGET_URL, {
      waitUntil: "domcontentloaded",
      timeout: 60_000,
    });
    await page.waitForLoadState("networkidle", {
      timeout: 12_000,
    }).catch(() => {});
    await page.waitForTimeout(500);

    const screenshot = path.join(OUTPUT_DIR, `${label}.png`);
    await page.screenshot({ path: screenshot, fullPage: true });

    const dom = await page.evaluate(() => ({
      lang: document.documentElement.lang || "",
      description:
        document.querySelector('meta[name="description"]')?.content || "",
      links: document.links.length,
      images: document.images.length,
      forms: document.forms.length,
      buttons:
        document.querySelectorAll('button, input[type="submit"]').length,
      h1: Array.from(document.querySelectorAll("h1"))
        .map((element) => element.innerText.trim())
        .filter(Boolean)
        .slice(0, 10),
      scrollWidth: document.documentElement.scrollWidth,
      scrollHeight: document.documentElement.scrollHeight,
      bodyTextLength: (document.body?.innerText || "").length,
    }));

    const session = await context.newCDPSession(page);
    await session.send("Performance.enable");
    const rawMetrics = await session.send("Performance.getMetrics");
    const performance = Object.fromEntries(
      rawMetrics.metrics
        .filter((metric) => METRIC_KEYS.has(metric.name))
        .map((metric) => [metric.name, metric.value])
    );
    await session.detach();

    return {
      status: response ? response.status() : null,
      content_type:
        response?.headers()["content-type"] || null,
      final_url: page.url(),
      title: await page.title(),
      elapsed_ms: Date.now() - started,
      screenshot,
      dom,
      console_errors: consoleErrors.slice(0, 50),
      page_errors: pageErrors.slice(0, 50),
      failed_requests: failedRequests.slice(0, 50),
      performance,
    };
  } catch (error) {
    const screenshot = path.join(OUTPUT_DIR, `${label}.png`);
    try {
      await page.screenshot({ path: screenshot, fullPage: false });
    } catch {}

    return {
      status: null,
      final_url: page.url(),
      title: await page.title().catch(() => ""),
      screenshot,
      error: String(error?.stack || error),
      elapsed_ms: Date.now() - started,
      dom: {},
      console_errors: consoleErrors.slice(0, 50),
      page_errors: pageErrors.slice(0, 50),
      failed_requests: failedRequests.slice(0, 50),
      performance: {},
    };
  } finally {
    await context.close();
  }
}


(async () => {
  fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  const browser = await chromium.connectOverCDP("http://127.0.0.1:9222");

  try {
    const audit = {
      url: TARGET_URL,
      generated_at: new Date().toISOString(),
    };
    for (const [label, options] of Object.entries(VIEWPORTS)) {
      audit[label] = await auditViewport(browser, label, options);
    }

    fs.writeFileSync(
      path.join(OUTPUT_DIR, "audit.json"),
      JSON.stringify(audit, null, 2)
    );
    console.log(JSON.stringify(audit, null, 2));
  } finally {
    await browser.close();
  }
})().catch((error) => {
  console.error(error);
  process.exit(1);
});
'''


def normalize_url(value):
    if "://" not in value:
        value = "https://" + value
    parsed = urlparse(value)
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise SystemExit(f"不支持的 URL: {value}")
    return value


if len(sys.argv) != 2:
    raise SystemExit("用法: python site_audit.py <url>")

target_url = normalize_url(sys.argv[1])
output_dir = Path("site-audit")
output_dir.mkdir(exist_ok=True)

sandbox = Sandbox.create(
    "system/browser-use",
    timeout=600,
    network={
        "allow_public_traffic": False,
    },
)
try:
    sandbox.files.write("/home/user/site_audit.js", SITE_AUDIT_SCRIPT)
    result = sandbox.commands.run(
        "node /home/user/site_audit.js",
        cwd="/app",
        envs={
            "AUDIT_URL": target_url,
            "NODE_PATH": "/app/node_modules",
        },
        timeout=180,
    )
    print(result.stdout)
    if result.stderr:
        print(result.stderr)

    for filename in ("audit.json", "desktop.png", "mobile.png"):
        try:
            (output_dir / filename).write_bytes(
                sandbox.files.read(
                    f"/home/user/site-audit/{filename}",
                    format="bytes",
                )
            )
        except Exception as exc:
            print(f"没有生成 {filename}：{exc}")

    audit = json.loads(
        (output_dir / "audit.json").read_text(encoding="utf-8")
    )
    for label in ("desktop", "mobile"):
        item = audit[label]
        print(
            f"{label}: HTTP {item.get('status')} | "
            f"title={item.get('title')!r} | "
            f"console_errors={len(item.get('console_errors', []))}"
        )
finally:
    sandbox.kill()

print(f"结果已保存到 {output_dir.resolve()}")
```

运行：

```bash
python site_audit.py https://astraflow.ucloud.cn/docs/agent-sandbox
```

输出目录：

```text
site-audit/
├── audit.json
├── desktop.png
└── mobile.png
```

检查结果保存在 `audit.json`。其中 `desktop` 是电脑屏幕下的结果，`mobile` 是手机屏幕下的结果，主要字段如下：

| 字段 | 说明 |
| --- | --- |
| `status` | 网页是否能正常打开 |
| `title` | 页面标题 |
| `final_url` | 最终打开的网址 |
| `error` | 网页打不开时的错误原因 |
| `dom` | 页面基本信息，例如标题、链接、图片和表单数量 |
| `console_errors` | 浏览器报错 |
| `page_errors` | 页面脚本异常 |
| `failed_requests` | 没有加载成功的图片、脚本或接口 |
| `performance` | 页面加载和运行速度 |

## 从宿主机安全控制沙箱浏览器

只有在宿主机或其他机器运行 browser-use 时才使用 `9223`。下面的示例在宿主机运行 Agent，创建沙箱时设置 `allow_public_traffic=False`，并通过 `trafficAccessToken` 安全连接沙箱 Chromium。

`trafficAccessToken` 在 SDK 中是 `sandbox.traffic_access_token`，请求头名称是 `e2b-traffic-access-token`。`BrowserSession(headers=...)` 会把这个请求头同时用于 `/json/version` 请求和 CDP WebSocket 握手。令牌不要放进 URL、日志或截图。

先安装宿主机依赖：

```bash
pip install browser-use ucloud-sandbox
```

创建 `secure_agent.py`：

```python
import asyncio
import json
import os
from urllib.error import HTTPError
from urllib.request import urlopen

from browser_use import Agent, BrowserSession
from browser_use.llm import ChatOpenAI as LLM
from ucloud_sandbox import Sandbox


TOKEN_HEADER = "e2b-traffic-access-token"
CDP_PORT = 9223
TASK = (
    "打开 https://astraflow.ucloud.cn/docs/agent-sandbox，"
    "读取页面标题和首屏内容，并用中文列出文档的主要章节。"
    "不要登录，也不要提交任何内容。"
)


def check_without_token(base_url):
    try:
        with urlopen(f"{base_url}/json/version", timeout=20):
            raise RuntimeError("未携带 token 仍然访问成功")
    except HTTPError as exc:
        if exc.code != 403:
            raise
        print("未携带 token: HTTP 403")


async def main():
    sandbox = Sandbox.create(
        "system/browser-use",
        timeout=600,
        network={
            "allow_public_traffic": False,
        },
    )
    browser = None
    try:
        token = sandbox.traffic_access_token
        if not token:
            raise RuntimeError("沙箱没有生成 trafficAccessToken")

        cdp_url = f"https://{sandbox.get_host(CDP_PORT)}"
        check_without_token(cdp_url)

        browser = BrowserSession(
            cdp_url=cdp_url,
            headers={TOKEN_HEADER: token},
            keep_alive=True,
        )
        await browser.start()

        llm = LLM(
            model="deepseek-v4.1-flash-1017",
            api_key=os.environ["UCLOUD_API_KEY"],
            base_url=(
                os.environ.get("LLM_BASE_URL")
                or "https://api.modelverse.cn/v1"
            ),
        )
        agent = Agent(
            task=TASK,
            llm=llm,
            browser_session=browser,
            use_vision=False,
            max_actions_per_step=1,
            max_failures=3,
            step_timeout=120,
            enable_signal_handler=False,
            use_judge=False,
        )
        history = await agent.run(max_steps=10)
        screenshot = "secure-agent.png"
        await browser.take_screenshot(path=screenshot, full_page=True)

        print(
            json.dumps(
                {
                    "successful": history.is_successful(),
                    "result": history.final_result(),
                },
                ensure_ascii=False,
                indent=2,
            )
        )
        print(f"截图: {screenshot}")
    finally:
        try:
            if browser is not None:
                await browser.kill()
        finally:
            sandbox.kill()


if __name__ == "__main__":
    asyncio.run(main())
```

运行 Agent 前设置模型环境变量：

```bash
export UCLOUD_API_KEY=<model-api-key>
export LLM_BASE_URL=https://api.modelverse.cn/v1

python secure_agent.py
```

运行后，宿主机上的 browser-use 会通过 `9223` 控制沙箱内的 Chromium，而未携带令牌的请求无法访问公开 CDP。当前 browser-use 版本需要支持 `BrowserSession(headers=...)`；如果使用旧版本，请升级 browser-use，或改用 SSH 隧道。公开 URL 访问令牌的完整说明参见[限制公开访问](/docs/agent-sandbox/network/restrict-public-access.md)。

### 预期效果

运行过程中不会弹出可见的浏览器窗口，但可以通过以下结果确认连接和鉴权已经生效：

1. **未授权访问被拒绝**：终端首先输出 `未携带 token: HTTP 403`。这说明 `9223` 不能在没有 `trafficAccessToken` 的情况下直接访问。
2. **携带令牌后成功连接**：browser-use 使用 `e2b-traffic-access-token` 请求头完成 `/json/version` 和 CDP WebSocket 握手，并开始控制沙箱内的 Chromium。
3. **Agent 完成浏览器任务**：终端输出 JSON，其中 `successful` 为 `true`，`result` 包含从星图 Agent Sandbox 文档中读取到的页面标题和主要章节。
4. **生成页面截图**：脚本在当前目录保存 `secure-agent.png`。这张截图由沙箱内的 Chromium 渲染，可以直观看到文档页面已经实际打开。
5. **资源被清理**：脚本结束后，browser-use 和沙箱会依次关闭，`9223` 地址随即失效。

示例输出如下，Agent 返回的文字会随实际页面内容变化：

```text
未携带 token: HTTP 403
{
  "successful": true,
  "result": "页面标题为《Agent Sandbox》，主要章节包括：..."
}
截图: secure-agent.png
```

如果 `BrowserSession` 无法连接并返回 `403`，检查是否使用了支持 `headers` 参数的 browser-use 版本，以及请求头名称是否为 `e2b-traffic-access-token`。如果未携带 token 的请求没有返回 `403`，说明当前环境没有正确限制公开访问，不要继续在该环境中执行任务。

## 常见问题

| 现象 | 处理方式 |
| --- | --- |
| 沙箱无法访问文档站 | 检查沙箱网络，或更换 `START_URL` |
| Agent 执行失败 | 查看 `run_browser_agent.py` 输出的 stderr，并检查 `UCLOUD_API_KEY`、模型名称和 `LLM_BASE_URL` |
| 截图是白屏 | 增加导航后的等待时间，等页面正文渲染完成再截图 |
| `agent.run()` 后浏览器已关闭 | 保持 `BrowserSession(..., keep_alive=True)` |
| 公网 CDP 返回 `502` | 检查沙箱内的 `http://127.0.0.1:9223/health` 和端口代理是否支持 WebSocket |
| 端口地址无法访问 | 确认沙箱仍在运行；沙箱销毁后，公网 CDP 地址会立即失效 |

## 相关文档

- [沙箱生命周期](/docs/agent-sandbox/sandbox/lifecycle.md)
- [网络访问](/docs/agent-sandbox/network/internet-access.md)
- [公开 URL](/docs/agent-sandbox/network/public-url.md)
- [文件系统](/docs/agent-sandbox/storage/filesystem/overview.md)
