[系列]1️⃣技术篇-AI辅助GUI自动化实战指导书:AI辅助GUI操作与测试的能力体系

本文摘要AI 辅助 GUI 自动化实战指导书面向对象:完全没接触过浏览器自动化,或只写过几行 Selenium 的初学者配套代码:AIforGUIcode/(本机已完整跑通,文中所有执行结果均为真实输出)技术栈:Python 3.11+ · Playwright 1.62 · Browser Use 0.13最后更新:2026-08-04这本书怎么读全书只分两章,对应两个完全不同的目的: 章节解决什么问题...

image.png

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 操作与测试的能力体系

第二章 · 实战篇:BookNest 会员捡漏机器人



第一章 · 技术篇: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. 这层能力要解决什么问题?(人话)
  2. 技术上难在哪?(原理)
  3. 两条技术路线各自怎么解?(框架图)
  4. 这层没做好会出什么事故?(踩坑预警)

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 = 一个标签页
│  │ └────┘ │ │ └────┘ │     │
│  └────────┘ └────────┘     │
└────────────────────────────┘

为什么要这样设计? 三个工程考量:

  1. 一份核心逻辑,多语言复用。Playwright 支持 Python / Java / .NET / Node,如果每种语言都重写一遍 auto-wait 和 actionability,行为一定会漂移。现在四种语言的客户端都只是"薄壳",真正的逻辑只有 Node driver 这一份,所以跨语言行为完全一致
  2. 管道通信比 HTTP 快一个量级。Selenium 每个命令一次 HTTP 往返;Playwright 走本地管道,没有 TCP 握手、没有 HTTP 头解析。
  3. 浏览器版本被锁死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.exePython 包装了,但浏览器二进制没下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 派发点击

这套设计里藏着三个精妙之处

  1. 编号取代了选择器。LLM 不需要生成 CSS/XPath(生成的往往还是错的),它只需要说一个数字。数字到真实节点的映射由框架维护,不可能"选不中"
  2. 体积压缩了 50\~100 倍。300KB → 3KB,token 成本直接降两个数量级。
  3. 过滤即降噪。丢掉 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 / 绝对 XPathdata-testidget_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()真实用户点击
事件的 isTrustedfalsetrue
是否触发 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 依然是未登录状态                    │
└─────────────────────────────────────────────────────────┘

这个模型的实用价值

  1. 测试隔离。每个测试用例开一个新 context,天然干净,不用写清理代码。开 context 只要 10 毫秒,开浏览器要几百毫秒——这就是 Playwright 跑得比 Selenium 快的关键之一。
  2. 多账号并发。想同时模拟买家和卖家?开两个 context 就行,不用起两个浏览器。
  3. 配置粒度。视口大小、语言、时区、地理位置、权限,都是 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 任何页面都是登录态  ← 省掉整个登录流程

为什么这个技巧这么重要? 三个理由,一个比一个现实:

  1. 省时间。一次登录 2\~4 秒。跑 100 个测试用例,就是省下 5 分钟。
  2. 降风控。频繁登录会触发验证码、短信二次验证、账号异常告警。真实项目里这是硬伤。
  3. 可分发。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)

三个值得注意的设计细节

  1. evaluation_previous_goal 是自我纠错机制。每一步开头,模型要先评价"上一步干成了没"。如果它发现上一步点了没反应,就会在这一步换个策略。这是 Agent 能从失败中恢复的关键。
  2. memory 是压缩后的工作记忆。完整历史会越来越长(token 爆炸),所以框架让模型自己维护一段简短的"我做到哪了"。
  3. 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-01Microsoft 开源 Playwright。核心团队来自 Google 的 Puppeteer 项目L0 驱动:从 WebDriver 转向 CDP
2020-07发布 Python / Java / .NET 客户端L0:确立"Node driver + 多语言薄壳"架构
2021-05发布 @playwright/test 测试框架L4:expect() web-first 断言成为标配
2022-03Trace Viewer 正式可用L7 可观测性:时间旅行式调试
2022 年内推出语义定位器 get_by_role / get_by_test_idL2 定位:官方明确"别用 CSS 选择器"
2023-02UI Mode(可视化调试)降低学习门槛
2024-12Playwright MCP 发布L6:主动向 AI 开放能力
2026-07Python 版 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-280.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 八层能力覆盖情况

不是打分表,是"这层能力主要靠谁提供":

能力PlaywrightBrowser 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 记住这几句话

  1. AI 的价值不在"替你点按钮",而在"替你决定点哪个"。 点按钮这件事,Playwright 二十年前的前辈就做得很好了。
  2. 能用确定性解决的,绝不要用概率性。 每一次 LLM 调用都是一次掷骰子,只是骰子比较听话。
  3. 把 AI 放在流程的"接缝处",而不是"主干道"上。 接缝处 = 需求理解、异常兜底、结构未知的探索。
  4. 先能跑通,再谈智能。 一个跑不通的 Agent,不如一段能跑的烂脚本。

第一章到此结束。 你现在应该能回答这些问题:

  • 为什么 Playwright 的 Python 包里有个 Node 程序?(1.1.2)
  • 为什么不该用 class 做选择器?(1.3.1)
  • 为什么 time.sleep(3) 是坏味道?(1.5.2)
  • 为什么 Agent 要给元素编号而不是生成选择器?(1.2.3)
  • 为什么生产环境推荐混合架构?(1.10.2)

接下来,把这些原理全部落到一个能跑的项目里

觉得内容不错?我要

打赏杯咖啡或蜜雪冰城吧
微信扫一扫
微信赞赏码
支付宝扫一扫
支付宝赞赏码
评论 暂无评论
请登录后参与评论