跳到主要内容

数据截至 (上游 commit a675d6d61c41)

第 3 章 · 动作怎么落到真实浏览器

这章讲什么: 模型说"点 (720, 306)",到浏览器里真的发生一次点击,中间隔着两层代码和一堆真实世界的麻烦事。这章讲这两层各自扛什么。


3.1 两层结构:环境层与控制器层

Fara15Agent._dispatch_action
│ env.left_click(720, 306) ← 传进来的已是真实视口像素
v
┌──────────────────────────────┐
│ PlaywrightEnvironment │ ← 语义层:一个动作 = 一个方法
│ - 单标签页策略 │ 决定"新开的页要不要接管"
│ - 快捷键路由 │ Ctrl+R / Alt+← / Ctrl+F 特殊处理
└──────────┬───────────────────┘
│ controller.click_coords(page, x, y)
v
┌──────────────────────────────┐
│ PlaywrightController │ ← 韧性层:让操作在烂网页上也能活
│ - 页面就绪保证 │ 每次操作前重新注入脚本、等加载
│ - 崩溃自动恢复 │ TargetClosed / 隧道错误重试
│ - 弹窗捕获、下载处理 │
└──────────┬───────────────────┘
v
Playwright API → Chromium

先划清一条边界:环境层只认真实视口像素。模型报的 1000×1000 归一坐标在 agent 层就换算完了(_proc,src/fara/agents/fara/fara15_agent.py:495-503,第 2 章 §2.4 讲过),往下这两层再没有任何坐标空间的概念。

为什么要分两层?因为语义决策(这个新标签页该不该成为 agent 的世界)和韧性处理(页面死了怎么救)是两类完全不同的关注点,混在一起会很难读。

上面还有一层抽象基类 ComputerEnvironment / BrowserEnvironment(src/fara/environments/computer.py),用 @abstractmethod 钉死了动作接口。核心动作必须实现,扩展动作(triple_clickhscrollmiddle_click 等)默认抛 NotImplementedError 由子类选择性覆盖(computer.py:120-143)。


3.2 单标签页世界观

要解决的小问题

agent 只能看一张截图。用户点了个 target="_blank" 的链接,浏览器开了新标签页 —— agent 的"世界"该跟过去吗?旧的那页怎么办?

三段式解法

第一段:预防。 点击带 id 的元素前,先用 JS 把页面上所有 target="_blank" 属性摘掉(src/fara/environments/playwright/playwright_controller.py:562-570),让链接在原地打开。

第二段:捕获。 预防挡不住 window.open()。所以点击动作统一裹在 _click_with_popup_capture 里(playwright_controller.py:501-520):

# 示意,非源码
async with page.expect_event("popup", timeout=1000):
await do_click()
new_page = await popup_info.value # 抓到了新页
await self.on_new_page(new_page) # 给新页做同样的初始化
# 1 秒内没弹窗就当没发生,返回 None

第三段:接管。 控制器只负责"抓到了",要不要换世界由环境层决定(src/fara/environments/playwright/environment.py:367-386):

抓到 new_page

├─ self._page = new_page 总是切过去(浏览器也是这么表现的)

└─ single_tab_mode ?
├─ True(默认)──> 关掉旧页,保持"只有一页"的世界观
└─ False ───────> 留着旧页,给未来的多标签动作空间用

源码注释解释得很清楚:切过去是因为真实浏览器就是会把新标签页放到前台,agent 观察到的应该和人看到的一致(environment.py:370-375)。


3.3 快捷键路由:三条岔路

模型发一个 key(keys=[...]) 动作,环境层不是直接转发,而是先看这个组合键是不是"浏览器功能键"(environment.py:479-488):

key(keys=[...])
│ 归一化:CUA 键名 → Playwright 键名,单字母转小写
v _normalize_chord (environment.py:74-78)
┌─────────────────────┐
│ 是 Ctrl+F 吗? │──是──> 注入 find_overlay.js,页内画一个假的查找框
└────────┬────────────┘

v
┌─────────────────────┐
│ 在浏览器功能表里吗? │──是──> 调 self.refresh() / go_back() / forward()
└────────┬────────────┘ _BROWSER_CHROME_DISPATCH (environment.py:64-70)

v
转发给 controller.keypress() → 真的按键给页面

为什么需要这一层路由

因为 Playwright 的 keyboard.press 只作用于页面,碰不到浏览器 chrome。你按 Ctrl+R,页面收到一个键盘事件,但浏览器不会刷新。所以这些键必须被拦下来,翻译成对应的 Playwright API 调用。

功能键映射表只有五条(environment.py:64-70):Ctrl+R / Ctrl+Shift+R / F5 → 刷新,Alt+← → 后退,Alt+→ → 前进。

Ctrl+F 覆盖层:一个很妙的伪造

浏览器原生的查找栏同样够不着。Fara 的做法是在页面里用 JS 画一个长得像查找栏的东西(src/fara/environments/playwright/find_overlay.js)。

文件头的注释把设计意图讲得很清楚(find_overlay.js:1-5):输入框自动获得焦点,这样 agent 接下来的 type() + key(["Enter"]) 会自然流进去。也就是说,agent 不需要知道这是个假的查找栏 —— 它按人的习惯操作,行为就对了。

