《AI Agent 高级编程》7.2 浏览器 Agent:Playwright 工具化完成网页取数

7.1 用坐标点屏幕,通用但昂贵;本节退一步,用 Playwright 把浏览器操作工具化,对本地 file:// 静态页面真跑取数、点击、读文本。用 Node 版 1.59.1 演示 createRequire 加载全局包,把 goto/click/read_text/extract_table 包成可序列化工具,再用 ARIA snapshot 替代截图观察,最后给出三种观察口径对照表。

7.2 浏览器 Agent:Playwright 工具化完成网页取数

7.1 的坐标动作有个绕不开的代价:模型只能看到像素。要判断「这一页有几个 SKU」,它得先截图、再看图、再推理——每步一张图,token 账单随分辨率涨。可是网页和普通桌面不一样:它本来就有结构,DOM 树、ARIA 角色、可寻址的元素,这些信息一直在那里,只是截图把它们拍平成了像素。浏览器 Agent 的核心动作,就是别传像素,传结构。

本节把浏览器操作工具化:用 Playwright 打开一个本地 file:// 静态页面,把 goto / click / read_text / extract_table 包成可序列化的工具,真跑一遍取数流程;再用 ARIA snapshot 替代截图做观察,最后对照「截图 / 选择器 / ARIA」三种观察口径的成本与稳定性。本节不访问任何真实网站,全程只打开本地文件。

7.2.1 为什么是 Playwright,而不是截图点坐标

浏览器 Agent 有两条路线:像素路线(截图 + 坐标,即 7.1)和结构化路线(DOM 选择器 / ARIA 角色)。结构化路线把「按钮在哪」翻译成「页面上叫『只看缺货』的按钮」——模型输出的是语义定位,不是坐标,于是分辨率变了也不失效,页面上多一个弹窗也不影响。

Playwright 是这条路线的现成实现:它能启动真实浏览器内核(Chromium),暴露 page.click(selector)、locator.textContent()、locator.ariaSnapshot() 这些结构化 API。我们要做的不是重新发明它,而是把它包成 Agent 能调用的工具——每个工具输入输出都是 JSON,和卷一 ToolRegistry 里的工具形状一致。

7.2.2 本地静态页面:把「网站」缩成一个 file:// 文件

为了真跑又不碰真实网站,我们写一个本地 HTML,用 file:// 打开。它是一个库存表格加一个过滤按钮,够演示取数与交互:

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="utf-8"><title>Inventory</title></head>
<body>
  <h1 id="title">库存清单</h1>
  <table id="inv">
    <thead><tr><th>SKU</th><th>名称</th><th>数量</th></tr></thead>
    <tbody>
      <tr><td>A-100</td><td>键盘</td><td>12</td></tr>
      <tr><td>A-200</td><td>鼠标</td><td>0</td></tr>
      <tr><td>A-300</td><td>显示器</td><td>5</td></tr>
    </tbody>
  </table>
  <button id="low">只看缺货</button>
  <div id="status">未过滤</div>
  <script>
    document.getElementById('low').addEventListener('click', () => {
      document.getElementById('status').textContent = '已过滤:A-200 缺货';
    });
  </script>
</body>
</html>

页面里有一个 click 会改动的 #status,这样我们才能验证「点击真的生效了」,而不是只读到静态内容。

7.2.3 加载全局 Playwright:NODE_PATH 对 ESM 无效

本机 Playwright 装在全局 /opt/homebrew/lib/node_modules/,Python 版 playwright 未安装,所以用 Node 版。实测版本:

node -e "console.log(require('/opt/homebrew/lib/node_modules/playwright/package.json').version)"
# 1.59.1

直接 require('playwright') 会失败——全局包不在当前项目的 node_modules 里。设 NODE_PATH 也不行,它对 ESM 无效。实测可用的写法是用 createRequire 指向全局目录:

const { createRequire } = require('module');
const req = createRequire('/opt/homebrew/lib/node_modules/');
const { chromium } = req('playwright');

