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 安全与成本:不可逆动作沙箱与人工确认 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。