覆盖层支持的交互:Enter 遍历文本节点高亮所有匹配并滚到当前项、显示 N/M 计数、重复 Enter 循环前进、Esc 关闭并清除高亮(find_overlay.js:193-200)。它还给按钮加了 title 提示 —— 虽然 agent 看不到 tooltip,但这说明作者是按"给人用"的标准做的。


3.4 韧性层:让操作在烂网页上不崩

每次操作前都重新"过一遍"页面

控制器里几乎每个公开方法第一行都是 await self._ensure_page_ready(page)(playwright_controller.py:361-364),而它直接调 on_new_page(playwright_controller.py:339-359),干四件事:

  1. bring_to_front() 把页面提到前台。
  2. 挂下载处理器。
  3. 重设视口尺寸。
  4. 注入 page_script.js 的 init script,然后等 domcontentloaded(60 秒超时;超时就 window.stop() 强行叫停加载)。

每次操作都跑一遍看着重,但换来的是不用维护"页面处于什么状态"这个状态机 —— 每次都当作全新页面处理。这是拿 CPU 换正确性的典型取舍。

崩溃恢复装饰器

@handle_target_closed()(playwright_controller.py:38-99)包住绝大多数方法。它只拦两类错误:

错误特征什么情况
TargetClosedError 或消息含 "Target page, context or browser has been closed"页面/上下文被关掉了
消息含 net::ERR_TUNNEL_CONNECTION_FAILED走代理时隧道断了(BrowserBase 场景常见)

其余异常原样抛出 —— 这是对的,盲目重试所有错误会掩盖真问题。

恢复流程是三级降级(_recover_page,playwright_controller.py:102-152):

① 试探:page.evaluate("1") ──成功──> 页面其实活着,直接返回
│ 失败
v
② 检查 browser.is_connected() ──断了──> 抛"恢复不可能"
│ 还连着
v
③ page.reload(wait_until="commit") ──成功──> 恢复完成
│ 失败
v
④ page.goto(当前 URL) ──成功──> 恢复完成
│ 失败
v
抛出两个错误的合并信息

第①步的试探很聪明:装饰器不知道页面是真死还是假死,一次极便宜的 evaluate("1") 就能分辨,避免了没必要的 reload。


3.5 观察侧:agent 除了截图还能拿到什么

每步都拿的 page context

get_page_context(environment.py:677-692)返回 URL 加一句滚动位置描述:

Current URL: https://www.bing.com/search?q=weather
Viewport: scrolled to 34% of page (0-900px of 2612px)

这个百分比是从 MultimodalWebSurfer.getVisualViewport() 拿的 pageTop / height / scrollHeight 算出来的。作用很直接:截图告诉不了模型"下面还有多少内容",这句话补上了。

注意这句话进的是轨迹记录(ComputerObservation.page_info),模型每轮实际看到的文字提示里只有 URL 那一行(fara15_agent.py:637-649)。

按需拿的页面 markdown

只有 read_page_answer_question 会触发。get_page_markdown(src/fara/environments/playwright/webpage_text_utils.py:67-102)分两条路:

页面类型走法
普通网页document.documentElement.outerHTML,喂给 MarkItDown 转 markdown
PDF先试浏览器内 PDF 查看器的 textLayer,文本少于 100 字符就退化成下载 PDF 用 MarkItDown 解析

PDF 检测本身查四个信号:URL 后缀、document.contentType<embed>/<object> 标签、PDFViewerApplication 全局对象(webpage_text_utils.py:104-117)。这种"多信号取或"的写法在爬虫里很常见,因为不同浏览器/查看器暴露的特征不一样。

页内脚本 page_script.js

src/fara/environments/playwright/page_script.js 是一个 IIFE,挂在全局 MultimodalWebSurfer 上,对外暴露四个函数(page_script.js:603-609):getInteractiveRectsgetVisualViewportgetFocusedElementIdgetPageMetadata

名字带 "MultimodalWebSurfer" 是因为它继承自 AutoGen 的 WebSurfer 那套。在 Fara-1.5 的路径上,只有 getVisualViewport 真的被用到 —— 因为模型不需要可访问性信息,它自己看图。getInteractiveRects 那套 id 化的能力是给旧的 click_id / fill_id 系列方法用的,而那些方法在 Fara-1.5 主循环里没有调用点。


3.6 BrowserBase:云端浏览器与验证码

评测跑真实网站时,本地浏览器很快会被反爬拦住。Fara 支持接 BrowserBase 云端会话(--browserbase)。

建会话的重试骨架

_init_browserbase(environment.py:174-221)最多试 5 次,每次都是完整重来:建新会话 → CDP 连接 → 标准初始化(含跳转起始页)→ 等验证码。失败就释放会话,下一次从干净状态开始。

退避策略分两种(environment.py:290-294):撞限流(RateLimitError)固定等 10 秒,其它错误按 2^(attempt-1) 指数退避。区分限流和普通错误是有道理的 —— 限流靠指数退避涨得太慢,固定等一个较长时间更划算。

