Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 07 节:浏览器自动化 (Browser Automation)

“互联网是人类有史以来最大的数据库——但 90% 的数据锁在 JavaScript 渲染的页面里,HTTP 请求拿不到。”

本节改动全景

相比第 06 节,本节的改动集中在工具层,状态管理与 Agent 主循环完全不变

改动点第 06 节第 07 节
工具集shell、file R/W+ browser_navigate / browser_content / browser_screenshot / browser_screenshot_annotated / browser_click / browser_type / browser_key / browser_scroll
LLM 输入格式纯文本 string+ ContentBlock[](图像 base64,Vision 模式)
HTML 处理精简管道:去噪 → 语义提取 → 截断
浏览器隔离BrowserContext per session(独立 cookie/storage)
Agent 主循环不变不变
状态持久化不变不变

这一节的核心设计思想:浏览器只是另一种工具——Agent 主循环不感知“这是浏览器调用“,照常 {"action":"browser_content"} 发起;工具层封装了所有的 Playwright 细节。


为什么需要浏览器工具

前 6 节的 Agent 只能操作本地文件和 shell 命令。但真实任务中,大量信息锁在 web 上:

帮我完成这个任务:
  1. 查 competitor.com 上 Pro 套餐的最新定价   ← HTTP 拿不到(SPA 渲染)
  2. 填写内部报销表单并提交                   ← 需要 JS 事件
  3. 截图证明提交成功,附在工作日志里          ← 需要真实截图
  4. 把定价写入 price_report.md

步骤 1-3 前 6 节全部做不到。

HTTP 请求 vs 真实浏览器

HTTP 请求                          真实浏览器(Playwright)
─────────────────────              ─────────────────────────
GET /page → 初始 HTML              ① 加载初始 HTML
  ↓                                ② 执行 JavaScript
  HTML 里全是 <div id="app"></div>  ③ 触发 Ajax / fetch
  (内容在 JS 里,没有)            ④ 等待 DOM 稳定
                                   ⑤ 返回完整渲染结果 ✓

典型失败案例:

  • SPA(React/Vue/Angular):内容全靠 JS 填充,GET 到的是空壳
  • 登录墙:需要 Cookie/Session,fetch 无法带 UI 登录流程
  • 无限滚动:内容在 scroll 事件后才加载
  • 验证码 / CAPTCHA:需要真实浏览器环境才能通过

1. 两种“看懂“网页的方式

面对一个渲染完成的页面,Agent 有两条路:

渲染完成的页面
      │
      ├── DOM 文本模式 ──► page.content() → distillHTML() → 字符串 → LLM
      │
      └── 视觉截图模式 ──► page.screenshot() → base64 → ContentBlock[] → LLM
维度DOM 文本模式视觉截图模式
Token 消耗低(精简后 1-3K token)高(1张图 ≈ 800-1200 token)
信息完整度文本/链接完整,布局丢失布局/颜色/图标/渐变可见
适用场景内容提取、表单定位、链接抓取验证码识别、图表理解、UI 布局判断
动态内容需等待 JS 渲染完成截图天然是渲染后结果
可交互性可精确提取 input[name]button只能描述,无法直接获取 selector

实践原则:优先用 DOM 文本模式(省 Token);遇到“用文字描述不清楚的布局“或“需要识别图形内容“时,切换到视觉截图模式。


2. HTML 精简 (HTML Distillation)

问题:原始 HTML 无法直接喂给 LLM

https://news.ycombinator.com 原始 HTML:约 80KB / ~20000 token
                                        ↑
                              Claude 单次限制 200K token,
                              但每次调用按 token 计费,
                              塞满整个页面性价比极低

原始 HTML 的噪音来源:

<!-- 这些对 LLM 毫无用处 -->
<script>window.__INITIAL_STATE__ = {"user":null, ...}</script>
<style>.btn-primary { background: linear-gradient... }</style>
<meta name="csrf-token" content="abc123">
<link rel="preload" href="/fonts/inter.woff2">
<div class="ad-banner" data-slot="top-728x90">...</div>

精简管道