createRequire 造出一个「从全局目录解析」的 require,绕过本地 node_modules 的限制。这是本节唯一一个「环境坑」,其余代码都是标准 Playwright。

7.2.4 工具化:把浏览器操作包成可序列化工具

工具层是一个工厂函数:给它一个 page,返回一组工具。每个工具输入一个对象、输出可 JSON 序列化的结果——这正是模型工具调用的形状。

function makeTools(page) {
  return {
    async read_text({ selector }) {
      const el = await page.$(selector);
      return el ? (await el.textContent()).trim() : null;
    },
    async extract_table({ selector }) {
      return page.$$eval(`${selector} tbody tr`, (rows) =>
        rows.map((r) => [...r.querySelectorAll('td')].map((c) => c.textContent.trim())));
    },
    async click({ selector }) {
      await page.click(selector);
      return `clicked ${selector}`;
    },
    async current_url() {
      return page.url();
    },
  };
}

四个工具里,extract_table 是关键:它把一张 HTML 表格直接变成二维数组。模型拿到的是 [["A-100","键盘","12"], ...],不需要看图、不需要推理行列,直接可用。这就是结构化路线相对像素路线的根本优势。

7.2.5 真跑:取数 / 点击 / 读文本

用一个写死的 PLAN 代表模型的决策序列,逐条调用工具。真跑(Node 26.5.0 / Playwright 1.59.1):

const PLAN = [
  ['read_text', { selector: '#title' }],
  ['extract_table', { selector: '#inv' }],
  ['click', { selector: '#low' }],
  ['read_text', { selector: '#status' }],
];

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  const url = 'file://' + path.resolve('/tmp/agentbook/ch7/inventory.html');
  await page.goto(url);                       // 只打开本地文件,不访问任何站点
  const tools = makeTools(page);
  console.log('opened:', await tools.current_url());
  for (const [name, args] of PLAN) {
    const out = await tools[name](args);
    console.log(`${name}(${JSON.stringify(args)}) ->`, JSON.stringify(out));
  }
  await browser.close();
  console.log('BROWSER_AGENT_OK');
})();

输出:

opened: file:///tmp/agentbook/ch7/inventory.html
read_text({"selector":"#title"}) -> "库存清单"
extract_table({"selector":"#inv"}) -> [["A-100","键盘","12"],["A-200","鼠标","0"],["A-300","显示器","5"]]
click({"selector":"#low"}) -> "clicked #low"
read_text({"selector":"#status"}) -> "已过滤:A-200 缺货"
BROWSER_AGENT_OK

四步全部拿到结构化结果:标题是字符串,表格是二维数组,点击返回确认,点完再读 #status 拿到了 '已过滤:A-200 缺货'——证明点击真的改变了页面状态,不是空跑。整个过程只打开了 file:// 本地文件,没有任何网络请求。

7.2.6 把工具接到 Agent 循环:工具 schema

工具函数写好了,还要给它一份给模型看的 schema。这就是模型决定「该调哪个工具、传什么参数」的依据,形状和卷一 ToolRegistry 里注册的工具完全一致:

{
  "name": "extract_table",
  "description": "从页面上的表格抽取所有行,返回二维字符串数组",
  "input_schema": {
    "type": "object",
    "properties": {
      "selector": { "type": "string", "description": "表格的 CSS 选择器" }
    },
    "required": ["selector"]
  }
}

接到循环里,就是一个「模型吐 tool_call → 我们用 tools[name](args) 执行 → 结果回灌」的分发:

for (const call of modelToolCalls) {          // 模型决定要调哪些工具
  const out = await tools[call.name](call.arguments);
  messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(out) });
}

注意这里的形状和 7.1 的坐标动作本质相同、只差参数类型:7.1 的 call.arguments 是 {x, y},这里的 call.arguments 是 {selector}。同一个 Agent 循环,换一套工具,就从「点屏幕」变成了「取网页数据」——这正是把浏览器操作工具化的意义:让浏览器能力变成循环里的普通工具,而不是另起一套循环。