验证码信号靠 console 消息传递

验证码闸门是一件事分两头:信号从哪来在这一节,agent 每步怎么等、超时怎么三档降级在第 1 章 §1.3。

BrowserBase 解验证码时会往页面 console 里打两条特定消息,环境层监听它们来开关那个 asyncio.Event(environment.py:267-274):

console 消息动作
browserbase-solving-started_captcha_event.clear() —— 闸门关上
browserbase-solving-finished起一个异步任务,睡 3 秒 + 等 domcontentloaded,再 set()

解完之后为什么还要再睡 3 秒?因为验证码解完页面通常要跳转/重载,立刻放行 agent 会截到一张中间态的图(_resume_after_captcha,environment.py:276-288)。这个 3 秒是纯经验值。

浏览器初始化时会往 .bing.com 注入一条 SRCHHPGUSR=EXLKNT=0 的 cookie(environment.py:334-340,_setup_browser 里应用)。它的作用是让 Bing 搜索结果在当前标签页打开而不是新开标签页

一条 cookie 消掉了单标签页模式下最常见的一类摩擦。这是那种"知道的人五分钟解决,不知道的人调一天"的实战经验。


3.7 关键细节与坑

get_visible_text 是坏的。 WebpageTextUtilsPlaywright.get_visible_text(webpage_text_utils.py:50-65)调的是 WebSurfer.getVisibleText(),但页内脚本挂的全局名是 MultimodalWebSurfer,且它的公开 API 里根本没有导出 getVisibleText(page_script.js:603-609 —— 函数在 IIFE 内部第 552 行定义了,但没进返回对象)。调它必然抛错。好消息是全仓库没有任何调用点,属于死代码。

left_click_drag 不 move 到起点。 环境层的实现是 mouse.down() → move(end) → up()(environment.py:474-477),起点用的是鼠标当前位置。所以模型必须先 mouse_move 到起点、再 left_click_drag 到终点,两步走。基类里倒是有个封装好的 drag_from_to(computer.py:79-84),但动作空间没暴露它。

输入速度是自适应的。 fill_coords 按文本长度选打字延迟:短于 100 字符每键 100ms,否则 10ms(playwright_controller.py:854-857)。慢速打字是为了触发前端的 debounce 搜索建议;长文本再慢就太耗时了。

keypress 用 try/finally 保证按键一定被松开。 按下的键记在 pressed 列表里,无论中途抛什么错,finally 都会逆序松开(playwright_controller.py:987-994)。少了这个,一次失败的 Control+A 会让 Control 永远处于按下状态,后面所有输入全乱。它还专门把 Playwright 的 "Unknown key" 错误翻译成一句人话提示,告诉调用方该用 type()(playwright_controller.py:974-983)。

fill_coords 的返回值在 fill_id 里被误用。 fill_idfill_coords 的返回值(一个可能为 None 的新 Page)赋给了局部变量 page(playwright_controller.py:923-930),然后什么也不做。无害,但说明这条路径没人在跑。

下载走一条很绕的路。 visit_page 遇到 net::ERR_ABORTED 且配了下载目录时,会重新导航一次并捕获 download 事件,存盘后跳转到一个 base64 编码的 data URL 页面告诉 agent"下载成功了,存在这个路径"(playwright_controller.py:397-421)。用一个假页面把"文件下载完成"这个不可见事件变成 agent 能看见的画面 —— 思路和 Ctrl+F 覆盖层是一脉相承的。


3.8 代码地图

主题文件路径符号名
环境抽象接口src/fara/environments/computer.pyComputerEnvironmentBrowserEnvironmentPageContext
环境实现src/fara/environments/playwright/environment.pyPlaywrightEnvironment
新标签页接管src/fara/environments/playwright/environment.py_swap_to_new_page
快捷键路由src/fara/environments/playwright/environment.pykey_normalize_chord_BROWSER_CHROME_DISPATCH
页面状态描述src/fara/environments/playwright/environment.pyget_page_context
BrowserBase 接入src/fara/environments/playwright/environment.py_init_browserbase_connect_browserbase_once
验证码信号src/fara/environments/playwright/environment.py_handle_browserbase_console_resume_after_captcha
底层控制器src/fara/environments/playwright/playwright_controller.pyPlaywrightController
崩溃恢复src/fara/environments/playwright/playwright_controller.pyhandle_target_closed_recover_page
弹窗捕获src/fara/environments/playwright/playwright_controller.py_click_with_popup_capture
页面就绪src/fara/environments/playwright/playwright_controller.py_ensure_page_readyon_new_page
按键安全释放src/fara/environments/playwright/playwright_controller.pykeypress
文本/markdown 抽取src/fara/environments/playwright/webpage_text_utils.pyWebpageTextUtilsPlaywright.get_page_markdown
页内脚本src/fara/environments/playwright/page_script.jsMultimodalWebSurfer
查找覆盖层src/fara/environments/playwright/find_overlay.js(IIFE,无导出符号)
键名映射src/fara/agents/key_mapping.pyCUA_KEY_TO_PLAYWRIGHT_KEY