function distillHTML(html: string, maxChars = 8000, offsetChars = 0): string {
  let result = html;

  // 第一步:删除完全无用的块级标签(含内容)
  result = result.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
  result = result.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
  result = result.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, '');
  result = result.replace(/<!--[\s\S]*?-->/g, '');

  // 第二步:删除无语义的元数据标签(自闭合)
  result = result.replace(/<(meta|link|svg|path|polygon)\b[^>]*\/?>/gi, '');

  // 第三步:去除所有属性,只保留语义必需的
  //   a → 保留 href        input → 保留 name/type/value/placeholder
  //   button → 保留 type   img → 保留 alt
  result = result.replace(/<a\b[^>]*href="([^"]*)"[^>]*>/gi, '<a href="$1">');
  result = result.replace(/<input\b[^>]*(name|type|placeholder|value)[^>]*>/gi, (m) => {
    const attrs = ['name', 'type', 'placeholder', 'value']
      .map(a => { const match = m.match(new RegExp(`${a}="([^"]*)"`)); return match ? `${a}="${match[1]}"` : ''; })
      .filter(Boolean).join(' ');
    return `<input ${attrs}>`;
  });
  result = result.replace(/<(?!\/?(a|button|input|select|option|h[1-6]|p|li|ul|ol|td|th|tr|table|label|form|main|article|section|nav|header|footer|title)\b)[^>]+>/gi, '');

  // 第四步:折叠多余空白
  result = result.replace(/\s{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();

  // 第五步:应用 offset,然后截断
  if (offsetChars > 0) result = result.slice(offsetChars);
  if (result.length > maxChars) {
    result = result.slice(0, maxChars) + `\n[内容已截断,共约 ${offsetChars + result.length} 字符;如需继续请使用 browser_content 并设置 offset=${offsetChars + maxChars}]`;
  }

  return result;
}

精简效果:

原始 HTML:82,451 字符 (~20K token)
精简后:   4,830 字符  (~1.2K token)   → 节省 94%

进阶:Accessibility Tree

Playwright 内置 page.accessibility.snapshot() 返回结构化的可访问性树,比 HTML 更紧凑、语义更强:

const snapshot = await page.accessibility.snapshot();
// 返回:
{
  role: 'WebArea',
  name: 'Hacker News',
  children: [
    { role: 'link', name: 'Hacker News', url: '/' },
    { role: 'link', name: '1. Show HN: xclaw - build your own agent' },
    { role: 'link', name: '2. Ask HN: best practices for LLM agents' },
    // ...
  ]
}

适合需要“精确定位可交互元素“的场景(点击、填表),不适合需要“理解文本内容“的场景。

可交互元素定位(Locator ID)

Accessibility Tree 仍然依赖元素的文本标签来定位,遇到没有文字的图标按钮或同名元素时会失效。更稳健的方案:在精简管道末尾为每个可操作节点注入唯一 ID

// 第六步(追加到 distillHTML 末尾):为可交互元素注入唯一编号
// 在真实 DOM 上操作(page.evaluate)以支持动态渲染的 ARIA 组件
async function injectLocatorIdsIntoDom(page: Page): Promise<void> {
  await page.evaluate(() => {
    let id = 0;
    // a/button/input/select 是经典可交互元素
    // td[role="gridcell"]  覆盖日历、数据表格(WAI-ARIA 标准)
    // li[role="option"]    覆盖 combobox/listbox 下拉选项(WAI-ARIA 标准)
    document.querySelectorAll('a, button, input, select, td[role="gridcell"], li[role="option"]')
      .forEach(el => el.setAttribute('data-agent-id', String(++id)));
  });
}

为什么扩展到 ARIA 角色td[role="gridcell"] 是日历、数据网格的标准角色;li[role="option"] 是 combobox 自动补全下拉的标准角色。不加这两类,Agent 看到的日历格子和下拉选项都没有 data-agent-id,只能用 CSS 选择器,极易失效。

精简 + 注入后的输出示例:

<a data-agent-id="1" href="/login">登录</a>
<button data-agent-id="2">搜索</button>
<input data-agent-id="3" name="q" placeholder="输入关键词">
<select data-agent-id="4" name="city">
  <option>上海</option>
  <option>北京</option>
</select>

Agent 工具调用从“猜选择器“变为“按编号操作“:

❌ 脆弱:{"action":"browser_click","selector":"div.search-bar > button.btn-primary:nth-child(2)"}
✅ 稳健:{"action":"browser_click","agent_id":"2"}

browser_clickbrowser_type 工具同时支持 selectoragent_id 两种参数,优先使用 agent_id

// tools.ts 中 browser_click 的实现逻辑(含导航等待)
const locator = params['agent_id']
  ? `[data-agent-id="${params['agent_id']}"]`
  : params['selector']!;

const urlBefore = page.url();
await Promise.all([
  page.waitForNavigation({ waitUntil: 'networkidle', timeout: 8000 }).catch(() => null),
  page.click(locator).catch(async () => {
    // Playwright click 失败时降级到 JS click(处理被遮挡的元素)
    await page.evaluate((sel) => {
      (document.querySelector(sel) as HTMLElement | null)?.click();
    }, locator);
  }),
]);
const urlAfter = page.url();
// 无论是否跳转都刷新 data-agent-id,保证下一步操作编号正确
if (urlBefore !== urlAfter) {
  await dismissPopups(page);
  await injectLocatorIdsIntoDom(page);
  return `clicked: ${locator}\nnavigated to: ${await page.title()}\nurl: ${urlAfter}`;
}
await injectLocatorIdsIntoDom(page);
return `clicked: ${locator}\nurl: ${urlAfter}`;

3. 视觉理解 (Vision)

截图 → multimodal message

// 工具实现
async function browserScreenshot(page: Page): Promise<string> {
  const buffer = await page.screenshot({ type: 'png', fullPage: false });
  const base64 = buffer.toString('base64');
  // 返回给 Agent 的不是文件路径,而是可直接嵌入 message 的 base64
  return `data:image/png;base64,${base64}`;
}

Agent 把截图结果放进下一轮 LLM 调用时,message 格式从纯文本变为 ContentBlock[]

// Claude API 的 multimodal 格式
const message: Message = {
  role: 'user',
  content: [
    {
      type: 'image',
      source: {
        type: 'base64',
        media_type: 'image/png',
        data: base64Data,           // 不含 "data:image/png;base64," 前缀
      },
    },
    {
      type: 'text',
      text: 'tool output:\n[截图已附上] 页面当前状态如上图,请判断下一步操作。',
    },
  ],
};
// OpenAI API 的 multimodal 格式(对比)
const message = {
  role: 'user',
  content: [
    {
      type: 'image_url',
      image_url: { url: `data:image/png;base64,${base64Data}` },
    },
    { type: 'text', text: '...' },
  ],
};

Token 成本估算

图片尺寸预计 token 消耗
1280×800 全页截图≈ 1500-2000 token
1280×800 视口截图≈ 800-1200 token
640×400 压缩截图≈ 400-600 token

成本控制建议

  • 非必要不截图,优先用 browser_content 获取文本
  • 截图前先 browser_scroll 定位到关键区域,避免全页截图
  • 可配置 { clip: { x, y, width, height } } 只截取关注区域

带标注的截图(Annotated Screenshot)

当 HTML 结构被混淆、或需要让 LLM 直接判断“点哪里“时,在截图上叠加编号红框比纯截图更有效:

// browser_screenshot_annotated 工具实现
async function screenshotWithBoundingBoxes(page: Page): Promise<string> {
  // 1. 收集所有可交互元素的屏幕坐标(与 injectLocatorIdsIntoDom 选择器一致)
  const elements = await page.evaluate(() => {
    return [...document.querySelectorAll('a, button, input, select, td[role="gridcell"], li[role="option"]')]
      .map((el, i) => {
        const r = el.getBoundingClientRect();
        const label = (el.textContent?.trim().slice(0, 15) ||
                       el.getAttribute('placeholder') ||
                       el.getAttribute('aria-label') || '').trim();
        return { id: i + 1, x: r.x, y: r.y, w: r.width, h: r.height, label };
      })
      .filter(e => e.w > 0 && e.h > 0); // 过滤不可见元素
  });

  // 2. 在页面上注入临时 canvas overlay,画红框 + 编号
  await page.evaluate((elems) => {
    const canvas = document.createElement('canvas');
    canvas.style.cssText = 'position:fixed;top:0;left:0;z-index:99999;pointer-events:none';
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    document.body.appendChild(canvas);
    const ctx = canvas.getContext('2d')!;
    for (const e of elems) {
      ctx.strokeStyle = 'red';
      ctx.lineWidth = 2;
      ctx.strokeRect(e.x, e.y, e.w, e.h);
      ctx.fillStyle = 'red';
      ctx.fillRect(e.x, e.y - 16, 22, 16);
      ctx.fillStyle = 'white';
      ctx.font = 'bold 11px sans-serif';
      ctx.fillText(String(e.id), e.x + 3, e.y - 3);
    }
  }, elements);

  // 3. 截图
  const buf = await page.screenshot({ type: 'png' });

  // 4. 移除 overlay(不影响后续操作)
  await page.evaluate(() => {
    document.querySelector('canvas[style*="z-index:99999"]')?.remove();
  });

  return buf.toString('base64');
}

输出效果与使用方式:

截图中:每个可点击元素被红色方框圈住,左上角显示白底红字编号 1、2、3…

Agent 看到截图后,system prompt 追加提示:
"截图中的编号对应可交互元素,请用 browser_click 的 agent_id 参数指定编号,
 用 browser_type 的 agent_id 参数指定输入框编号。"

Agent 回复:
{"action":"browser_click","agent_id":"3"}   ← 点击编号 3 的元素
{"action":"browser_type","agent_id":"5","text":"上海"}  ← 在编号 5 的输入框输入
场景推荐方式
标准表单(有 name/placeholder)DOM 文本 + Locator ID
图标按钮/无文字元素红框截图 + agent_id
复杂日历/富文本组件红框截图 + 坐标 click(x, y)
验证码图片红框截图 → 识别 or HITL

4. 浏览器工具集与会话隔离

八个工具

// system prompt 中的工具描述
browser_navigate  { url: string }
  // 导航到指定 URL,等待页面加载完成
  // 返回:页面标题 + 当前 URL

browser_content   { mode?: "text" | "html", offset?: string }
  // 获取当前页面内容(默认 html,经精简管道处理,含 data-agent-id)
  // offset:字符偏移量,用于读取被截断的后续内容(见截断提示中的 offset= 值)
  // 返回:精简后的页面内容字符串,超出 maxContentChars 时附带 offset 提示

browser_screenshot  {}
  // 截取当前视口截图
  // 返回:base64 编码的 PNG(Agent 在下一轮 message 中附图发给 LLM)

browser_screenshot_annotated  {}
  // 截取截图并在每个可交互元素上叠加编号红框
  // 返回:base64 编码的 PNG(含红框标注)

browser_click     { agent_id?: string, selector?: string }
  // 点击元素;优先用 agent_id,点击后自动等待可能发生的导航,并刷新 data-agent-id 编号
  // 返回:点击结果(含跳转后的标题/URL,或未跳转时的当前 URL)

browser_type      { agent_id?: string, selector?: string, text: string }
  // 清空元素内容并输入 text;输入后自动刷新 data-agent-id(autocomplete 弹出后编号更新)
  // 返回:输入后元素的 value

browser_key       { key: string }
  // 按下键盘按键:Enter(确认 autocomplete 选项或提交表单)、Escape(关闭弹窗/下拉)、
  //   ArrowDown/ArrowUp(在下拉选项间导航)、Tab(切换焦点)
  // 按键后自动等待可能发生的导航,并刷新 data-agent-id 编号
  // 返回:按键结果(含跳转信息或当前 URL)

browser_scroll    { direction: "up" | "down", pixels?: number }
  // 滚动页面(默认 500px)
  // 返回:滚动后的位置信息

BrowserContext 会话隔离

多用户同时使用时,每个 sessionId 必须拥有独立的浏览器上下文,否则:

sessionA 登录了 github.com
sessionB 打开 github.com → 自动以 sessionA 的身份登录  ← 严重安全问题

解决方案:BrowserContext(Playwright 的隔离单元,类似无痕窗口)

class BrowserPool {
  private browser!: Browser;
  private contexts = new Map<string, { ctx: BrowserContext; page: Page }>();

  async init() {
    this.browser = await chromium.launch({ headless: true });
  }

  async getPage(sessionId: string): Promise<Page> {
    if (!this.contexts.has(sessionId)) {
      const ctx = await this.browser.newContext({
        viewport: { width: 1280, height: 800 },
        userAgent: 'Mozilla/5.0 (compatible; xclaw-agent/1.0)',
      });
      const page = await ctx.newPage();
      this.contexts.set(sessionId, { ctx, page });
    }
    return this.contexts.get(sessionId)!.page;
  }

  async closeSession(sessionId: string): Promise<void> {
    const entry = this.contexts.get(sessionId);
    if (entry) {
      await entry.ctx.close();
      this.contexts.delete(sessionId);
    }
  }

  async closeAll(): Promise<void> {
    await Promise.all([...this.contexts.values()].map(e => e.ctx.close().catch(() => {})));
    this.contexts.clear();
    await this.browser.close().catch(() => {});
  }
}

每个 BrowserContext 拥有独立的:

  • Cookie jar
  • localStorage / sessionStorage
  • IndexedDB
  • HTTP 缓存
  • 权限授权记录

等待策略

浏览器工具最容易踩的坑——过早读取页面内容,JS 还没渲染完:

// ❌ 危险:只等到 HTML 解析完,JS 还没跑
await page.goto(url, { waitUntil: 'domcontentloaded' });
const html = await page.content(); // SPA 里内容是空的

// ✅ 安全:等到网络请求都静止(SPA 加载完成的信号)
await page.goto(url, { waitUntil: 'networkidle' });
const html = await page.content(); // 内容已渲染

// ✅ 更精确:等到特定元素出现
await page.waitForSelector('.product-price', { timeout: 10000 });
等待策略说明适用场景
commit收到第一个字节最快,适合只需 URL 的场景
domcontentloadedHTML 解析完静态页面
load所有资源加载完有图片/字体的静态页面
networkidle500ms 内无新请求SPA / Ajax 页面(推荐默认

HITL 集成

浏览器操作中,某些动作具有不可逆性——点击“提交“、“删除”、“支付“后无法撤回。与第 05 节 HITL 机制直接集成:

// tools.ts 中的 browser_click 实现
async execute(sessionId: string, params: { selector: string }) {
  const destructive = DESTRUCTIVE_SELECTORS.some(pattern =>
    params.selector.match(pattern)
  );
  // "submit", "pay", "delete", "confirm", "purchase" 等触发 HITL
  const approved = await hitl.confirm(
    `browser_click ${params.selector}`,
    `即将点击页面元素,当前 URL: ${await page.url()}`,
    destructive,
  );
  if (!approved) return 'action denied by user';

  await page.click(params.selector);
  return `clicked: ${params.selector}`;
}

5. 动态网页交互循环 (Action-Observation Loop)

循环结构

浏览器操作不是“一次调用“,而是一个多轮观察-行动循环。Agent 主循环本身已经是循环(第 01 节),浏览器任务只是让每一轮工具调用都对应“看一眼页面、做一个动作“:

┌─────────────────────────────────────────────┐
│              Action-Observation Loop         │
│                                              │
│  观察 (Observe)                              │
│    browser_content → distillHTML + Locator   │
│    或 browser_screenshot → 红框截图           │
│           │                                  │
│           ▼                                  │
│  思考 (Think)                                │
│    LLM 分析:我在哪一步?下一步做什么?        │
│    输出:{"action":"browser_click","agent_id":"3"} │
│           │                                  │
│           ▼                                  │
│  行动 (Act)                                  │
│    Playwright 执行:点击 / 输入 / 滚动        │
│           │                                  │
│           ▼                                  │
│  验证 (Verify)                               │
│    再次 browser_content,检查 URL / 新元素    │
│    ├── 未变化 → 重试 or 上报错误             │
│    └── 已变化 → 进入下一轮 ──────────────────┘
└─────────────────────────────────────────────┘

这个循环完全由 Agent 主循环(while(true) + 工具调用)驱动,无需新增代码

  • 每次 LLM 输出工具调用 → 执行 → 结果反馈 → LLM 再决策
  • maxIterations 作为循环上限(第 01 节原有机制)

Smart Waiting

浏览器操作最常见的失败原因是“操作太快,页面还没反应“:

// ❌ 危险:固定等待,在慢网络下仍然会失败
await page.click('#search-btn');
await new Promise(resolve => setTimeout(resolve, 2000));
const html = await page.content(); // 结果可能未加载完

// ✅ 安全:等待特定元素出现
await page.click('#search-btn');
await page.waitForSelector('.result-item', { timeout: 10000 });
const html = await page.content(); // 此时结果已渲染

// ✅ 安全:等待跳转完成
await Promise.all([
  page.waitForNavigation({ waitUntil: 'networkidle' }),
  page.click('#submit-btn'),
]);
等待方式适用场景
waitForSelector(sel)点击后等待新元素出现(列表、弹窗)
waitForNavigation()点击后等待页面跳转(登录、提交)
waitForURL(pattern)等待 URL 变为特定模式
networkidleSPA 全页加载后读取内容

异常处理:弹窗与 CAPTCHA

随机弹窗自动关闭(每次 browser_navigate 后调用):

async function dismissPopups(page: Page): Promise<void> {
  const candidates = [
    '[aria-label*="close"]', '[aria-label*="关闭"]',
    'button:has-text("Accept")', 'button:has-text("同意")',
    'button:has-text("Got it")', 'button:has-text("知道了")',
    '.modal-close', '.popup-close', '#cookie-accept', '#gdpr-accept',
  ];
  for (const sel of candidates) {
    try {
      const el = await page.$(sel);
      if (el && await el.isVisible()) await el.click();
    } catch { /* 元素不存在则跳过 */ }
  }
}

CAPTCHA 检测 → 触发 HITL(复用第 05 节机制):

async function checkAndHandleCaptcha(page: Page, hitl: HITLConfirmer): Promise<void> {
  const captchaSelectors = [
    '[class*="captcha"]',
    'iframe[src*="recaptcha"]',
    'iframe[src*="hcaptcha"]',
    '[id*="challenge-form"]',  // Cloudflare
  ];
  for (const sel of captchaSelectors) {
    if (await page.$(sel)) {
      // 交给人类处理,等待确认后继续
      await hitl.confirm(
        'CAPTCHA detected — manual action required',
        `页面出现验证码\n当前 URL: ${page.url()}\n请在浏览器中手动完成验证后按 y 继续`,
        true, // destructive=true,强制询问
      );
      return;
    }
  }
}

CAPTCHA 处理流程:

Agent 导航到目标页面
      │
      ▼
checkAndHandleCaptcha()
      │
      ├── 无 CAPTCHA → 继续执行
      │
      └── 有 CAPTCHA → [HITL] 提示人类介入
                          │
                          ├── 用户手动完成验证,按 y
                          │       │
                          │       └── Agent 继续执行(页面已通过验证)
                          │
                          └── 超时 / 按 n → 返回错误

第 06 节                              第 07 节

tools.ts                              tools.ts
  ├─ shell_tool               →         ├─ shell_tool
  ├─ read_file                →         ├─ read_file
  └─ write_file               →         ├─ write_file
                                        └─ browser_tool  ← 新增(8个工具)
                                              ├─ BrowserPool(会话隔离)
                                              │    └─ BrowserContext per session
                                              ├─ distillHTML(html, maxChars, offset)
                                              │    去噪→语义提取→偏移→截断
                                              ├─ injectLocatorIdsIntoDom()
                                              │    DOM 注入(含 ARIA gridcell/option)
                                              └─ screenshot→base64(Vision)

providers/                            providers/
  Message:                    →         Message:
    role: string                          role: string
    content: string                       content: string
                                          imageURL?: string    ← Vision(Go 实现)
                                          // Node.js: content: string | ContentBlock[]

index.ts                              index.ts
  agent + gateway             →         agent + gateway(不变)
                                        + browserPool.init()
                                        + SIGINT: browserPool.closeAll()

增加能力:
  静态页面  → browser_navigate + browser_content(DOM 文本模式)
  SPA      → waitUntil: networkidle 等待渲染
  表单填写  → browser_type + browser_click(HITL 拦截敏感点击)
  视觉任务  → browser_screenshot → Vision multimodal message
  多用户    → BrowserContext 隔离,cookie 不互串
  精准操作  → Locator ID(data-agent-id)代替脆弱 CSS 选择器
  ARIA 组件 → td[role="gridcell"]日历 + li[role="option"]下拉 获得 agent_id
  视觉定位  → 红框截图标注可交互元素,LLM 按编号点击
  键盘交互  → browser_key:Enter/Escape/ArrowDown 等键盘事件
  长内容    → browser_content offset 参数分页读取超大 DOM
  长流程    → Action-Observation Loop,自动弹窗关闭 + CAPTCHA→HITL

知识点总结

知识点说明
HTTP vs 真实浏览器HTTP 只拿初始 HTML;真实浏览器执行 JS、处理 Cookie、等待 Ajax,SPA 必须用浏览器
DOM 文本 vs VisionDOM 文本省 Token 适合内容提取;Vision 保留布局适合 UI 理解,两者互补不替代
HTML 精简管道去除 script/style/注释 → 只保留语义标签 → 截断;节省 90%+ Token
Locator ID 注入精简后在真实 DOM 上为 a/button/input/select/td[role="gridcell"]/li[role="option"] 注入 data-agent-id;覆盖标准 ARIA 日历格子和下拉选项
Accessibility Treepage.accessibility.snapshot() 返回结构化树,比 HTML 更紧凑,适合精确定位交互元素
BrowserContext 隔离每 session 独立 context,cookie/storage/缓存全部隔离,防止用户登录态互串
waitUntil 策略静态页面用 domcontentloaded;SPA 必须用 networkidle 等 JS 渲染完毕
Vision Token 成本1 张视口截图 ≈ 800-1200 token,约为 1K 字文本的 3-5 倍,仅必要时使用
Bounding Box 标注canvas overlay 在截图上画编号红框;适合图标按钮、日历等无文字可交互元素
multimodal message 格式Node.js: content: string | ContentBlock[],Claude 用 source.type:'base64',OpenAI 用 image_url.url;Go: Message.ImageURL 独立字段,provider 层各自拼装多模态块
截图历史管理截图 base64 不写入消息历史(防上下文溢出);历史中只存 [screenshot] 占位符,当轮 LLM 调用通过独立 vision 字段接收图像
Action-Observation Loop观察→思考→行动→验证的多轮循环;由 Agent 主循环驱动,maxIterations 控制上限
Smart WaitingwaitForSelector / waitForNavigation 比固定 sleep 更可靠;browser_click 内置 Promise.all([waitForNavigation, click]) 模式,点击提交按钮自动等待跳转
弹窗自动关闭导航后扫描常见 close/accept 按钮并点击;处理 cookie 通知、广告遮罩
CAPTCHA → HITL检测 recaptcha/hcaptcha/Cloudflare challenge;触发 HITL 让人类介入,完成后 Agent 继续
HITL 与浏览器结合点击 submit/pay/delete 等触发 HITL 确认;与第 05 节 HITL 机制完全复用,无需新增代码
长内容分页读取page.content() 始终返回完整 DOM(不受滚动影响);用 browser_contentoffset 参数分段读取超大页面,截断提示中含 offset=N 下一段起点
键盘交互browser_key 工具处理 autocomplete 的 ArrowDown+Enter 确认、表单 Enter 提交、弹窗 Escape 关闭等键盘场景

试一试

cd sections/07-browser-automation/nodejs
cp .env.example .env
# 确认 .env 中 API_KEY 正确
npm install
npx playwright install chromium
npm start

Terminal 2(CLI 客户端)

node --env-file=.env src/cli.ts

验证 DOM 文本模式

You: 打开 https://example.com 并告诉我页面的主标题和主要内容

xclaw uses [browser_navigate]: {"url":"https://example.com"}
xclaw uses [browser_content]: {"mode":"text"}
xclaw: 页面标题是 "Example Domain",主要内容为:
       "This domain is for use in illustrative examples..."

验证视觉截图模式

You: 截图 https://news.ycombinator.com 并描述首页排版

xclaw uses [browser_navigate]: {"url":"https://news.ycombinator.com"}
xclaw uses [browser_screenshot]: {}
xclaw: [附图] 页面是橙色顶栏的新闻聚合网站,
       左侧有编号排列的文章标题,右侧显示积分和评论数...

验证表单填写(HITL 拦截)

You: 在 https://httpbin.org/forms/post 填写 custname 为 "xclaw" 并提交

xclaw uses [browser_navigate]: {"url":"https://httpbin.org/forms/post"}
xclaw uses [browser_type]: {"selector":"input[name=custname]","text":"xclaw"}

[HITL] browser_click input[type=submit]
即将点击页面元素,当前 URL: https://httpbin.org/forms/post
Approve? [y/N] (timeout 30s, default N) y

xclaw uses [browser_click]: {"selector":"input[type=submit]"}
xclaw: 表单已提交,页面返回了 POST 请求的 JSON 响应,
       custname 字段值为 "xclaw" ✓

验证会话隔离

# 两个 CLI,两个不同 sessionId,同时访问需要登录的页面
# session A 登录后,session B 应看到未登录状态
# 验证:两个 browser_content 返回不同的页面内容

进阶验证:端到端酒店搜索

演示 Locator ID + ARIA 选择器 + 导航等待 + offset 分页读取的完整协作:

You: 去 https://www.booking.com,搜索"上海",入住明天退房后天,帮我找价格最低的酒店

xclaw uses [browser_navigate]: {"url":"https://www.booking.com"}
  → 自动关闭 cookie 弹窗(dismissPopups 检测到同意按钮并点击)

xclaw uses [browser_content]: {"mode":"html"}
  → distillHTML 精简,injectLocatorIdsIntoDom 注入编号(含 li[role="option"])
  → 返回:<input data-agent-id="1" placeholder="目的地、住处名称">

xclaw uses [browser_type]: {"agent_id":"1","text":"Shanghai"}
  → 输入后 autocomplete 弹出,DOM 更新,injectLocatorIdsIntoDom 自动刷新编号
  → li[role="option"] 获得新的 data-agent-id

xclaw uses [browser_click]: {"agent_id":"3"}   ← 点击 autocomplete 第一个选项"上海,中国"
  → Promise.all([waitForNavigation, click]):点击触发地点确认但未跳转,8s 超时后继续
  → injectLocatorIdsIntoDom 再次刷新

xclaw uses [browser_screenshot_annotated]: {}
  → 日历弹出,td[role="gridcell"] 获得 data-agent-id,截图上红框标注日期格子
  → 截图可见:编号 12 = 明天日期,编号 13 = 后天日期

xclaw uses [browser_click]: {"agent_id":"12"}  ← 点击入住日期(明天)
xclaw uses [browser_click]: {"agent_id":"13"}  ← 点击退房日期(后天)
xclaw uses [browser_click]: {"agent_id":"7"}   ← 点击"搜索"按钮
  → Promise.all([waitForNavigation, click]):等待 networkidle,URL 变为搜索结果页
  → dismissPopups + injectLocatorIdsIntoDom

xclaw uses [browser_content]: {"mode":"html"}
  → 返回约 20000 字符,主要是筛选栏
  → 末尾提示:[内容已截断,共约 62000 字符;如需继续请使用 browser_content 并设置 offset=20000]

xclaw uses [browser_content]: {"mode":"html","offset":"40000"}
  → 跳过筛选栏,直接读到酒店列表区域
  → 提取酒店名称、价格、评分

xclaw: 找到最低价酒店:
       「Hi Cozy International Hostel(嗨享客栈·国际青年旅舍)」
       每晚 ¥375,评分 8.3 / 10
       https://www.booking.com/hotel/cn/...

关键技术点总结:

步骤技术点
弹窗关闭dismissPopups 自动处理 cookie 同意
文本框定位Locator ID → agent_id:"1" 代替 CSS 选择器
Autocomplete 选项li[role="option"] 获得 data-agent-id,输入后自动刷新编号
日历选择td[role="gridcell"] 获得 data-agent-id,截图可见红框编号
搜索提交Promise.all([waitForNavigation, click]) 等待跳转完成
超大 DOM 读取offset 参数分页:offset=0(筛选栏)→ offset=40000(酒店列表)
价格提取browser_content: html → 精简后 LLM 提取结构化数据