本节摘要:本节把第 1.1 节的语义与第 1.2 节的机制在靶场上合流。任务选一个不平凡但可控的:观察本地测试页有哪些可操作元素,添加一条待办「买牛奶」,再抽取整个列表验证结果。三步各占一节:observe 一步摊开候选动作清单这个中间产物;act 一步执行并讨论指令写法与幂等问题(重复运行会重复添加,测试要清场);extract 一步用指令加数据模式取出结构化列表。随后给两版对照:Stagehand 指令式完整脚本(全书 TypeScript 代码块的统一形态说明也在本节),与纯 Playwright 代码式脚本——对照的意义在于看清「三原语是语义层,其下仍是普通浏览器驱动」。最后一张排查表对应三步各自的期望产物与失败信号。
复用第 0.2 节的本地测试页(http://127.0.0.1/demo_todo.html),元素结构沿用第 0.2 节的结构表:标题「待办清单演示」、输入框「新待办」、按钮「添加」、列表、链接「关于」。本节任务分三步,正好对应三原语:
| 步骤 | 原语 | 目标 | 期望产物 |
|---|---|---|---|
| 一 | observe | 看清页面可操作什么 | 候选动作清单 |
| 二 | act | 添加待办「买牛奶」 | 页面列表出现新条目 |
| 三 | extract | 取出整个列表 | 结构化 JSON |
一个先说破的坑:这个任务不幂等——每跑一遍就多一条「买牛奶」。自动化测试对幂等的标准处理是「前置清场或断言数量」,第 6 章会把这上升为验证纪律;本节阶段,手动刷新页面即可清场。
// step1_observe.mjs —— 用 Stagehand 观察页面(写法示意,以官方文档为准) import { Stagehand } from "@browserbasehq/stagehand"; const stagehand = new Stagehand({ env: "LOCAL", modelName: "gpt-4o" }); await stagehand.init(); const page = stagehand.page; await page.goto("http://127.0.0.1:8080/demo_todo.html"); const candidates = await page.observe("这个页面上可以做的操作"); console.log("候选动作数:", candidates.length); for (const item of candidates) { console.log("-", JSON.stringify(item)); } await stagehand.close();
期望输出形如(示意,字段名以官方文档为准):
# step1_output.txt —— observe 输出示意(节选) 候选动作数: 3 - {"instruction":"在「新待办」输入框输入内容","method":"fill", ... } - {"instruction":"点击「添加」按钮","method":"click", ... } - {"instruction":"点击「关于」链接","method":"click", ... }
注意两点:其一,这条清单就是第 1.2 节「编号点击」清单的同构物——每项携带定位信息(方法、目标),只欠执行;其二,observe 全程没有改页面一个字节,你此刻拥有零成本否决权:候选里若混进「删除全部」这类危险项,在这里就能看见并改指令。
// step2_act.mjs —— 用 act 添加一条待办(写法示意,以官方文档为准) import { Stagehand } from "@browserbasehq/stagehand"; const stagehand = new Stagehand({ env: "LOCAL", modelName: "gpt-4o" }); await stagehand.init(); const page = stagehand.page; await page.goto("http://127.0.0.1:8080/demo_todo.html"); await page.act("在「新待办」输入框填入 买牛奶,然后点击「添加」按钮"); await stagehand.close();
act 指令刻意按第 1.1 节三原则书写:说目标(添加待办)、引用可见文本(「新待办」「添加」)、复合但不分支(填与点两个子意图,交给框架解析)。若失败,回到 observe 检查候选——多半是输入框的 label 关联没做对(第 0.2 节结构表的「用 label 正确关联」正是为这一步铺路),或页面尚未渲染完就执行了。
act 的每一步都是一次模型调用,成本与延迟随指令数线性增长——这句话是第 3 章整章的引子。
// step3_extract.mjs —— 用 extract 抽取待办列表(写法示意,以官方文档为准) import { Stagehand } from "@browserbasehq/stagehand"; import { z } from "zod"; const stagehand = new Stagehand({ env: "LOCAL", modelName: "gpt-4o" }); await stagehand.init(); const page = stagehand.page; await page.goto("http://127.0.0.1:8080/demo_todo.html"); const data = await page.extract({ instruction: "提取待办列表的全部条目文本", schema: z.object({ items: z.array(z.string()) }), }); console.log(JSON.stringify(data, null, 2)); await stagehand.close();
若第二步刚执行过,期望输出:
# step3_output.txt —— extract 输出示意 { "items": [ "买牛奶" ] }
模式刻意写窄(只要一个字符串数组):第 1.1 节讲过,宽模式诱发模型脑补。若此处结果为空数组而页面上明明有「买牛奶」,嫌疑按序排查:列表是否真的渲染(手工打开页面确认)、抽取是否发生在渲染前(加等待)、指令是否被理解成别的列表(把指令里的锚文本写具体)。
把同一个三步任务用纯 Playwright 写一遍(不含任何模型调用):
# three_steps_playwright.py —— 纯代码完成同样三步(对照版) import asyncio from playwright.async_api import async_playwright async def main() -> None: async with async_playwright() as p: browser = await p.chromium.launch(headless=True) page = await browser.new_page() await page.goto("http://127.0.0.1:8080/demo_todo.html") # 观察:枚举可交互元素(第 1.2 节机制的手工版) print("标题:", await page.title()) print("按钮数:", await page.get_by_role("button").count()) # 执行:语义定位,填表并提交 await page.get_by_label("新待办").fill("买牛奶") await page.get_by_role("button", name="添加").click() # 抽取:读取列表 items = await page.get_by_role("listitem").all_inner_texts() print("待办:", items) await browser.close() asyncio.run(main())
| 维度 | 指令式(Stagehand) | 代码式(Playwright) |
|---|---|---|
| 定位依据 | 指令文本,模型现场裁决 | 写死的角色与文本匹配 |
| 模型调用 | 每原语一次 | 零 |
| 页面结构变化耐受 | 高(语义不变即成立) | 中(可见文本不变即成立) |
| 执行一致性 | 概率路径 | 完全确定 |
| 适合 | 动态页面、一次性任务 | 稳定流程、回归测试 |
对照的结论一句话:三原语是架在浏览器驱动之上的语义层,不是替代品。Stagehand 内部正是用 Playwright 驱动浏览器;browser-use 同样构建在 Playwright 之上。语义层买适应性、付模型成本;代码层反之——两层之间如何逐块换料,是第 3.3 节渐进式固化的全部内容。
顺带交代本书 TypeScript 代码块的统一形态:为兼顾无编译直接检查语法,代码块写成 ES2020 模块形态(.mjs,可直接用 node 做语法检查),类型信息以 JSDoc 注释标注而非 TS 语法,如:
// jsdoc_shape.mjs —— 本书 TS 代码块形态:JSDoc 标注类型 /** @param {string} instruction 自然语言指令 @returns {Promise} 成功 resolve 为 true,失败为 false */ async function safeAct(page, instruction) { try { await page.act(instruction); return true; } catch (err) { console.warn("act 失败:", err.message); return false; } }
| 步骤 | 期望产物 | 失败信号 | 第一反应 |
|---|---|---|---|
| observe | 3 项左右候选清单 | 候选为 0 或大量无关项 | 检查页面渲染与 label 关联 |
| act | 列表出现「买牛奶」 | 报错或列表未变 | 回 observe 看候选;检查指令锚文本 |
| extract | items 数组含新条目 | 空数组或字段缺失 | 确认渲染完成;收窄模式;具体化指令 |
三步各自独立成文件便于分段排障;合并成一个脚本的完整版(含清场与错误处理)留作本章练习思路:goto 后先刷新重开页面保证清场,三步之间插入显式等待,extract 结果与预期做一次显式比对——这个比对动作就是第 6 章「验证」的最小雏形。
单步会做了,语义层与代码层的边界也看清了。下一章把语义层推到极致:browser-use 的 Agent 循环——谁在替你编排三原语、页面状态如何反馈给模型、循环什么时候停。