7.2.7 等待与稳定性:动态内容要等

网页是异步的。点击之后 #status 不会立刻更新,如果马上读,可能读到旧值。Playwright 的 waitForSelector 就是等「某个条件成立」再继续。实测:

await page.click('#low');
await page.waitForSelector('#status:has-text("已过滤")', { timeout: 2000 });
console.log('waitForSelector ok ->', (await page.textContent('#status')).trim());

输出:

waitForSelector ok -> 已过滤:A-200 缺货

has-text("已过滤") 等的是文本内容而非元素出现——这比「等 500 毫秒」可靠得多,也是 Playwright 相对手写 sleep 的核心优势:locator 自带自动等待(auto-waiting),元素可交互才动作。真实抓取里,动态渲染的站点必须靠这类条件等待,否则会间歇性读到空值,产生极难复现的 bug。

7.2.8 ARIA snapshot:比截图更省的观察方式

如果模型需要「看懂」整页而不只是取一个字段,还有比截图更好的办法:ARIA snapshot。它把页面渲染成一棵带角色的文本树,模型读文本就能定位元素,不必处理像素。实测 page.locator('#inv').ariaSnapshot() 的输出:

- table:
  - rowgroup:
    - row "SKU 名称 数量":
      - columnheader "SKU"
      - columnheader "名称"
      - columnheader "数量"
  - rowgroup:
    - row "A-100 键盘 12":
      - cell "A-100"
      - cell "键盘"
      - cell "12"
    - row "A-200 鼠标 0":
      - cell "A-200"
      - cell "鼠标"
      - cell "0"
    - row "A-300 显示器 5":
      - cell "A-300"
      - cell "显示器"
      - cell "5"

这棵树就是「页面的语义骨架」。模型看完它,可以直接说「点 role=button、name=只看缺货 的元素」——Playwright 的 page.getByRole('button', { name: '只看缺货' }) 实测 count() 返回 1,精确定位到位。同样的信息,截图要传一张图,ARIA 只传几十行文本。

7.2.9 三种观察口径对照

口径模型看到什么定位方式分辨率敏感相对成本适用
截图(7.1)像素图坐标 (x, y)极敏感高(图像 token)无 DOM 的桌面应用
DOM 选择器单个字段/表格CSS 选择器不敏感低已知结构、精确取数
ARIA snapshot语义树role + name不敏感低(文本)结构未知、需探索页面

结论很清楚:只要页面有 DOM,就别用截图。选择器适合你已经知道要取哪个字段;ARIA snapshot 适合你要先「看懂」整页再决定。截图只留给真正的兜底场景——Canvas 渲染的应用、桌面软件、以及压根没有语义标记的页面。

7.2.10 让渡与边界

维度你得到的你让渡的
定位语义定位,抗分辨率、抗布局微调依赖页面结构——DOM 一改,选择器就断
取数直接拿结构化数据,无需视觉推理动态渲染内容要等 waitFor,否则读到空
成本文本观察,token 远低于截图复杂交互(拖拽、Canvas)表达力不足
环境可 headless 跑在 CI 里需要浏览器内核,镜像体积与环境依赖增加

Playwright 替你做了浏览器控制、等待、定位、取值这一整套;你让渡的是「对页面结构的依赖」——选择器是脆的。真实生产里要在选择器之外加一层容错:优先 getByRole(语义最稳),其次 data-testid,最后才用 CSS 路径。另外别忘了:即使工具化了,导航到外部站点仍然是一次不可逆的副作用(可能触发下单、发帖),这正是下一节要处理的。

下一节 7.3 安全与成本 会把「不可逆动作」单独拎出来:支付、删除、发送如何沙箱化,审批回调、dry-run、配额上限怎么落地。想回看 Claude Agent SDK 里的权限审批机制(7.3 会复用它的 allow/deny 思路),见 3.2 工具与权限 。

阅读导航:上一节:7.1 Computer Use 基础:截图/坐标动作/循环最小实现 · 下一节:7.3 安全与成本:不可逆动作沙箱与人工确认 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距