数据截至 (上游 commit 6a6b6383eb6a)
执行层:动作如何精确落到真实元素
30 秒导读: 上一章(决策层)里,模型吐出的是一句抽象指令——「在
element_id=42上输入‘张三’」。 本章讲 Skyvern 的手:这句话如何被翻译成一次真实的、会成功的浏览器操作。核心难点只有一个—— 模型只知道一个编号42,执行层得把它反查回页面上那个具体的 DOM 节点,再想尽办法把动作落上去(普通点击不行就换坐标点击、再不行换 JS 点击……)。
1. 这是什么:从「一句指令」到「一次真实操作」
先接住上一章的输出。决策层交给执行层的,是一个 Action 对象——比如一个 ClickAction,里面
只有 action_type="click"、element_id="42"、外加一点推理文本。它不含任何「这个元素在屏幕
哪个像素、用什么 CSS 选到它」的信息。
执行层要做三件事,缺一不可:
- 认领:根据
action_type找到对应的处理函数(点击归点击、输入归输入)。 - 回查:把
element_id="42"这个抽象编号,还原成一个能被 Playwright 操作的真实定位器(locator)。 - 落地:在真实元素上执行操作;一次不成,逐级降级重试,直到成功或彻底失败。
一句话直觉:执行层是「编号 → 真实元素 → 真实动作」的翻译器 + 一套不服输的容错重试机制。
为什么难?因为模型看到的页面(上一章「感知层」抓的那份快照)和此刻的真实页面几乎从不一字不差—— 按钮可能刚被 React 重渲染、下拉刚弹出把目标遮住、元素刚从 disabled 变成 enabled。执行层的全部 工程含量,都花在弥合「模型以为的页面」与「真实页面」之间的缝上。
本章不重复上一章「怎么决定要点这个按钮」的规划逻辑,只讲「决定之后,怎么把它稳稳点上」。
2. 动作类型全景:一张枚举定生死
所有动作类型集中定义在一个枚举里(webeye/actions/action_types.py:4 ActionType)。这不是
简单的字符串列表,它同时回答了三个问题,靠三处定义:
| 定义 | 位置(action_types.py) | 回答什么问题 |
|---|---|---|
ActionType 枚举成员 | :4-:36 | 一共有哪些动作(click / input_text / select_option / scroll / keypress / terminate…) |
is_web_action() | :38-:47 | 这个动作需不需要一个真实 DOM 元素(要 element_id) |
POST_ACTION_EXECUTION_ACTION_TYPES | :50-:62 | 这个动作执行完要不要做后置处理(截图、抓页面变化等) |
is_web_action() 是关键分水岭。 只有 7 种动作被算作「web action」——CLICK、INPUT_TEXT、
UPLOAD_FILE、DOWNLOAD_FILE、SELECT_OPTION、CHECKBOX、HOVER。它们的共同点:必须落到一个
具体元素上,所以都要经过下一节讲的 element_id 反查。其余动作分两类:
- 坐标/全局类(不绑元素):
SCROLL、KEYPRESS、MOVE、DRAG、GOTO_URL、SWITCH_TAB…… 它们作用于整页或某个屏幕坐标,无需回查元素。 - 控制流类(不碰页面):
TERMINATE、COMPLETE、WAIT、NULL_ACTION——它们改变的是 agent 循环的状态,不是网页。
记住这条线:web action 才需要「回查元素」这套重活;其余动作直接就能干。
3. 注册表式分发:一张字典,动态派活
3.1 思路:不用 if-else,用注册表
要把 30 种 action_type 各自派给对应函数,最朴素的写法是一坨 if action_type == CLICK: ... elif ...。
Skyvern 用了更干净的注册表模式:一张「动作类型 → 处理函数」的字典,运行时按 key 查表调用。
核心就是 ActionHandler 这个类,它其实只是三张字典的壳(webeye/actions/handler.py:3575 ActionHandler):
class ActionHandler:
_handled_action_types: dict[ActionType, Callable[...]] = {} # 主处理函数
_setup_action_types: dict[ActionType, Callable[...]] = {} # 前置钩子(当前为空)
_teardown_action_types: dict[ActionType, Callable[...]] = {} # 后置钩子(当前为空)
注册用一个类方法往字典里塞(handler.py:3592 register_action_type),本质是 cls._handled_action_types[action_type] = handler。
文件底部一口气把所有 handler 注册进去(handler.py:8660-8686),比如:
ActionHandler.register_action_type(ActionType.CLICK, handle_click_action)
ActionHandler.register_action_type(ActionType.INPUT_TEXT, handle_input_text_action)
ActionHandler.register_action_type(ActionType.SELECT_OPTION, handle_select_option_action)
# ……一直到 execute_js
一个可核实的细节:
_setup_action_types/_teardown_action_types两张钩子字典目前没有任何 注册(全仓没有一处调用register_setup_for_action_type)。所以分发时的 setup/teardown 步骤实际 是空转——框架预留了扩展点,但当前动作都不用。
3.2 分发主线:两层包裹
真正的分发入口是 handle_action(handler.py:3624),但它其实是个包裹层,干的是「下载感知」的脏活;
真正查表调用在它内部的 _handle_action(handler.py:4465)。分两层的原因很实在——下载动作和非下载
动作的耗时是双峰分布:普通动作 ~1 秒结束,而会触发下载的动作要轮询等文件落盘,最长能耗到 240 秒(120s 无信号宽限 + 120s 在途延长)
(handler.py:3648-3651 的注释直接点破了这个下载长尾:默认 120s 无信号宽限 + 120s 在途延长,上限 240s)。
handle_action(外层包裹, :3522)
│ 判断这 次动作会不会触发下载 (trigger_download_action)
├─ 不触发下载 ──► 直接调 _handle_action,落库,返回 (快路径, ~1s)
└─ 会触发下载 ──► 挂 download 监听 → 调 _handle_action
→ 轮询下载目录 / XHR 捕获 / 事件兜底 (慢路径, 最长 120s)
→ 处理 about:blank 回跳 → 落库
下载那套轮询逻辑(handler.py:4036-4041)很长但目标单一:多路信号抢答——浏览器 download 事件、
下载目录新文件、XHR 抓包、以及「一直没信号」的宽限超时,谁先命中就按谁的结果收尾。本章不逐行展开,
知道它是「为下载这种慢动作单开的一条等待路径」即可。
3.3 查表调用的五步(_handle_action)
真正的注册表派活在 _handle_action(handler.py:4465)。剥掉异常处理,主干是这样:
# 简化自 _handle_action,非逐字源码
if action.action_type in ActionHandler._handled_action_types:
# ① 前置校验:web action 但元素不在快照里 → 直接判失败
if invalid := check_for_invalid_web_action(action, page, scraped_page, task, step):
return invalid
# ② setup 钩子(当前恒空)
if setup := ActionHandler._setup_action_types.get(action.action_type):
...
# ③ 查表拿到 handler,执行
handler = ActionHandler._handled_action_types[action.action_type]
results = await handler(action, page, scraped_page, task, step)
# ④ teardown 钩子(当前恒空)
# ⑤ 返回结果
return results
else:
return [ActionFailure(...)] # 没注册的类型
两个值得记住的点:
-
check_for_invalid_web_action(handler.py:4635)是元素回查前的第一道闸。 它的核心判断只有 一行:isinstance(action, WebAction) and action.element_id not in scraped_page.id_to_element_dict——如果这是个需要元素的 web action,但element_id压根不在感知层抓的快照字典里,立刻返回MissingElement失败,连回查都不用做。(例外:带了x/y坐标的点击、以及 CUA 那种没有element_id的输入,直接放行。) -
所有 handler 签名统一:
(action, page, scraped_page, task, step) -> list[ActionResult]。返回的是 一个结果列表(不是单值),因为一次动作内部可能重试多次、每次留一条ActionSuccess/ActionFailure记录,列表最后一项才代表最终成败(见_handle_action的finally块handler.py:4583:靠actions_result[-1]是不是ActionSuccess来定动作status)。
4. 难点核心:element_id 如何反查回真实元素
这是整个执行层工程含量最高的一块。模型手里只有一个字符串编号,执行层要把它变成一个
Playwright Locator——一个能 .click()、.fill() 的真实句柄。
4.1 四张字典:编号的「户口本」
感知层(上一章)抓页面时,给每个可交互元素编了号,并留下四张以 element_id 为 key 的字典,
挂在 ScrapedPage 上(webeye/scraper/scraped_page.py:199-204):
| 字典 | key → value | 作用 |
|---|---|---|
id_to_element_dict | 编号 → 元素属性字典(含 tagName、xpath 等) | 元素长什么样 |
id_to_css_dict | 编号 → CSS 选择器 | 首选怎么定位 |
id_to_frame_dict | 编号 → 所在 frame 的编号 | 元素在哪个 iframe 里 |
id_to_element_hash | 编号 → 元素内容 hash | 跨快照识别「同一个元素」(缓存复用用,见 §6) |
回查的总入口是 DomUtil.get_skyvern_element_by_id(webeye/utils/dom.py:1586)。它把这四张字典串起来,
产出一个 SkyvernElement(对真实 locator 的封装)。
4.2 回查五步 + 两级容错
element_id="42"
│
├─① id_to_element_dict[42]? 没有 → MissingElementDict(元素根本没抓到)
├─② id_to_frame_dict[42]? 没有 → MissingElementInIframe(不知道在哪个 frame)
├─③ id_to_css_dict[42]? 没有 → MissingElementInCSSMap(没有选择器)
│
├─④ resolve_locator(frame, css) ── 顺着 iframe 链逐层下钻,得到 locator
│
└─⑤ locator.count() == 1 ?
├─ ==0 ─► 【容错1】回退用 xpath 再定位一次;还是 0 → MissingElement
├─ >1 ─► MultipleElementsFound(选择器不唯一,宁可失败也不乱点)
└─ ==1 ─► ✅ 返回 SkyvernElement(locator, frame, element, hash)
这段逻辑在 dom.py:1586-1641。三个设计点值得单独讲:
(a) iframe 链下钻(resolve_locator,dom.py:106)。 元素可能嵌在 iframe 里、甚至 iframe 套 iframe。
id_to_frame_dict 只记「我在哪个 frame」,resolve_locator 就顺着 frame_element.get("frame") 一路往上
爬到 main.frame,攒出一条 iframe 路径,再从主页面一层层 frame_locator(...) 钻回去。定位一个深层
iframe 里的元素,靠的就是这条链。注意它用的定位属性是 unique_id(constants.py:5 SKYVERN_ID_ATTR = "unique_id")
——感知层给每个元素注入的那个属性。
(b) CSS 优先、xpath 兜底(dom.py:1603-1619)。 首选 CSS 选择器;万一 CSS 选出 0 个元素(页面变了),
就回退到该元素记录里的 xpath。源码注释很诚实地承认这个 xpath 只按标签名记位置、不 100% 可靠
(dom.py:1610-1612),但「同位置有同标签元素」时够用了——这是典型的「差一点也比彻底失败强」的容错。
(c) 唯一性是硬约束(dom.py:1631)。 选择器命中多个元素时,直接抛 MultipleElementsFound 而不是
挑第一个。理由:点错元素的代价远高于失败重试。「宁可报错也不瞎点」是这里的安全底线。
还有一个「安全版」入口 safe_get_skyvern_element_by_id(dom.py:1644):包一层 try/except,回查失败返回
None 而非抛异常——给那些「查不到就算了、别中断」的调用点用(比如 §5.5 的 scroll)。
5. 代表性 handler 逐个看
回查解决了「找到元素」,落地才是「把动作做成」。下面挑几个有代表性的 handler,看它们各自的容错花样。
5.1 点击:handle_click_action + chain_click 的容错梯
handle_click_action(handler.py:5385)先分两条路:
- 有
x/y坐标(CUA/视觉 agent 给的):用document.elementFromPoint(x,y)反查该像素处元素的unique_id,能查到就先试「若 是链接则直接导航」,否则退化成纯坐标page.mouse.click(handler.py:5397-8798)。repeat决定单击/双击/三击。 - 有
element_id:走 §4 回查拿到skyvern_element,然后做几件容错准备,最后交给chain_click。
回查后、点击前有三个巧妙的前置处理:
- disabled 重定向(
handler.py:5457,_retarget_disabled_element_for_click)。 元素若是 disabled 的 包装容器,尝试找它「唯一一条链上最深的可交互后代」改点那个(很多 UI 框架把真正可点的东西 包在禁用的外壳里)。找不到明确后代才判InteractWithDisabledElement失败。 - 动态复检 disabled(
is_disabled(dynamic=True))。 因为前一个动作可能刚把它从禁用变成可用—— 静态快照会骗人,所以实时再查一次。 - 跳过 scroll_into_view 的信号(
handler.py:5486)。 如果上一步是对同一元素的 SCROLL 动作 (比如把条款弹窗滚到底以激活「同意」按钮),这里就不再scrollIntoView(),否则会把刚滚好的 位置又顶回去。它靠window.__skyvernScrolledElementId这个页面变量在 scroll 和 click 之间传信号 (见 §5.5)。
真正落点的是 chain_click(handler.py:8822)——一条逐级降级的容错梯。核心思路:一种点法失败,
就换下一种更「暴力」的点法,每种失败都记一条 ActionFailure 但不放弃,直到某一级成功。
chain_click 容错梯(从上到下,命中即停)
├─ 0. 若元素是 <a href> ─► 直接导航到 href(绕过点击)
├─ 1. 正常 Playwright 点击(走光标策略 EventStrategyFactory) ← 绝大多数在这一级成功
│ └─ 若「物理点击已派发、只是等导航超时」→ 也算成功(:4205)
├─ 2. 是 <label>? → 找 for= 绑定的控件点它 / 找 label 里的 <input> 点它
├─ 2'. 非 label? → 反查绑定它的 <label>(by attr id / by 直接父节点)点 label
├─ 3. 元素已不可见 → 直接放弃(返回累积的失败)
├─ 4. 找「挡在前面的元素」(find_blocking_element)
│ ├─ 没有遮挡但 Playwright 仍失败(React 重渲染/动画)→ 坐标点击 coordinate_click
│ │ └─ 坐标点击也失败 → JS 点击 click_in_javascript()
│ └─ 有遮挡且遮挡者是父/兄弟 → 改点那个遮挡元素
└─ 5. 遮挡者非父/兄弟 → JS 点击原元素,再用「增量抓取」验证页面真的响应了(_did_page_respond)
对应源码在 handler.py:8888-9304。这条梯子是 Skyvern「手」的精华:同一个「点击」意图,被翻译成
至多六七种不同的物理实现,逐级兜住真实页面的各种坑(label 关联、元素遮挡、框架重渲染、命中测试失败)。
顺带一提,chain_click 还兼管文件上传时的 filechooser:点击可能弹出系统文件选择框,它预挂一个
监听器自动 set_files(handler.py:8864-8867),点完若没弹(被别的弹窗拦了)就把监听器「延迟挂起」
留给下次点击触发(handler.py:9257-9269)。
5.2 输入文本:handle_input_text_action 的「输入还是选择」纠结
handle_input_text_action(handler.py:6127)远不止「往框里打字」。它要先判断这个「输入」到底是不是
真的输入。主流程(简化):
handle_input_text_action
├─ 没有 element_id → CUA 直接 type_text,结束
├─ 回查元素;若当前值已等于目标值 → 直接成功(幂等)
├─ 解析 secret:文本是密文占位符就换成真值;TOTP 特殊处理(:2441-2453)
├─ 元素本身可 select(<select>)→ 转成 SelectOptionAction 走选择逻辑(:2472)
├─ 疑似「自动补全输入框」? 按 ↓ 试探有没有下拉冒出来(:2492-2607)
│ 有下拉 → 转 sequentially_select_from_dropdown 当选择处理
│ 没下拉 → 回到纯输入
└─ 纯输入:focus → 清空 → 按类型特判 → input_sequentially 逐字输入
├─ type=tel → 电话号码格式校验/回读校验(:2628-2657, 2783-2816)
├─ type=date → 日期格式校验(:2745)
└─ 其它 → 逐字键入 + 监听增量 DOM,命中搜索下拉则选中(:2818-2857)
要抓住的直觉:「输入」和「选择」在真实网页里经常是同一个框。 一个看似普通的输入框,打字后可能
弹出自动补全下拉——那这次「输入」的正确落地其实是「选中下拉里的某项」。这个 handler 一大半代码在
处理这种输入/选择二义性,靠「按 ↓、监听 DOM 有没有新增元素」来现场判定(handler.py:6404-6447)。
底层真正打字的是 input_sequentially(handler_utils.py:52):超长文本先 fill 灌前半段、只对最后
TEXT_PRESS_MAX_LENGTH 个字符逐键输入(省时间又保留「像人一样键入」触发的事件)。
结尾还有个 HACK(handler.py:7233-7247):自动补全没选中时,兜底按一下 Tab 强制让浏览器选中一项。
5.3 选择下拉:handle_select_option_action 的 custom vs normal
下拉选择是最麻烦的一类,因为网页上「下拉」有两种完全不同的实现:
| 类型 | 长什么样 | Skyvern 怎么选 |
|---|---|---|
| normal-select | 原生 <select><option> | normal_select:让 LLM 从 option 列表挑 index/value,再 locator.select_option |
| custom-select | <div> 拼的假下拉(React 组件等) | 点开 → 监听弹出的选项 → LLM 匹配 → 点中那一项 |
handle_select_option_action(handler.py:7497)先做一长串「这到底是什么控件」的分诊
(handler.py:7529-7704):
- 是原生
<select>?→ 检查有没有被遮挡,没遮挡走normal_select(handler.py:7581-7607)。 - 是
<input>checkbox / radio / button?→ 分别转成CheckboxAction或ClickAction。 - 不可选但子节点里有可选元素?→ 改选那个子节点(
find_selectable_child,handler.py:7546)。 - 都不是 → 走 custom select(
handler.py:7704起)。
custom select 的容错(handler.py:7718 起)是本 handler 的重头:
custom select
├─ 挂 DOM 增量监听 → 点开下拉 → 等动画结束
├─ 抓到「点开后新增的元素」(就是选项们)
│ └─ 一个都没抓到? 且是 <input> → 按 ↓ 再试一次触发下拉(:3330)
│ └─ 还是没有 → 换「重新整页抓取找匹配元素」select_from_emerging_elements(:3349)
├─ 有选项 → sequentially_select_from_dropdown:LLM 看选项 HTML 挑一个去点(:3372)
└─ 按 label 没选中 → 拿 LLM 建议的 value 再走 select_from_dropdown_by_value 兜底(:3435)
finally: 失败就把下拉关掉(Escape + blur),别把页面卡在「下拉开着」的脏状态(:3397)
LLM 匹配选项的核心在 select_from_dropdown(handler.py:11726):把弹出的选项树清洗成 HTML,塞进
custom-select prompt 让模型选(handler.py:11463-11478),必要时还会滚动下拉加载全部选项再匹配
(handler.py:11774-11784)。这里体现了执行层和决策层的边界:「选哪一项」仍要问模型,但「怎么把
下拉点开、怎么把选中动作可靠落上、失败怎么收场」全是执行层的活。
5.4 上传文件:handle_upload_file_action
handle_upload_file_action(handler.py:7323)有两个亮点:
- 防幻觉 file_url(
handler.py:7339-7346)。 模型给的下载 URL 若既不在 navigation goal 也 不在 payload 里,先用有界编辑距离模糊搜索(_find_similar_url_in_text,handler.py:7256)看是不是 模型把用户给的长 URL(含预签名 token)敲错了几个字符——能对上就用用户原文的那个 URL;对不上 就判ImaginaryFileUrl失败。这是防「模型编造一个下载地址」的安全阀。 - file input 反查(
handler.py:7379-7382)。 目标若本身就是<input type=file>,直接set_input_files;若不是(比如是个「上传」按钮),就去子节点里找真正的 file input,找不到就退化成 一次点击(chain_click并把待传文件挂上),靠点击弹出的 filechooser 完成上传。
5.5 滚动:handle_scroll_action 与它和点击的暗号
handle_scroll_action(handler.py:8134)分三种:元素级、坐标级、纯滚动。元素级最有意思
(handler.py:8143-8156):用 JS scrollNearestScrollableContainer 找到元素最近的可滚动容器;若只有
整页可滚,就用 mouse.wheel 分块滚 + 每块停 100ms(handler.py:8207-8209),因为很多页面靠
真实 wheel 事件触发懒加载、或滚到底才启用按钮——window.scrollTo 这种编程式滚动它们不认。
滚完它会在页面上写一个变量 window.__skyvernScrolledElementId = id(handler.py:8218、3763)。这就是
§5.1 里点击 handler 读的那个「暗号」:「我刚故意把这个元素滚到位了,你点它时别再 scrollIntoView 把
它顶回去」——两个 handler 通过一个页面全局变量做隐式协作,解决「滚到底激活按钮 → 再点按钮」这类
连续动作的经典坑。
5.6 键盘:handle_keypress_action
最薄的一层(handler.py:8250):转手给 handler_utils.keypress(handler_utils.py:80)。后者干的主要是
按键名归一化——把模型可能说的 "enter"/"return" 都映射成 Playwright 的 "Enter"、"esc"→"Escape"、
"ctrl"→"Control"、"f5"→"F5"(handler_utils.py:82-102),再用 + 拼成组合键按下。支持 hold
(按住 duration 秒)和 repeat(重复 n 次)。
5.7 收尾:handle_terminate_action / handle_complete_action
这两个是控制流动作,不碰页面:
- terminate(
handler.py:8003):任务失败退出。若配了error_code_mapping,顺手让 LLM 从页面 抽取「用户自定义错误」填进action.errors,然后返回成功(terminate 动作本身总是「成功地终止」)。 - complete(
handler.py:8027):任务完成退出,但要先核实。若尚未验证过(action.verified为假) 且有 navigation goal,就调complete_verify让模型看着页面确认目标真达成了(handler.py:8048):- 验证不通过 → 返回
ActionFailure(IllegitComplete),任务继续(防模型过早宣布成功)。 - 验证要求终止 → 转成
TerminateAction执行(handler.py:8077)。 - 通过 → 标
verified=True,成功。
- 验证不通过 → 返回
这里的精华是 complete 不是模型说完成就完成——执行层强制加了一道 LLM 复核,这是对抗「幻觉式提前 收工」的关键闸门。
6. 动作缓存与复用:caching.py
跑过一次的任务,Skyvern 能把「上次的动作序列」缓存下来,下次同 URL 同目标时直接重放,省掉再问
一遍大模型。难点是:上次记录的 element_id 这次已经失效了(每次抓取编号会变),怎么把老动作对回
到新页面的元素上?
答案是 element hash 反查(webeye/actions/caching.py)。缓存的每个动作带一个 skyvern_element_hash
(元素内容指纹,跨快照稳定),复用时拿它去新快照的 hash_to_element_ids 里查回新编号:
# 简化自 _retrieve_action_plan,caching.py:128-133
if cached_action.skyvern_element_hash:
matching = scraped_page.hash_to_element_ids.get(cached_action.skyvern_element_hash)
if matching and len(matching) == 1: # 必须唯一命中
updated_action.element_id = matching[0] # 用新编号替换老编号
updated_action.skyvern_element_data = scraped_page.id_to_element_dict.get(matching[0])
关键规则(caching.py:75-104):
- hash 唯一命中才复用;命中 0 个或多个就停止匹配,剩下的动作退回「问模型」模式。这跟 §4.2 的 「唯一性是硬约束」一脉相承——对不准就别猜。
- 无 hash 的动作(terminate/complete/wait/captcha/null)没有元素可对,规则是只能作为每步的第一个
动作执行,且执行完要重新抓页面(
caching.py:79-87)。 - 复用的动作还会按意图重新个性化(
personalize_actions,caching.py:155):比如缓存里的 INPUT_TEXT 会用当前上下文重新问 LLM 该填什么值(caching.py:216),而不是照搬上次的文本。 - 只有部分动作类型支持缓存(
check_for_unsupported_actions,caching.py:250:input_text/wait/click/ complete/download_file),碰到不支持的类型整体退回无缓存模式。
一句话:缓存复用 = 用稳定的 element hash 把老动作重新锚回新页面 + 把易变的值重新个性化。
7. 巧妙之处(值得带走的技术)
- 注册表分发替代 if-else 巨兽。 30 种动作靠一张
dict[ActionType, handler]派活,加动作只需register_action_type一行(handler.py:3592、:3615)。还预留了 setup/teardown 钩子扩展点。 - 一个抽象编号,四张字典还原真实元素。
element_id不含任何定位信息,靠 element/css/frame/hash 四张字典 + iframe 链下钻还原成真实 locator(dom.py:1631,:106)。感知与执行彻底解耦。 - 容错梯:一个意图,多种物理实现。
chain_click把「点击」降级成 label 关联点击 / 坐标点击 / JS 点击 / 点遮挡元素等六七种(handler.py:8888-9304)——真实网页的坑,用「不服输地换招」兜住。 - CSS 失败退 xpath、多命中即报错。 定位既有兜底(
dom.py:1615)又有安全底线(多命中宁可失败不乱点,dom.py:1631)。 - 点击与滚动用页面全局变量传暗号。
window.__skyvernScrolledElementId让「滚到底激活按钮」和 「再点按钮」两个动作不互相拆台(handler.py:5486↔:8219)。 - complete 强制 LLM 复核。 模型说完成不算数,执行层再问一遍模型「真的达成了吗」,对抗幻觉式收工
(
handler.py:8039-8047)。 - 防幻觉 file_url 的模糊搜索。 用有界编辑距离把模型敲错的下载 URL 拉回用户原文(
handler.py:7256)。 - element hash 让动作缓存跨快照复用。 编号会变、hash 不变,靠 hash 唯一命中把老动作重锚回新页面
(
caching.py:129)。
8. 边界与局限(诚实说)
- 定位不 100% 可靠。 xpath 兜底只按标签名记位置,源码自己承认「不 100% 可靠」(
dom.py:1610-1612); 页面结构变动时可能定到「同位置但不同」的元素。 - 多命中 = 直接失败,不重试选择。
MultipleElementsFound一抛就是失败(dom.py:1638),选择器不唯一 的页面会卡在这里,交给上层重新规划。 - 下载动作的长尾。 触发下载的动作最长能等 240 秒(120s 宽限 + 120s 在途延长,
handler.py:3648-3651)——慢是设计使然,不是 bug。 - custom select 重度依赖 LLM 和 DOM 增量监听。 若下拉是异步渲染、或选项不通过 MutationObserver 冒出来,
匹配会退化到「整页重抓找匹配」这种更慢的路径(
handler.py:7771)。 - 缓存复用条件苛刻。 只支持少数动作类型、且 hash 必须唯一命中,任一环节对不上就整体退回问模型
(
caching.py:91-104,:250);页面稍有结构变化,缓存就大概率失效。 - setup/teardown 钩子当前是死代码。 框架留了口子但没接线(全仓无注册),别指望它现在做什么。
9. 代码地图(导航索引)
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| 动作类型枚举 / web-action 判定 | webeye/actions/action_types.py | ActionType、ActionType.is_web_action、POST_ACTION_EXECUTION_ACTION_TYPES |
| 注册表 + 三张字典 | webeye/actions/handler.py:3575 | ActionHandler、register_action_type |
| 分发外层(含下载等待) | webeye/actions/handler.py:3624 | ActionHandler.handle_action |
| 分发内层(查表调用) | webeye/actions/handler.py:4465 | ActionHandler._handle_action |
| 回查前置闸 | webeye/actions/handler.py:4635 | check_for_invalid_web_action |
| element_id → 真实元素回查 | webeye/utils/dom.py:1586 | DomUtil.get_skyvern_element_by_id、safe_get_skyvern_element_by_id |
| iframe 链下钻定位 | webeye/utils/dom.py:106 | resolve_locator、SKYVERN_ID_ATTR |
| 四张 id 字典 | webeye/scraper/scraped_page.py:201 | id_to_element_dict、id_to_css_dict、id_to_frame_dict、id_to_element_hash、hash_to_element_ids |
| 点击 handler | webeye/actions/handler.py:5385 | handle_click_action、_retarget_disabled_element_for_click |
| 点击容错梯 | webeye/actions/handler.py:8822 | chain_click、_locator_click、_get_click_count、_did_page_respond |
| 输入 handler | webeye/actions/handler.py:6127 | handle_input_text_action、_find_similar_url_in_text |
| 选择 handler(分诊) | webeye/actions/handler.py:7497 | handle_select_option_action |
| custom / normal 选择 | webeye/actions/handler.py:11726 / :12068 | select_from_dropdown、select_from_dropdown_by_value、normal_select |
| 上传 handler | webeye/actions/handler.py:7323 | handle_upload_file_action |
| 滚动 handler(含点击暗号) | webeye/actions/handler.py:8134 | handle_scroll_action、__skyvernScrolledElementId |
| 键盘归一化 | webeye/actions/handler_utils.py:80 | keypress、input_sequentially、drag |
| terminate / complete | webeye/actions/handler.py:8003 / :8027 | handle_terminate_action、handle_complete_action |
| 动作缓存复用 | webeye/actions/caching.py:16 | retrieve_action_plan、personalize_action、check_for_unsupported_actions |
相关章节:上游「模型怎么决定要做这个动作」见 决策层;动作要落的那份 「页面快照」怎么来的(四张 id 字典的源头)见 感知层;DOM 规划器与 计算机使用(CUA)两种引擎在动作上的差异见 引擎家族;全景与阅读顺序见 index。