
AI 辅助 GUI 自动化实战指导书
面向对象:完全没接触过浏览器自动化,或只写过几行 Selenium 的初学者
配套代码:AIforGUIcode/(本机已完整跑通,文中所有执行结果均为真实输出)
技术栈:Python 3.11+ · Playwright 1.62 · Browser Use 0.13
最后更新:2026-08-04
这本书怎么读
全书只分两章,对应两个完全不同的目的:
| 章节 | 解决什么问题 | 读完你会 | |
|---|---|---|---|
| 第一章 | 技术篇 | 搞懂原理。不比谁强谁弱,而是沿着"让机器操作 GUI 需要哪些能力"这条主线,一层一层往下挖,看 Playwright 和 Browser Use 各自是怎么解决同一层问题的 | 看懂任何一段浏览器自动化代码在干什么,知道出问题时该往哪一层查 |
| 第二章 | 实战篇 | 动手做成。一个完整的真实场景,从零搭环境到跑出报告,六个步骤逐个拆解 | 独立复现整个项目,并能把这套骨架搬到自己的业务上 |
建议读法:
- 时间充裕 → 顺序读。第一章的每一层能力,都会在第二章的某一步里被真实用到,前后是咬合的。
- 想先跑起来 → 直接跳到 第二章 2.3 环境搭建,跑通之后再回头读第一章,很多"为什么要这么写"会突然通透。
- 已有基础 → 第一章挑 1.7 意图层 和 1.8 韧性层 读,这两层是 AI 带来的真正增量。
阅读约定:
- 每个技术点都按 「先说人话 → 再说原理 → 配框架图」 展开。
- 所有框架图用纯文本绘制,任何 Markdown 阅读器都能正常显示。
- 代码块里的注释就是讲解本身,不要跳过。
目录
第一章 · 技术篇:AI 辅助 GUI 操作与测试的能力体系
- 1.0 先建坐标系:一次人工操作到底包含哪些能力
- 1.1 L0 驱动层:程序怎么"抓住"一个浏览器
- 1.2 L1 感知层:机器怎么"看见"页面
- 1.3 L2 定位层:怎么准确指到"那个按钮"
- 1.4 L3 操作层:为什么"点击"远不止派发一个事件
- 1.5 L4 同步层:什么时候可以动手(自动化最大的坑)
- 1.6 L5 状态层:身份、会话与隔离
- 1.7 L6 意图层:从"告诉你点哪"到"告诉你要什么"
- 1.8 L7 韧性层:页面改版之后还能不能跑
- 1.9 两条技术路线是怎么长出来的
- 1.10 能力全景与工程选型
第二章 · 实战篇:BookNest 会员捡漏机器人
- 2.1 项目背景:一个真实到有点无聊的痛点
- 2.2 场景设计思路:靶场为什么长这样
- 2.3 第 0 步:环境从零搭建
- 2.4 Step 1 · Hello Playwright:先确认环境是活的
- 2.5 Step 2 · 五个陷阱:先踩坑,再填坑
- 2.6 Step 3 · 登录与会话复用
- 2.7 Step 4 · Playwright 完整方案(确定性流水线)
- 2.8 Step 5 · Browser Use 方案(AI 自主完成)
- 2.9 Step 6 · 混合架构(生产环境推荐形态)
- 2.10 完整项目结构与全部产物
- 2.11 排错手册
- 2.12 学习路径与进阶方向
第一章 · 技术篇:AI 辅助 GUI 操作与测试的能力体系
1.0 先建坐标系:一次人工操作到底包含哪些能力
在讲任何工具之前,先看一件你每天都在做、但从没拆解过的事。
场景:你要去某图书网站,登录后找出所有"会员折扣超过 15%、评分 4.5 以上、且有现货"的书。
你实际做了什么?慢镜头回放:
① 打开浏览器 → 你得先有一个能跑网页的东西
② 眼睛扫一遍页面,认出"这是登录入口" → 你在"看懂"页面
③ 视线锁定那个输入框 → 你在"定位"一个具体元素
④ 手指点下去、键盘敲字 → 你在"操作"
⑤ 页面转圈,你等它转完 → 你在"判断时机"
⑥ 登录后,后面所有页面都记得你是谁 → 浏览器在"保持状态"
⑦ 你心里想的其实是"找便宜好书",
而不是"点第 3 个按钮" → 你有"意图",不是在执行指令
⑧ 网站改版了,按钮换了位置,
你愣两秒然后照样找到 → 你有"适应变化"的能力这八件事,就是 GUI 自动化要复刻的全部内容。把它们归纳一下,得到一个八层能力栈——这也是本章的骨架:
图 1-1 · GUI 自动化能力八层栈
┌──────────────────────────────────────────────┐
越往上 │ L7 韧性层 页面变了还能不能跑 │ ← AI 的主战场
越"像人" ├──────────────────────────────────────────────┤
▲ │ L6 意图层 从"点哪里"到"我要什么" │ ← AI 的主战场
│ ├──────────────────────────────────────────────┤
│ │ L5 状态层 我是谁、登录态、会话隔离 │
│ ├──────────────────────────────────────────────┤
│ │ L4 同步层 什么时候可以动手 │ ← 初学者 80% 的 bug
│ ├──────────────────────────────────────────────┤
│ │ L3 操作层 点击/输入/上传/拖拽 │
│ ├──────────────────────────────────────────────┤
│ │ L2 定位层 指到"那一个"元素 │ ← 脚本最脆的地方
越往下 ├──────────────────────────────────────────────┤
越"像机器" │ L1 感知层 机器怎么"看见"页面 │
├──────────────────────────────────────────────┤
│ L0 驱动层 怎么"抓住"一个浏览器 │
└──────────────────────────────────────────────┘关于本章的讲法,有一句话要先说清楚:
关键认知:这不是擂台赛,是「发动机 + 自动驾驶」
一句话:这不是一场 Playwright vs Browser Use 的擂台赛。这两个东西根本不在同一个抽象层上——Playwright 把 L0 ~L5 做到了极致,Browser Use 是站在 L0\~L5 之上、去补 L6\~L7 的。它们的关系更像"发动机"和"自动驾驶系统",不是"本田"和"丰田"。
为什么这么说,拆成三点:
① 它们压根不在同一抽象层。 回到上面那座八层栈(图 1-1):L0L5(抓浏览器 / 看页面 / 定位 / 操作 / 等时机 / 保持登录态)是自动化的"体力活",Playwright 把这些做到了极致;L6L7(把"点第 3 个按钮"翻译成"帮我找便宜好书"、页面改版后自己找回来)是自动化的"脑子",这是 Browser Use 要补的部分。Browser Use 自己底层也得靠浏览器驱动去干 L0\~L5 的活(新版本用自研的 cdp-use),所以它不是另一台"发动机",而是"给现有发动机装上了驾驶脑"。
② 类比要换一下。 如果你把它们想成"本田 vs 丰田",那就错了——那是两台同级别的整车在比参数。正确的是 "发动机 vs 自动驾驶系统":发动机负责把车跑起来、把动力传到轮子(L0L5);自动驾驶系统负责决定往哪开、怎么应对路况变化(L6L7)。你不会问"发动机和自动驾驶哪个更好",因为它们是上下层、互补的关系。反过来——没有好发动机,再聪明的驾驶脑也只是空谈;没有驾驶脑,发动机只能听你一句一句下指令。
③ 这对你意味着什么。 学的时候,先吃透 L0L5(Playwright),再学 L6L7(Browser Use)——底层不懂,上层就是黑盒,出问题时你查不到落在哪一层。用的时候,两者不是二选一,而是按场景拼装:稳定、可重复、要省 LLM 费的,用 Playwright 确定性流水线;规则说不清、界面总变、要"人话下指令"的,用 Browser Use 或混合架构(第二章 Step 5、Step 6 会分别演示)。
所以下面每一层,我会问同一组问题:
- 这层能力要解决什么问题?(人话)
- 技术上难在哪?(原理)
- 两条技术路线各自怎么解?(框架图)
- 这层没做好会出什么事故?(踩坑预警)
1.1 L0 驱动层:程序怎么"抓住"一个浏览器
1.1.1 人话:你得先有个"遥控器"
写代码控制浏览器,第一个问题是:代码和浏览器是两个独立进程,它们凭什么能对话?
历史上有三代答案:
图 1-2 · 浏览器驱动方式的三代演进
第一代:操作系统级模拟(2000s,如早期 AutoIt / 按键精灵)
┌────────┐ 模拟鼠标坐标点击 ┌──────────┐
│ 脚本 │ ─────────────────▶ │ 操作系统 │ ─▶ 浏览器窗口
└────────┘ 模拟键盘扫描码 └──────────┘
问题:① 认不出元素,只认屏幕坐标 ② 窗口一移就全废 ③ 无法后台运行
第二代:WebDriver 协议(2010s,Selenium)
┌────────┐ HTTP + JSON ┌─────────────┐ 浏览器私有接口 ┌────────┐
│ 脚本 │ ────────────▶ │ chromedriver│ ──────────────▶ │ 浏览器 │
└────────┘ (W3C 标准) └─────────────┘ └────────┘
优点:跨浏览器标准化,认得 DOM 元素
问题:① 每个命令一次 HTTP 往返,慢
② 单向请求-响应,浏览器有事件也没法主动通知你
③ 版本必须严格对齐(driver 版本 ≠ 浏览器版本就崩)
第三代:DevTools 协议 / CDP(2020s,Playwright、Puppeteer、Browser Use)
┌────────┐ WebSocket 长连接(双向) ┌────────┐
│ 脚本 │ ◀────────────────────────▶ │ 浏览器 │
└────────┘ CDP:Page/DOM/Network/ └────────┘
Input/Runtime… 数十个域
优点:① 双向,浏览器可以主动推事件(请求发出了、DOM 变了、弹窗来了)
② 一条长连接,延迟低
③ 能力远超 WebDriver:拦截网络、注入脚本、录制 trace、模拟设备CDP 是什么:Chrome DevTools Protocol。你按 F12 打开的开发者工具,本身就是一个 CDP 客户端——它和浏览器之间说的就是这套协议。也就是说,自动化工具能做的事,本质上就是"开发者工具能做的事",这个类比可以帮你建立正确的能力预期。
1.1.2 Playwright 的进程模型:为什么 Python 包里塞了个 Node.js
很多人装完 Playwright 会疑惑:我明明装的是 Python 库,site-packages 里怎么有个几十兆的 Node 程序?
图 1-3 · Playwright 的三段式进程架构
┌────────────────────────────┐
│ 你的 Python 进程 │
│ ┌──────────────────────┐ │
│ │ page.click("#btn") │ │ ← 你写的这行
│ └──────────┬───────────┘ │
│ │ 序列化成 JSON │
└─────────────┼──────────────┘
│ stdin/stdout 管道(不是 HTTP!)
▼
┌────────────────────────────┐
│ Playwright Driver │ ← 随包发布的 Node.js 程序
│ (核心逻辑全在这里) │ · 定位器求值
│ │ · actionability 检查
│ │ · auto-wait 重试循环
│ │ · trace 录制
└─────────────┬──────────────┘
│ CDP over WebSocket
▼
┌────────────────────────────┐
│ Browser 进程(Chromium) │ ← playwright install 下载的那个
│ ┌────────┐ ┌────────┐ │
│ │Context1│ │Context2│ ... │ ← 互相隔离的"浏览器身份"(见 L5)
│ │ ┌────┐ │ │ ┌────┐ │ │
│ │ │Page│ │ │ │Page│ │ │ ← 每个 Page = 一个标签页
│ │ └────┘ │ │ └────┘ │ │
│ └────────┘ └────────┘ │
└────────────────────────────┘为什么要这样设计? 三个工程考量:
- 一份核心逻辑,多语言复用。Playwright 支持 Python / Java / .NET / Node,如果每种语言都重写一遍 auto-wait 和 actionability,行为一定会漂移。现在四种语言的客户端都只是"薄壳",真正的逻辑只有 Node driver 这一份,所以跨语言行为完全一致。
- 管道通信比 HTTP 快一个量级。Selenium 每个命令一次 HTTP 往返;Playwright 走本地管道,没有 TCP 握手、没有 HTTP 头解析。
- 浏览器版本被锁死。
playwright install下载的是 Playwright 团队亲自构建、测试过的浏览器版本,不用你的系统 Chrome。这就根治了 Selenium 时代最烦人的"driver 和浏览器版本对不上"。
实战印证:第二章 Step 1 里with sync_playwright() as p:这一行,做的就是"启动 driver 进程 + 建立管道"。p.chromium.launch()才是启动浏览器。两件事是分开的——理解这点,你就知道为什么sync_playwright()必须用with包住(要保证 driver 进程被回收)。
1.1.3 Browser Use 的进程模型:把 LLM 插进这条链路
Browser Use 早期(0.1.x)直接依赖 Playwright 当驱动。从 0.13 起,它换成了自研的 cdp-use,直接说 CDP,不再经过 Node driver。
图 1-4 · Browser Use 的进程架构
┌──────────────────────────────────────────────────────────┐
│ 你的 Python 进程 │
│ │
│ TASK = "登录后找出折扣>15%、评分>4.5、有货的书" │ ← 你只写这个
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Agent │ │
│ │ ┌──────────┐ ┌────────────┐ ┌───────────────┐ │ │
│ │ │ Message │ │ Tools │ │ Output │ │ │
│ │ │ Manager │ │ Registry │ │ Schema │ │ │
│ │ │(记忆) │ │(动作集) │ │(结构化契约) │ │ │
│ │ └──────────┘ └────────────┘ └───────────────┘ │ │
│ └───────┬───────────────────────────────┬───────────┘ │
│ │ HTTPS │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ LLM 服务 │ │ BrowserSession │ │
│ │(GPT/Claude/ │ │ ┌────────────────┐ │ │
│ │ DeepSeek…) │ │ │ DOM Service │ │ │
│ └──────────────┘ │ │(把页面提纯成 │ │ │
│ │ │ 编号地图) │ │ │
│ │ └────────────────┘ │ │
│ └──────────┬───────────┘ │
└────────────────────────────────────────────┼──────────────┘
│ CDP(cdp-use)
▼
┌──────────────────┐
│ Chromium │
└──────────────────┘对比图 1-3,多出来的是两个盒子:
- DOM Service:负责把浏览器里那棵几千节点的 DOM 树,压缩成 LLM 读得懂、读得起的东西(L1 感知层详述)。
- Agent:负责"想"。它拿着页面状态和历史记忆去问 LLM,LLM 回答"下一步做什么",然后执行、再观察——这个循环是 L6 意图层的核心。
这一层的关键认知:Browser Use 并没有发明新的浏览器控制方式,它用的还是 CDP。它真正的创新在上面两层。驱动层是共通的地基,不是差异点。
1.1.4 这一层没做好会怎样
| 症状 | 根因 | 排查方向 |
|---|---|---|
ModuleNotFoundError: No module named 'playwright' | Python 包没装,或装到了另一个解释器 | 见2.11 排错手册 |
Executable doesn't exist at ...chrome.exe | Python 包装了,但浏览器二进制没下 | python -m playwright install chromium |
| 脚本跑完进程不退出 | 没用 with,driver 进程泄漏 | 检查 sync_playwright() 的作用域 |
1.2 L1 感知层:机器怎么"看见"页面
1.2.1 人话:页面有三种"看法"
你看网页,看到的是渲染后的画面。但程序有三条完全不同的感知通道,代价和精度天差地别:
图 1-5 · 页面感知的三条通道
同一个"登录"按钮
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ ① DOM 树 │ │ ② 无障碍树 │ │ ③ 像素截图 │
│ │ │ (AX Tree) │ │ │
│ <button │ │ role: button │ │ ██████████ │
│ class="a3f" │ │ name: "登录" │ │ ██ 登录 ██ │
│ data-testid │ │ focusable:yes │ │ ██████████ │
│ ="login"> │ │ disabled: no │ │ │
│ 登录 │ │ │ │ (一张 PNG) │
│ </button> │ │ │ │ │
├───────────────┤ ├───────────────┤ ├───────────────┤
│ 精度:最高 │ │ 精度:语义级 │ │ 精度:视觉级 │
│ 体积:大 │ │ 体积:中 │ │ 体积:巨大 │
│ 成本:~0 │ │ 成本:~0 │ │ 成本:贵 │
│ │ │ │ │ (视觉 token) │
│ 适合:程序 │ │ 适合:程序+AI │ │ 适合:AI 兜底 │
└───────────────┘ └───────────────┘ └───────────────┘- DOM 树:浏览器解析 HTML 后的完整对象树。什么都有,包括一堆你不关心的
<div><span>嵌套。 - 无障碍树(Accessibility Tree):浏览器为读屏软件(盲人用的辅助工具)生成的语义化摘要。它只保留"这是个按钮、它叫登录、它现在可点"这类信息,天然过滤掉了纯装饰元素。这棵树是个被严重低估的宝藏——它本来就是给"看不见画面的用户"设计的,而 LLM 恰好也看不见画面。
- 像素:截图。信息最全(能看出颜色、布局、遮挡),但对 LLM 来说一张 1280×900 的截图动辄消耗 1000+ 视觉 token,而且模型还得"认字",容易看错。
1.2.2 Playwright 的感知:你自己决定看什么
Playwright 不替你"理解"页面,它给你一组精确的读取 API,看什么、看多少,完全由你决定:
page.title() # 读标题
page.content() # 读整页 HTML 源码
locator.inner_text() # 读某元素的可见文本
locator.get_attribute("data-stock") # 读某元素的属性
locator.count() # 数有多少个匹配
page.accessibility.snapshot() # 读无障碍树快照
page.screenshot() # 截图这种设计的代价和收益都很明确:
- 收益:零冗余。你要 24 本书的价格,就只读这 24 个价格节点,不多传一个字节。这也是脚本方案能做到"扫 24 本书只花 15 秒、成本 ¥0"的根本原因。
- 代价:你必须事先知道要读什么、在哪读。页面结构一变,你的读取代码就得跟着改。
1.2.3 Browser Use 的感知:把 DOM 提纯成"编号地图"
LLM 的处境和你完全不同:它事先什么都不知道,你也没法把 30 万字符的原始 HTML 塞给它(贵、且它会淹死在噪音里)。
所以 Browser Use 做了一件核心工作——DOM 提纯:
图 1-6 · DOM 提纯流水线
┌─────────────────────────────────────────────────────────────┐
│ ① 原始 DOM │
│ 3000+ 节点,约 300KB HTML │
│ <html><head><style>...500 行 CSS...</style></head> │
│ <body><div class="wrap"><div class="grid"> │
│ <div class="card card-8f2a1c" data-testid="book-card">│
│ <div class="title"><a href="/book/1"…>算法导论</a> │
│ … │
└──────────────────────┬──────────────────────────────────────┘
│ 注入 JS,遍历 DOM 树
▼
┌─────────────────────────────────────────────────────────────┐
│ ② 逐节点判定:这个节点值得给 LLM 看吗? │
│ │
│ 可见吗? display:none / visibility:hidden → 丢弃 │
│ 在视口内吗? 滚动区外的标记为 out-of-viewport │
│ 可交互吗? a / button / input / select / [onclick] / │
│ [role=button] / contenteditable → 保留 │
│ 有文本吗? 纯文本节点 → 保留为上下文 │
│ 被遮挡吗? z-index 更高的元素盖住它 → 标记 │
└──────────────────────┬──────────────────────────────────────┘
│ 给每个"可交互元素"编号
▼
┌─────────────────────────────────────────────────────────────┐
│ ③ 编号地图(真正喂给 LLM 的东西,约 2~5KB) │
│ │
│ [1]<a href="/">首页</a> │
│ [2]<a href="/products">全部图书</a> │
│ [3]<button>同意并继续</button> │
│ [4]<input placeholder="搜索书名或作者"/> │
│ [5]<select>全部分类</select> │
│ [6]<a href="/book/1">算法导论(第4版)</a> │
│ ¥188.00 ¥158.00 评分 4.8 · 921 条评价 │
│ ... │
│ [23]<a>下一页 →</a> │
└──────────────────────┬──────────────────────────────────────┘
│ LLM 回答
▼
{ "action": [ {"click_element_by_index": {"index": 3}} ] }
│
▼ 框架查表:3 号 → 那个真实 DOM 节点 → CDP 派发点击这套设计里藏着三个精妙之处:
- 编号取代了选择器。LLM 不需要生成 CSS/XPath(生成的往往还是错的),它只需要说一个数字。数字到真实节点的映射由框架维护,不可能"选不中"。
- 体积压缩了 50\~100 倍。300KB → 3KB,token 成本直接降两个数量级。
- 过滤即降噪。丢掉 CSS、脚本、装饰性 div,模型的注意力只落在能操作的东西上,决策准确率显著提升。
1.2.4 要不要开视觉:一笔要算清的账
Browser Use 的 use_vision 参数控制"要不要额外附一张截图给 LLM"。
图 1-7 · 开不开视觉的决策
页面能不能靠文本表达清楚?
│
┌─────────────────┴──────────────────┐
│ 能 │ 不能
▼ ▼
use_vision=False use_vision=True
┌──────────────────┐ ┌──────────────────┐
│ 只传编号地图 │ │ 编号地图 + 截图 │
│ ~2K token/步 │ │ ~3K token/步 + │
│ │ │ ~1.2K 视觉token │
│ 成本基准 1× │ │ 成本约 2~3× │
└──────────────────┘ └──────────────────┘
适用: 适用:
· 常规表单、列表、后台 · Canvas 画布、图表
· 结构规整的电商站 · 富文本编辑器
· 本项目的 BookNest · 拖拽/绘图类交互
· 纯图片按钮、验证码
· 靠颜色区分状态的 UI第二章 Step 5 里我们设了 use_vision=False,原因就写在代码注释里:本地靶场结构清晰、全是标准 HTML 元素,编号地图已经足够描述一切,开视觉纯属浪费钱。
1.2.5 这一层没做好会怎样
| 症状 | 根因 |
|---|---|
| Agent 说"我没找到搜索框" | 元素在视口外没被收录,或被浮层遮挡后判为不可见 → 先滚动/先关浮层 |
| Agent 点错了相似的按钮 | 编号地图里两个元素文本一样,缺少区分上下文 → 考虑开 vision 或补充任务描述 |
| token 消耗爆炸 | 页面元素太多(比如一页 500 行表格)→ 先用脚本翻页/筛选,再交给 AI |
1.3 L2 定位层:怎么准确指到"那个按钮"
1.3.1 人话:这是脚本最容易挂的地方
假设页面上有这么一段:
<div class="card card-8f2a1c" data-testid="book-card" data-book-id="1">
<div class="title"><a href="/book/1" data-testid="book-link">算法导论(第4版)</a></div>
</div>你想点这本书的链接,有多少种写法?它们的寿命天差地别:
图 1-8 · 定位器演进阶梯(从脆到稳)
脆
▲ ┌────────────────────────────────────────────────────────────────┐
│ │ ① 绝对 XPath │
│ │ /html/body/div[2]/div[1]/div[3]/div[1]/a │
│ │ 任何位置变动 → 立即失效。寿命:一次改版 │
│ ├────────────────────────────────────────────────────────────────┤
│ │ ② 样式 class │
│ │ .card-8f2a1c > .title > a │
│ │ class 是给 CSS 用的,前端随时会改;现代框架还会生成随机 class │
│ │ (本项目靶场就故意做了这个:每次刷新 class 都不同) │
│ │ 寿命:一次样式重构,甚至一次刷新 │
│ ├────────────────────────────────────────────────────────────────┤
│ │ ③ 结构化 CSS │
│ │ div.card:nth-child(3) a │
│ │ 比 ① 稳一点,但仍绑死 DOM 结构。寿命:一次布局调整 │
│ ├────────────────────────────────────────────────────────────────┤
│ │ ④ 可见文本 │
│ │ get_by_text("算法导论(第4版)") │
│ │ 文案改了/做了国际化 → 挂。但结构重构不影响它 │
│ │ 寿命:一次文案调整 │
│ ├────────────────────────────────────────────────────────────────┤
│ │ ⑤ 语义角色 + 无障碍名称 │
│ │ get_by_role("link", name="算法导论(第4版)") │
│ │ 基于无障碍树,反映"这东西是什么、叫什么" │
│ │ 除非产品语义变了,否则不挂。寿命:长 │
│ ├────────────────────────────────────────────────────────────────┤
│ │ ⑥ 测试专用属性 │
│ │ get_by_test_id("book-link") → data-testid="book-link" │
│ │ 前端和自动化之间的显式契约。它存在的唯一理由就是被自动化用 │
│ │ 寿命:最长(前提是团队有共识不乱删) │
▼ └────────────────────────────────────────────────────────────────┘
稳这张图是整个第一章最实用的一张。 记住一个原则:定位器应该描述"这个元素是什么",而不是"它长在哪、长什么样"。
1.3.2 Playwright 的 Locator:两个反直觉但重要的设计
loc = page.get_by_test_id("book-card") # 这一行没有去页面上找任何东西
print(loc.count()) # 这一行才真正去查
loc.first.click() # 这一行会重新查一次设计一:Locator 是惰性的(lazy)
page.get_by_test_id(...) 返回的不是"找到的元素",而是一条查找规则。每次你调用 .click() / .inner_text() / .count(),它都会重新执行这条规则。
为什么这么设计?因为现代网页的 DOM 一直在变。React 一次重渲染,你三秒前拿到的元素引用就变成了游离节点(detached),再操作它就报 Element is not attached to the DOM。惰性求值让每次操作都基于最新的页面状态。
设计二:严格模式(strict mode)
page.get_by_test_id("book-card").click()
# ✗ 报错:strict mode violation: resolved to 6 elements如果一条规则匹配到多个元素,Playwright 拒绝执行并报错,而不是"默默点第一个"。这个设计救过无数人——"默默点第一个"会让你的脚本在数据变化时静默地做错事,而报错至少能让你立刻发现。
要么明确指定:
page.get_by_test_id("book-card").first.click() # 明确要第一个
page.get_by_test_id("book-card").nth(2).click() # 明确要第三个
page.get_by_test_id("book-card").filter(has_text="算法导论").click() # 用条件缩小语义定位器五件套(优先按这个顺序用):
page.get_by_test_id("login-submit") # 1. 测试属性,最稳
page.get_by_role("button", name="登录") # 2. 语义角色
page.get_by_label("用户名") # 3. 表单 label 关联
page.get_by_placeholder("搜索书名或作者") # 4. 占位符
page.get_by_text("共 24 本") # 5. 可见文本,兜底1.3.3 Browser Use 的定位:把问题消灭掉
Agent 方案里没有定位器这个概念。LLM 拿到的是编号地图,它输出的是 click_element_by_index(6),框架查表找到对应节点。
这带来一个有意思的性质变化:
图 1-9 · 两种定位方式的失效模式完全不同
选择器方案(Playwright) 编号方案(Browser Use)
───────────────────────── ─────────────────────────
定位规则:写死在代码里 定位规则:每一步重新生成
稳定性: 同一页面 100% 一致 稳定性: 同一页面也可能选不同的
失效方式:改版 → 报错(响亮地挂) 失效方式:改版 → 可能仍然成功
也可能悄悄点错(沉默地错)
调试成本:低(报错信息精确到选择器) 调试成本:高(要读 LLM 的推理轨迹)
成本: ¥0 成本: 每一步一次 LLM 调用注意最后那行对比:脚本方案失败得很响亮,Agent 方案可能失败得很安静。这不是"谁更好"的问题,而是"你的业务能不能容忍安静的错误"。给财务对账做自动化,你要的一定是响亮地挂;给一次性的调研抓取,安静地容错反而更省事。
1.3.4 这一层没做好会怎样
| 症状 | 根因 | 正确做法 |
|---|---|---|
| 昨天好好的,今天全挂 | 用了 class / 绝对 XPath | 换 data-testid 或 get_by_role |
strict mode violation | 匹配到多个 | 加 .first / .nth(i) / .filter() |
Element is not attached to the DOM | 缓存了元素引用 | 用 Locator,不要存 ElementHandle |
1.4 L3 操作层:为什么"点击"远不止派发一个事件
1.4.1 人话:假点击和真点击
初学者常写这样的代码:
page.evaluate("document.querySelector('#btn').click()") # 用 JS 直接调 click()这能"点"上,但它是假点击。区别在哪?
JS 的 element.click() | 真实用户点击 | |
|---|---|---|
事件的 isTrusted | false | true |
| 是否触发 mousemove/mousedown/mouseup | 否,只有 click | 是,完整序列 |
| 元素被遮挡时 | 照样触发 | 点到的是遮挡物 |
| 元素不可见/disabled 时 | 照样触发 | 点不动 |
| 会触发 hover 效果吗 | 不会 | 会 |
很多网站的前端逻辑、埋点、风控都会检查 isTrusted,或者依赖 mousedown → mouseup → click 的完整序列。假点击在这些站上会静默失效——代码不报错,但业务没发生。
Playwright 的 locator.click() 走的是 CDP 的 Input.dispatchMouseEvent,由浏览器内核产生事件,isTrusted === true,和真人点击在浏览器看来毫无区别。
1.4.2 actionability:点击之前的五道体检
真点击有个前提:元素得真的能被点到。Playwright 在执行 click() 之前,会自动做一组检查,全部通过才动手,否则不断重试直到超时。
图 1-10 · click() 的 actionability 检查流程
locator.click()
│
▼
┌─────────────────────────────────────┐
│ 重试循环(默认最长 30 秒) │◀──────────────┐
└─────────────────┬───────────────────┘ │
▼ │
① 元素存在于 DOM 吗? ─── 否 ──────────▶│
│ 是 │
▼ │
② Visible? │
有非空 bounding box │
且 visibility != hidden ─── 否 ──────────▶│
│ 是 │
▼ │
③ Stable? │
连续 2 帧动画位置不变 ─── 否 ──────────▶│
(防止点到正在滑入的弹窗) │
│ 是 │
▼ │
④ Enabled? │
没有 disabled 属性 ─── 否 ──────────▶│
│ 是 │
▼ │
⑤ Receives Events? │
在元素中心点做命中测试: │
document.elementFromPoint(x,y) │
返回的是它自己(或其后代)吗? │
─── 否 ──────────▶│
│ 是 (被遮挡了) │
▼ │
滚动进视口 → CDP 派发真实鼠标事件 │
│ │
▼ 超时 30s ─┘
点击完成 │
▼
抛出 TimeoutError,并附上诊断:
"<div class='consent-backdrop'>
intercepts pointer events"第 ⑤ 项是本项目 Step 2 陷阱 1 的原理。当 Cookie 浮层盖在商品链接上时:
错误做法:page.get_by_test_id("book-link").first.click()
↓
命中测试发现 elementFromPoint 返回的是 consent-backdrop
↓
Playwright 重试 3 秒后报错,并明确告诉你:
<div class="consent-backdrop"> intercepts pointer events这条报错信息是 Playwright 最值钱的设计之一。Selenium 时代你只会得到一个 ElementClickInterceptedException,得自己猜是谁挡的;Playwright 直接把"凶手"的 HTML 打给你。
第二章 Step 2 的代码里,我专门写了几行把这条诊断从多行异常里捞出来打印,就是为了让你亲眼看到它。
1.4.3 Browser Use 的动作集与自定义扩展
Agent 侧的操作被封装成一组动作(action),LLM 通过输出 JSON 来调用它们。内置的大致有:
导航类:go_to_url、go_back、open_tab、switch_tab、close_tab
交互类:click_element_by_index、input_text、select_dropdown_option、
scroll、send_keys、drag_drop、upload_file
读取类:extract_structured_data、get_dropdown_options
控制类:wait、done(宣告任务完成并返回结果)关键扩展点:你可以注册自己的动作。 这是把业务能力注入 Agent 的正门:
from browser_use import Tools, ActionResult
tools = Tools()
@tools.action(description="记录一条中间发现到本地日志,便于人工复核 AI 的推理过程")
def note_finding(message: str) -> str:
# 函数签名(参数名、类型)会被自动转成 JSON Schema 塞进 prompt,
# 所以 LLM 知道"有这么个工具、要传什么参数"。
# description 就是给模型看的说明书——写得越清楚,它用得越准。
with log_path.open("a", encoding="utf-8") as f:
f.write(f"[{now()}] {message}\n")
return f"已记录:{message}" # 返回值会作为观察结果回到下一轮 prompt这段代码就是第二章 Step 5 里 build_tools() 的真身。理解它的意义:Agent 不只能操作浏览器,还能调用你给它的任何 Python 函数——查数据库、调内部 API、写文件,都可以变成它的一个动作。
1.5 L4 同步层:什么时候可以动手(自动化最大的坑)
1.5.1 人话:网页不是一次性画完的
初学者写自动化,80% 的诡异 bug 都来自这一层:代码跑得比页面快。
页面加载其实有好几个阶段:
时间轴 ──────────────────────────────────────────────────────▶
HTML 到达 DOM 构建完 CSS/图片加载完 JS 发起 XHR 数据回来渲染
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
────┼──────────────┼─────────────┼──────────────┼──────────────┼───
│ │ │
domcontentloaded load 真正"可用"
│ │ │
└─── 很多人在这里就开始操作 ───┘ ← 但数据还没来!本项目靶场的陷阱 4 精确复刻了这个问题:详情页的库存字段,是 JS 在 800 毫秒后才注入的。
<span data-testid="stock" data-loaded="false">加载中…</span>
<span data-testid="stock" data-loaded="true" data-stock="32">有货,剩余 32 件</span>如果你在 domcontentloaded 之后立刻读,拿到的是字符串 "加载中…"——代码不报错,数据是错的。这类 bug 最难查。
1.5.2 三代等待策略
图 1-11 · 等待策略的演进
第一代:固定睡眠
─────────────────────────────────────────────
page.click("#submit")
time.sleep(3) ← 拍脑袋定的 3 秒
page.inner_text("#result")
问题:网快时白等 2.9 秒(100 个元素 = 白等 5 分钟)
网慢时 3 秒不够,照样挂
本质:你在赌,而不是在判断
⚠ 这是自动化脚本的头号坏味道
第二代:显式等待(Selenium WebDriverWait)
─────────────────────────────────────────────
WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.ID, "result")))
driver.find_element(By.ID, "result").text
改进:条件满足就往下走,不浪费时间
问题:每个操作前都得手写一遍,代码里 60% 是等待样板
第三代:自动等待(Playwright auto-wait)
─────────────────────────────────────────────
page.get_by_test_id("result").inner_text()
↑ 就这一行。等待是内建的,不用写
原理:每个操作内部都是"重试循环 + actionability 检查"
条件不满足就等下一帧再试,直到超时才报错1.5.3 但 auto-wait 不是万能的:三种必须手动等的情况
auto-wait 解决的是"元素什么时候可用",它管不了"数据什么时候正确"。
# ── 情况 A:等属性变化(最精确,本项目用的就是这招)──────────────
page.wait_for_selector('[data-testid="stock"][data-loaded="true"]', timeout=8000)
# 这个选择器要求 data-loaded 必须等于 "true"。
# 元素虽然一开始就存在,但属性不满足 → 继续等 → JS 改了属性 → 立刻返回
# 精确、无浪费。前提是前端愿意提供这样的"完成标记"
# ── 情况 B:等文本不再是加载态(不依赖前端配合)──────────────────
from playwright.sync_api import expect
expect(page.get_by_test_id("stock")).not_to_have_text("加载中…", timeout=5000)
# expect() 是 web-first 断言:内置轮询,条件不满足就重试
# 和普通 assert 的区别:assert 是"此刻是不是",expect 是"一段时间内会不会变成"
# ── 情况 C:等网络请求完成(数据驱动的页面)──────────────────────
with page.expect_response("**/api/stock/*") as resp_info:
page.get_by_test_id("refresh").click()
print(resp_info.value.json())
# 直接等那个 API 回来,比等 DOM 更贴近数据源头图 1-12 · 该用哪种等待
需要等什么?
│
┌───────────────┼────────────────┬─────────────────┐
▼ ▼ ▼ ▼
元素出现/ 元素的属性 元素的文本 后端接口
可点击 变了 变了 返回
│ │ │ │
▼ ▼ ▼ ▼
什么都不用写 wait_for_selector expect(...) expect_response
(auto-wait) 带属性条件 .to_have_text ()
.not_to_have_...
✗ 永远不要用 time.sleep()1.5.4 Agent 的时序处理:用"观察-重试"闭环替代等待
Agent 没有显式的等待 API,它靠循环本身来处理时序:
第 N 步: 观察页面 → 看到"库存:加载中…"
LLM 判断:数据还没好
决策:调用 wait 动作,或直接进入下一轮观察
第 N+1 步:观察页面 → 看到"库存:有货,剩余 32 件"
LLM 判断:数据好了
决策:记录数据,继续下一个目标优点:不需要你预先知道"哪里会异步"。
代价:每多一轮观察就多一次 LLM 调用(时间 +2\~5 秒,成本 +N token)。而 Playwright 的 wait_for_selector 只花 800 毫秒、¥0。
这就是为什么第二章 Step 5 的任务描述里,我明确把异步这件事写进了 prompt:
【关于库存】详情页的"库存"字段是异步加载的,刚打开时显示"加载中…",
请等它变成"有货,剩余 N 件"或"暂时缺货"之后再读取。给 Agent 写 prompt 的一条重要经验:把你已知的页面"坑"直接告诉它,能省掉大量试错步数。Prompt 里的先验知识,就是给 Agent 省钱。
1.6 L5 状态层:身份、会话与隔离
1.6.1 BrowserContext:一次性的"浏览器身份"
人话:Chrome 的"无痕窗口"就是一个 context。它有自己的 cookie、localStorage、缓存,和主窗口互不干扰。
图 1-13 · Browser / Context / Page 三级模型
┌─────────────────────────────────────────────────────────┐
│ Browser 进程(启动一次约 300~800ms,重) │
│ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ BrowserContext A │ │ BrowserContext B │ │
│ │ (开销 ~10ms,轻) │ │ │ │
│ │ │ │ │ │
│ │ cookies: 用户甲 │ │ cookies: 用户乙 │ │
│ │ localStorage: … │ │ localStorage: … │ │
│ │ 缓存: 独立 │ │ 缓存: 独立 │ │
│ │ │ │ │ │
│ │ ┌──────┐ ┌──────┐ │ │ ┌──────┐ │ │
│ │ │Page 1│ │Page 2│ │ │ │Page 1│ │ │
│ │ └──────┘ └──────┘ │ │ └──────┘ │ │
│ │ 这两个标签页共享 │ │ │ │
│ │ 同一份登录态 │ │ │ │
│ └────────────────────┘ └────────────────────┘ │
│ ↑ │
│ └── 两个 context 完全隔离: │
│ A 登录了,B 依然是未登录状态 │
└─────────────────────────────────────────────────────────┘这个模型的实用价值:
- 测试隔离。每个测试用例开一个新 context,天然干净,不用写清理代码。开 context 只要 10 毫秒,开浏览器要几百毫秒——这就是 Playwright 跑得比 Selenium 快的关键之一。
- 多账号并发。想同时模拟买家和卖家?开两个 context 就行,不用起两个浏览器。
- 配置粒度。视口大小、语言、时区、地理位置、权限,都是 context 级别的设置。
context = browser.new_context(
viewport={"width": 1280, "height": 900}, # 视口尺寸(影响响应式布局和"是否在视口内")
locale="zh-CN", # 语言,影响 Accept-Language 和页面文案
timezone_id="Asia/Shanghai", # 时区,影响页面里的时间显示
)1.6.2 storage\_state:把登录态存成一个 JSON 文件
人话:登录一次,把"登录凭证"导出成文件,以后所有脚本直接加载这个文件,就不用再登录了。
图 1-14 · storage\_state 的生命周期
【第一次运行】
打开登录页 → 填表单 → 提交 → 服务端 Set-Cookie: bn_session=xxx
│
▼
ctx.storage_state(path="auth_state.json")
│
▼
┌──────────────────────────────────┐
│ auth_state.json │
│ { │
│ "cookies": [ │
│ {"name":"bn_session", │
│ "value":"xxx", │
│ "domain":"127.0.0.1", │
│ "expires": 1786...} │
│ ], │
│ "origins": [ │
│ {"origin":"http://…", │
│ "localStorage":[…]} │
│ ] │
│ } │
└──────────────────────────────────┘
【后续每次运行】
browser.new_context(storage_state="auth_state.json")
│
▼
context 一创建就带着 cookie
│
▼
直接 goto 任何页面都是登录态 ← 省掉整个登录流程为什么这个技巧这么重要? 三个理由,一个比一个现实:
- 省时间。一次登录 2\~4 秒。跑 100 个测试用例,就是省下 5 分钟。
- 降风控。频繁登录会触发验证码、短信二次验证、账号异常告警。真实项目里这是硬伤。
- 可分发。CI 流水线里,可以由一个前置任务生成
auth_state.json,后面几十个并行任务共享它。
要注意的两件事:
- 这个文件等同于账号密码,绝不能提交到 Git(本项目把它放在
output/下,是产物目录)。 - 会话是有有效期的(本项目靶场设的是 3600 秒)。生产脚本应该做过期检测:加载后先访问一个需登录的页面,检查是否被重定向到登录页,是就重新登录。第二章 Step 4 的
ensure_auth()做的就是这个兜底。
1.6.3 Agent 侧怎么处理登录
三种做法,成本递减:
做法 A:把账号密码写进 prompt,让 Agent 自己登录
✓ 简单,零额外代码
✗ 每次跑都要花 4~6 个 LLM 步骤去完成登录(贵、慢)
✗ 凭证进入了 prompt,会被发到 LLM 服务商(有合规风险)
→ 第二章 Step 5 用的是这个,因为它是教学演示
做法 B:先用 Playwright 登录拿到 storage_state,再让 Agent 复用
✓ 登录零 LLM 成本,凭证不出本地
✓ Agent 一上来就是登录态,直接干正事
→ 生产环境推荐
做法 C:用已登录的真实浏览器(连接本地 Chrome 的调试端口)
✓ 连 storage_state 都不用管,直接用你自己的浏览器
✗ 无法并发、无法在服务器上跑
→ 适合本地做一次性的调研任务1.7 L6 意图层:从"告诉你点哪"到"告诉你要什么"
这一层是 AI 带来的真正增量。前面 L0\~L5,传统工具已经做得很好了。
1.7.1 两种编程范式的根本区别
同一个业务目标,两种表达:
# ══ 指令式(imperative):我告诉你每一步怎么做 ══════════════════
page.goto("http://127.0.0.1:8899/products")
page.get_by_test_id("accept-cookies").click()
page.get_by_test_id("username").fill("demo")
page.get_by_test_id("password").fill("demo1234")
page.get_by_test_id("login-submit").click()
cards = page.get_by_test_id("book-card")
for i in range(cards.count()):
list_price = money(cards.nth(i).get_by_test_id("list-price").inner_text())
...
# 特点:每一行都是确定的。你必须事先知道页面长什么样。
# ══ 目标式(declarative):我告诉你我要什么 ═══════════════════
TASK = """
在 http://127.0.0.1:8899 上,用 demo/demo1234 登录,
逐页浏览全部图书,找出会员折扣≥15%、评分≥4.5、且有货的书。
库存要进详情页看,是异步加载的,等它加载完再读。
"""
agent = Agent(task=TASK, llm=llm)
await agent.run()
# 特点:没有一个选择器。页面长什么样,Agent 自己去看。转换的本质:你把"怎么做"的决策权,从代码里交给了运行时的模型。
代价和收益同样明确:
| 指令式 | 目标式 | |
|---|---|---|
| 谁做决策 | 你(编码时) | LLM(运行时) |
| 页面必须事先已知吗 | 是 | 否 |
| 同样输入 → 同样输出 | 保证 | 不保证 |
| 单次成本 | ¥0 | 每步一次 LLM 调用 |
| 改需求的成本 | 改代码 | 改一句话 |
1.7.2 Agent Loop:拆解 AI 的"想-做-看"循环
图 1-15 · Agent 执行循环(这是 Browser Use 的心脏)
TASK(自然语言目标)
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 每一步(Step) │
│ │
│ ① OBSERVE ── 采集浏览器状态 │
│ · 当前 URL、标题、打开了哪些标签页 │
│ · 可交互元素编号地图(L1 那张提纯后的表) │
│ · 可选:截图(use_vision=True 时) │
│ │ │
│ ▼ │
│ ② BUILD PROMPT ── 组装上下文 │
│ ┌────────────────────────────────────────┐ │
│ │ system: 你是浏览器操作 Agent,可用动作… │ │
│ │ user: 【任务】<TASK> │ │
│ │ history: 第1步做了X,结果Y │ │
│ │ 第2步做了Z,结果W … │ │
│ │ state: 当前页面 = <编号地图> │ │
│ └────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ③ THINK ── 调用 LLM,拿回结构化决策 │
│ { │
│ "current_state": { │
│ "evaluation_previous_goal": "成功,已关闭浮层", │
│ "memory": "已扫描第1页6本,还剩3页", │
│ "next_goal": "点击下一页" │
│ }, │
│ "action": [ │
│ {"click_element_by_index": {"index": 23}} │
│ ] │
│ } │
│ │ │
│ ▼ │
│ ④ ACT ── 框架执行动作(编号 → 真实节点 → CDP) │
│ 一步可以返回多个动作,批量执行省 LLM 调用 │
│ │ │
│ ▼ │
│ ⑤ 是否调用了 done 动作? │
│ 否 ──────────────┐ 是 ──▶ 返回最终结果 │
└─────────────────────────┼──────────────────────────────────┘
│
└──▶ 回到 ①(步数 +1,上限 max_steps)三个值得注意的设计细节:
evaluation_previous_goal是自我纠错机制。每一步开头,模型要先评价"上一步干成了没"。如果它发现上一步点了没反应,就会在这一步换个策略。这是 Agent 能从失败中恢复的关键。memory是压缩后的工作记忆。完整历史会越来越长(token 爆炸),所以框架让模型自己维护一段简短的"我做到哪了"。action是数组,不是单个动作。模型可以一次返回[填用户名, 填密码, 点登录]三个动作,框架顺序执行。这直接把 3 次 LLM 调用压缩成 1 次,是最重要的成本优化。
1.7.3 结构化输出:让 AI 吐出数据,而不是散文
Agent 默认的最终输出是一段自然语言。但你要的是能进 Excel 的数据。
图 1-16 · 结构化输出契约
你定义 Pydantic 模型
┌────────────────────────────────────┐
│ class PickedBook(BaseModel): │
│ title: str = Field( │
│ description="图书完整书名") │
│ list_price: float = Field( │
│ description="定价,纯数字, │
│ 不带货币符号") │
│ ... │
│ │
│ class PickResult(BaseModel): │
│ picks: list[PickedBook] │
│ total_scanned: int │
│ summary: str │
└──────────────┬─────────────────────┘
│ output_model_schema=PickResult
▼
┌────────────────────────────────────┐
│ 框架把它转成 JSON Schema, │
│ 注入到 done 动作的参数定义里 │
│ │
│ ★ Field 的 description 会进 prompt │
│ → 写清楚 = 直接给模型下指令 │
│ → "纯数字,不带货币符号" 这句话 │
│ 能避免模型返回 "¥139.00" │
└──────────────┬─────────────────────┘
▼
┌────────────────────────────────────┐
│ LLM 调用 done 时必须按 schema 填 │
│ history.final_result() 拿到 JSON 串 │
└──────────────┬─────────────────────┘
▼
┌────────────────────────────────────┐
│ PickResult.model_validate_json(raw) │
│ → 类型不对就报错,不会静默污染下游 │
└────────────────────────────────────┘这是把 AI 接入工程系统的关键一环。没有这层契约,你拿到的是"我找到了 13 本书,其中人类简史很划算……"这样的段落,还得写正则去解析——那才是真正的噩梦。
1.7.4 成本模型:算清楚这笔账
Agent 方案的成本 = 步数 × 每步 token × 单价。
每步 token 估算(use_vision=False):
system prompt(动作说明书) ~1,200 token 固定
任务描述 ~400 token 固定
历史摘要 ~300~2,000 随步数增长
当前页面编号地图 ~800~3,000 随页面复杂度
─────────────────────────────────────────────
输入合计 ~2,700~6,600 token/步
输出(决策 JSON) ~200~500 token/步
本项目场景(24 本书、4 页、16 次详情页)的步数估算:
关浮层 1 + 登录 2 + 翻 4 页 4 + 进 16 次详情页 32 + 汇总 1 ≈ 40 步
→ 输入约 15 万 token,输出约 1.5 万 token
→ 按 gpt-4.1-mini 档位,单次运行约 ¥0.3~0.6
→ 耗时约 3~6 分钟
对照 Playwright 方案:
→ 0 token,¥0,15.78 秒(第二章有真实数据)结论不是"AI 太贵",而是:用 AI 去做"每次都一样的事",是在为不需要的灵活性反复付费。AI 的钱应该花在真正会变的地方——这正是下一层和第二章 Step 6 的主题。
1.7.5 控制 Agent 的四个旋钮
agent = Agent(
task=TASK,
llm=llm,
output_model_schema=PickResult, # ① 输出契约:强制结构化,见 1.7.3
use_vision=False, # ② 视觉开关:省一半 token,见 1.2.4
tools=build_tools(), # ③ 能力扩展:注入自定义动作,见 1.4.3
)
await agent.run(max_steps=40) # ④ 步数上限:防止死循环烧钱(必设!)max_steps 是成本熔断器。没有它,一个陷入循环的 Agent 能在半小时内烧掉你几十块钱。任何生产环境的 Agent 都必须设这个值。
1.8 L7 韧性层:页面改版之后还能不能跑
1.8.1 脆弱性从哪里来
自动化脚本的宿命:它依赖的东西,别人随时会改,而且不会通知你。
前端改了 class 名 → 样式选择器挂
前端调整了 DOM 层级 → 结构选择器挂
产品改了按钮文案 → 文本选择器挂
运营加了个新的活动弹窗 → 所有点击被拦截
后端改了接口返回结构 → 数据解析挂传统做法是"挂了再修"。但如果这个脚本每天凌晨自动跑,你会在早上九点收到一堆失败告警——修复成本高,且总是滞后。
1.8.2 分级降级:让定位器自己找退路
思路很朴素:同一个元素,准备多套定位方案,从最稳的开始试,挨个降级。
图 1-17 · 定位器四级自愈链
需要定位「全部图书」这个导航链接
│
▼
┌────────────────────────────────────────┐
│ L1 data-testid="nav-products" │
│ 最稳、最快、成本 ¥0 │
└──────────┬─────────────────────────────┘
│ 找到 → 直接用,结束
│ 没找到(前端把 testid 改名了)
▼
┌────────────────────────────────────────┐
│ L2 get_by_role("link", name="全部图书") │
│ 语义没变就还在,成本 ¥0 │
│ ★ 记录一条自愈日志 │
└──────────┬─────────────────────────────┘
│ 找到 → 用它,并告警"testid 该修了"
│ 没找到(连 aria 名称也变了)
▼
┌────────────────────────────────────────┐
│ L3 get_by_text("全部图书") │
│ 文案没变就还在,成本 ¥0 │
└──────────┬─────────────────────────────┘
│ 找到 → 用它
│ 没找到(改版幅度很大)
▼
┌────────────────────────────────────────┐
│ L4 交给 Browser Use Agent: │
│ "在当前页面找到进入图书列表的入口 │
│ 并点击它" │
│ 成本:一次 LLM 调用(约 ¥0.01) │
│ ★ 这是 AI 在这套架构里最合适的位置 │
└──────────┬─────────────────────────────┘
│ 还是不行
▼
记录失败 + 截图 + 告警,人工介入这个设计的精髓在于成本分布:99% 的情况在 L1 就结束了(¥0),只有真正出问题的那 1% 才会付出 LLM 的代价。你为"变化"付费,而不是为"日常"付费。
第二章 Step 6 的 ResilientLocator 类就是这个图的完整实现,而且它会统计每一级的命中次数——这些统计数据本身就是一份"技术债报告":L2/L3 命中越多,说明你的 testid 契约烂得越厉害。
1.8.3 可观测性:出事之后怎么查
自愈只能救一部分,剩下的靠"看得见"。
| 手段 | 工具 | 用途 |
|---|---|---|
| Trace 追踪 | context.tracing.start(screenshots=True, snapshots=True) | 录下每个操作的 DOM 快照、截图、网络请求。用 playwright show-trace trace.zip 打开,可以像看录像一样逐帧回放,还能在任意时刻检查当时的 DOM |
| 失败截图 | page.screenshot() | 最低成本的现场保留 |
| 录像 | new_context(record_video_dir=...) | 适合演示和复盘 |
| Agent 轨迹 | history.model_actions() / 自定义 note_finding 动作 | 看 LLM 每一步"想了什么、为什么这么决定" |
Trace Viewer 是 Playwright 最被低估的功能。CI 上一个偶发失败,本地怎么都复现不了——把 trace 下下来打开,你能直接看到失败那一刻页面的真实样子。这比看十遍日志都管用。
1.9 两条技术路线是怎么长出来的
理解了能力分层,再看历史就很清楚了:这两条线,一条在把 L0L5 打磨到极致,另一条在补 L6L7。
1.9.1 Playwright 线:把确定性做到极致
| 时间 | 事件 | 补的是哪层能力 |
|---|---|---|
| 2020-01 | Microsoft 开源 Playwright。核心团队来自 Google 的 Puppeteer 项目 | L0 驱动:从 WebDriver 转向 CDP |
| 2020-07 | 发布 Python / Java / .NET 客户端 | L0:确立"Node driver + 多语言薄壳"架构 |
| 2021-05 | 发布 @playwright/test 测试框架 | L4:expect() web-first 断言成为标配 |
| 2022-03 | Trace Viewer 正式可用 | L7 可观测性:时间旅行式调试 |
| 2022 年内 | 推出语义定位器 get_by_role / get_by_test_id 等 | L2 定位:官方明确"别用 CSS 选择器" |
| 2023-02 | UI Mode(可视化调试) | 降低学习门槛 |
| 2024-12 | Playwright MCP 发布 | L6:主动向 AI 开放能力 |
| 2026-07 | Python 版 1.62.0(需 Python ≥ 3.10) | 本项目使用版本 |
Playwright MCP 值得单独说。MCP(Model Context Protocol)是给 AI 提供工具的标准协议。Playwright MCP 把浏览器能力包装成 MCP 工具暴露给任意 LLM 客户端。
它最有意思的技术选择是:用无障碍树快照,而不是截图。
传统的 AI 操作浏览器:截图 → 视觉模型识别 → 猜坐标 → 点 (x, y)
↑ 贵、慢、会看错、分辨率一变就偏
Playwright MCP: 无障碍树 → 结构化文本 → 指定元素引用 → 点它
↑ 便宜、快、精确、不需要视觉模型这个选择和 Browser Use 的"DOM 提纯成编号地图",本质上是同一个思路的两种实现——都在说:别让 LLM 看图,让它读结构。
1.9.2 Browser Use 线:把意图理解补上
| 时间 | 事件 |
|---|---|
| 2024 年中 | Magnus Müller 与 Gregor Žunič(均来自 ETH Zurich)启动项目。出发点:让 LLM 能真正"用"浏览器,而不只是读网页 |
| 2024 下半年 | 开源,GitHub 迅速蹿升。核心创新:把 DOM 提纯成带编号的可交互元素地图,让 LLM 用"数字"而不是"选择器"来指定操作对象 |
| 2025-01 | 进入 Y Combinator W25 批次 |
| 2025-03-22 | 完成1700 万美元种子轮(Felicis 领投)。同期 WebVoyager 基准测试 SOTA 表现引发关注 |
| 2025 年内 | 引入 Tools 自定义动作、output_model_schema 结构化输出、多标签页管理、Cloud 版本 |
| 2026-07-28 | 0.13.7 发布。底层从依赖 Playwright 改为自研 cdp-use,直连 CDP(需 Python ≥ 3.11) |
为什么会出现 Browser Use? 因为 2023 年之后,一个新需求诞生了:
过去的自动化需求:「每天早上八点,把这个固定报表抓下来」
→ 页面固定、流程固定 → 脚本完胜
新出现的需求: 「帮我在这 20 个我从没见过的供应商网站上,
各自找到联系方式和报价」
→ 页面全不一样、没法预先写选择器 → 脚本无解Browser Use 解决的是第二类问题。它不是来替代脚本的,它是来做脚本做不了的事的。
1.9.3 两条线正在合流
图 1-18 · 技术演进时间线
2004 ──── Selenium 诞生(WebDriver 时代开启)
│
2017 ──── Puppeteer(Google):CDP 路线的先声
│
2020 ─┬── Playwright 开源 ────────────────────────────┐
│ │ L0~L5
2021 ─┼── @playwright/test,web-first 断言 │ 持续深化
│ │ 确定性
2022 ─┼── Trace Viewer + 语义定位器 │
│ │
2023 ─┼── UI Mode ChatGPT 引爆 LLM
│ │
2024 ─┼── 12月 Playwright MCP ◀──── 合流点 ────▶ Browser Use 开源
│ (向 AI 开放能力) (用 LLM 补 L6/L7)
│ │
2025 ─┼── MCP 生态爆发 YC W25 → $17M 种子轮
│ │
2026 ─┴── Playwright 1.62 Browser Use 0.13.7
(改用自研 cdp-use)
└───────────── 共同的终点 ─────────────┘
确定性骨架 + AI 处理模糊环节
(第二章 Step 6 的形态)看清楚这张图,你就明白了:行业已经给出答案了,不是二选一,是分层组合。
1.10 能力全景与工程选型
1.10.1 八层能力覆盖情况
不是打分表,是"这层能力主要靠谁提供":
| 层 | 能力 | Playwright | Browser Use | 说明 |
|---|---|---|---|---|
| L0 | 驱动浏览器 | ★ 主力 | ★ 主力 | 都走 CDP,无本质差异 |
| L1 | 感知页面 | 精确读取,零冗余 | DOM 提纯 + 可选视觉 | 前者要你知道读什么,后者替你理解 |
| L2 | 定位元素 | 语义定位器体系 | 编号地图,无需选择器 | 前者稳定可复现,后者适应未知页面 |
| L3 | 执行操作 | ★ 主力(trusted event) | 复用同一套底层能力 | Agent 的动作最终也是 CDP 调用 |
| L4 | 时序同步 | ★ 主力(auto-wait) | 靠观察-重试闭环 | 前者毫秒级零成本,后者每轮要花钱 |
| L5 | 会话状态 | ★ 主力(storage\_state) | 建议复用前者产物 | 这层没必要用 AI |
| L6 | 意图理解 | 不提供 | ★ 主力 | AI 的核心增量 |
| L7 | 变化适应 | 提供原料(多种定位器) | ★ 主力(兜底决策) | 两者配合最佳 |
读这张表的正确姿势:L0L5 是"体力活",交给 Playwright,快、稳、免费;L6L7 是"脑力活",交给 AI。把 AI 用在 L0\~L5,等于雇博士去搬砖。
1.10.2 两个真实约束:确定性预算 vs 变化预算
图 1-19 · 选型坐标系
页面
变化
频率
▲
高 │ ┌──────────────────┐ ┌──────────────────────┐
│ │ ③ 探索型任务 │ │ ④ 最难的象限 │
│ │ │ │ │
│ │ 一次性调研 │ │ 高频变化 + 要求准确 │
│ │ 竞品信息收集 │ │ → 混合架构 + 强监控 │
│ │ 未知站点批量抓取 │ │ → 人工抽检 + 双跑校验 │
│ │ │ │ │
│ │ → Browser Use │ │ → Step 6 的形态 │
│ └──────────────────┘ └──────────────────────┘
│
│ ┌──────────────────┐ ┌──────────────────────┐
│ │ ① 脚本的主场 │ │ ② 测试的主场 │
│ │ │ │ │
│ │ 固定报表抓取 │ │ 回归测试 │
│ │ 定时数据采集 │ │ 金额/权限校验 │
│ │ 内部系统操作 │ │ 合规流程 │
│ │ │ │ │
低 │ │ → Playwright │ │ → Playwright + │
│ └──────────────────┘ │ 严格断言 + Trace │
│ └──────────────────────┘
└──────────────────────────────────────────────────▶
低 高
对"结果必须正确"的要求一句话判断法:
问自己:这件事错了会怎样?
错了没人知道也没关系 → 用 AI 都行
错了会导致财务损失 / 合规问题 / 用户投诉 → 骨架必须是确定性脚本
1.10.3 三种落地形态
形态 A:纯脚本(Playwright only)
┌──────────────────────────────────────────┐
│ 登录 → 翻页 → 抓取 → 校验 → 出报告 │
│ 全部确定性代码 │
└──────────────────────────────────────────┘
适用:页面稳定、要求精确、高频运行
本书对应:Step 4(真实数据:15.78 秒,¥0,13 本命中)
形态 B:纯 Agent(Browser Use only)
┌──────────────────────────────────────────┐
│ 一段自然语言 → Agent 自主完成 → 结构化输出 │
└──────────────────────────────────────────┘
适用:页面未知、一次性任务、探索场景
本书对应:Step 5
形态 C:混合分层(★ 生产推荐)
┌──────────────────────────────────────────┐
│ 确定性骨架(≥95% 的步骤) │
│ 登录 · 翻页 · 抓取 · 断言 · 出报告 │
│ ──────────────────────────────────── │
│ ↓ 仅在两处引入 LLM ↓ │
│ ┌────────────────────────────────────┐ │
│ │ 模糊点 A:自然语言需求 → 结构化规则 │ │
│ │ "打折狠、口碑好、要现货" │ │
│ │ ↓ │ │
│ │ {折扣≥25, 评分≥4.5, 有货} │ │
│ ├────────────────────────────────────┤ │
│ │ 模糊点 B:定位器自愈兜底(L4 级) │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
适用:绝大多数生产场景
本书对应:Step 6(真实数据:10.80 秒,9 本命中,自愈 L1/L2/L3 各 1 次)1.10.4 记住这几句话
- AI 的价值不在"替你点按钮",而在"替你决定点哪个"。 点按钮这件事,Playwright 二十年前的前辈就做得很好了。
- 能用确定性解决的,绝不要用概率性。 每一次 LLM 调用都是一次掷骰子,只是骰子比较听话。
- 把 AI 放在流程的"接缝处",而不是"主干道"上。 接缝处 = 需求理解、异常兜底、结构未知的探索。
- 先能跑通,再谈智能。 一个跑不通的 Agent,不如一段能跑的烂脚本。
第一章到此结束。 你现在应该能回答这些问题:
- 为什么 Playwright 的 Python 包里有个 Node 程序?(1.1.2)
- 为什么不该用 class 做选择器?(1.3.1)
- 为什么
time.sleep(3)是坏味道?(1.5.2) - 为什么 Agent 要给元素编号而不是生成选择器?(1.2.3)
- 为什么生产环境推荐混合架构?(1.10.2)
接下来,把这些原理全部落到一个能跑的项目里。
觉得内容不错?我要