感知层:把网页变成 LLM 能读的东西
30 秒导读: LLM 看不懂原始 HTML(几万行、噪声满天),也不能直接"点第三个按钮"。 Skyvern 的感知层把整张网页压成两样东西喂给模型:一棵只留可交互元素、每个元素带唯一 ID 的精简 HTML 树,加上滚动分屏的截图。这个"唯一 ID"(
unique_id)是全书的锚点—— 后面决策层(ch02)让模型输出"点 ID=Abcd的元素",执行层 (ch03)再用这个 ID 反查真实 DOM 节点去点。没有这一层,整个 agent 是瞎的。
本章只讲"眼睛":网页 → LLM 可读数据。模型怎么用这些数据(ch02)、动作怎么落到真实元素(ch03) 不在这里。
1. 这是什么(零基础也能懂)
一句话定义: 感知层 (scraper)是一段"把网页翻译成 LLM 母语"的流水线。输入是一个已经打开的
浏览器页面,输出是一个 ScrapedPage 对象。
它要解决的问题。 直接把网页原始 HTML 丢给模型有三个致命伤:
| 问题 | 后果 |
|---|---|
| HTML 太大 | 几万个节点、内联样式、脚本,一屏就爆 token |
| 噪声太多 | <script>、隐藏元素、装饰性 <div> 淹没真正能点的东西 |
| 没有稳定抓手 | 就算模型说"点登录按钮",代码也无从知道它指哪个 DOM 节点 |
Skyvern 的解法:只保留"人真的能点/能填"的元素,给每个这样的元素烙一个唯一 ID,再把这棵 树渲染成一小段干净 HTML。ID 就是模型和真实页面之间的共享坐标系。
输出长什么样(示意)。 一个购物页可能被压成这样喂给模型:
<a unique_id="a1">Sign in</a>
<input unique_id="a2" placeholder="Search" type="text">
<button unique_id="a3">Add to cart</button>
模型只要回一句"在 a2 里输入 iPhone、然后点 a3",执行层就能精确落地。
一句话直觉: 把感知层想成给网页做减法 + 贴标签——先擦掉所有 LLM 用不上的东西,再给每个 "能操作的把手"贴一张带编号的标签。
2. 顶层全景(一次 scrape 怎么转)
怎么读这张图: 从上到下是一次 scrape_website 的主流程;左边是 Python 编排、右边标注了在
浏览器里跑的 JS(真正判定"哪些元素可交互"的逻辑在浏览器执行,因为只有浏 览器知道计算样式、
可见性、命中测试)。
┌──────────────────────────────────────────────┐
Python 侧 │ scrape_website (失败重试外壳) │
│ └─> scrape_web_unsafe (真正干活) │
└───────────────────┬──────────────────────────┘
│
① 拿到工作页面 Page ▼
must_get_working_page ──> 等页面稳定 _wait_for_scrape_ready
│
② 建可交互元素树 ▼ 浏览器(JS)侧
get_interactable_element_tree ───注入JS,evaluate──> buildTreeFromBody
│ └ buildElementTree 遍历 DOM
│ 每个节点问三连:
│ 可见? 未隐藏? 可交互?
│ └ buildElementObject
│ 给命中的元素烙 unique_id
③ 递归进 iframe ▼
add_frame_interactable_elements (每个子帧重复②)
│
④ 清洗 + 精简 ▼
cleanup_element_tree → trim_element_tree (删无用属性/丢无 ID 节点)
│
⑤ 分屏截图 ▼
take_split_screenshots (滚一屏截一张)
│
⑥ 建索引表 ▼
build_element_dict (id→css / id→element / id→hash ...)
│
▼
ScrapedPage(元素树 + 截图 + 各种 id 映射)
│
需要喂 LLM 时 ▼
json_to_html(元素树) ──> 一段带 unique_id 的干净 HTML
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
scrape_website | 失败重试外壳(生产环境重试次数=0) | scraper.py:190 |
scrape_web_unsafe | 一次 scrape 的真正主流程 | scraper.py:386 |
get_interactable_element_tree | 主帧 + 所有子帧建树的 Python 编排 | scraper.py:641 |
buildElementTree (JS) | 在浏览器里遍历 DOM、判定可交互 | domUtils.js:1849 |
buildElementObject (JS) | 给命中元素烙 unique_id、抽属性 | domUtils.js:1654 |
trim_element_tree | 删无用属性、丢掉不该留 ID 的节点 | scraper.py:942 |
take_split_screenshots | 滚动分屏截图 | page.py:601 |
build_element_dict | 建 id→css/element/frame/hash 五张表 | scraper.py:168 |
json_to_html | 元素树 → 给 LLM 的 HTML | scraped_page.py:45 |
ScrapedPage | 装所有产物的最终模型 | scraped_page.py:170 |
主线走一遍(高层): 一个 BrowserState(浏览器底座,见 §7)交出一个 Playwright Page →
在页面里注入 domUtils.js 并遍历 DOM 选出可交互元素、逐个烙 ID → 递归钻进每个 iframe 重复一遍 →
清洗精简这棵树 → 滚动截几张图 → 建好一堆以 ID 为 key 的索引表 → 打包成 ScrapedPage。
3. 核心原理(逐个机制,由浅入深)
3.1 unique_id:全书的锚点
要解决的小问题: 模型说"点这个",代码怎么知道"这个"是 DOM 里哪一个节点?
思路: 不靠 xpath、不靠坐标(都易漂移),而是在扫描时给每个可交互元素写一个自定义属性
unique_id,直接烙进真实 DOM。之后无论是喂给 LLM 的 HTML、还是执行动作时的定位,都用
[unique_id='...'] 这个 CSS 选择器反查——同一个字符串串起"模型看到的"和"真实存在的"。
这个属性名就叫 unique_id:
# constants.py:5 —— 全项目共用的锚点属性名
SKYVERN_ID_ATTR: str = "unique_id"
烙 ID 发生在 JS 侧:每个被判定要保留的元素,进 buildElementObject 时拿到(或新生成)一个
ID 并写回 DOM。
// domUtils.js:1660-1662 buildElementObject —— 有就复用,没有就新发一个,再 setAttribute 写回真实节点
var element_id = element.getAttribute("unique_id") ?? (await uniqueId());
element.setAttribute("unique_id", element_id);
ID 怎么生成(uniqueId,domUtils.js:1543): 4 个字符的短码。第 1 位编码帧序号
(GlobalSkyvernFrameIndex),后 3 位是一个自增计数器按 62 进制(A-Za-z0-9)展开。所以
unique_id 天然带"哪个帧"的信息,且短——省 token。
反查在 Python 侧统一成 CSS 选择器:
# scraper.py:180 build_element_dict —— 每个 id 生成一个 [unique_id='xxx'] 选择器
id_to_css_dict[element_id] = f"[{SKYVERN_ID_ATTR}='{element_id}']"
关键点: ID 是写进真实 DOM 的,不是内存里另存的编号。这让"模型的世界"和"浏览器的世界" 用同一把钥匙对齐——这是后续 ch02/ch03 一切动作能落地的前提。
3.2 "可交互"到底怎么判:三道关
要解决的小问题: 一张网页几千个节点,哪些值得给模型看?标准是"人能不能操作它"。
思路: 在浏览器里对每个节点做判定(只有浏览器知道计算样式和命中测试)。buildElementTree
遍历时对每个元素问三连(domUtils.js:1932-1934):
isElementVisible ? ──否──> 跳过(但会递归它的子节点)
│是
!isHidden ? ──否──> 跳过
│是
!isScriptOrStyle ? ──否──> 跳过(script/style 永不要)
│是
isInteractable ? ──是──> 烙 ID,记为 interactable
│否
仍可能保留(见下:表格/伪元素/有文本的节点等),但 interactable=false
第一关 可见性 isElementVisible(domUtils.js:454) 比"有没有 display:none"复杂得多,处理了
一堆真实世界的坑:
option/radio/checkbox自身默认无尺寸 → 改看父元素是否可见(:493-504)。display:contents元素自身不渲染但子节点渲染 → 递归看子节点(:517-532),并用一个seen集合打破 option↔parent 的递归环(:457-459)。- 影子 DOM(Shadow DOM)里被 CSS 藏起来的原生
input/select→ 组件库常这么干,强制判可见 (:464-489)。 - 零尺寸 但
cursor:pointer+ 有::before/::after伪内容的图标按钮 → 仍算可见(:542-549)。
第二关 隐藏 isHidden(domUtils.js:713) 看 display:none 和 hidden 属性,但给"其实是提交
按钮却带 hidden 属性"开了后门(:718-728)。
第三关 可交互 isInteractable(domUtils.js:908) 一长串规则,本质是"这个东西点了/填了有用吗":
| 命中即算可交互 | 依据 |
|---|---|
| 有 ARIA widget role | domUtils.js:921-923 |
input(非 hidden 类型) | :938-940 |
button / select / option / textarea | :968-975 |
带 href 的 <a> | :959-961 |
关联了未禁用控件的 <label> | :977-979 |
有 onclick / contentEditable / jsaction | :981-987 |
role=listbox / role=option 的 div/ul/li | :1007-1020 |
div/span/... 但计算样式是 cursor:pointer | :1056-1080 |
| 有 jQuery / Angular 绑定的 click 事件 | :1090-1107 |
反过来,pointer-events:none(且非 disabled、非 hover 才显形)会被判不可交互(:928-936);
html/iframe/frame/frameset 也一律不算可交互元素(:943-957)。
为什么不可交互的节 点有时也留? 因为要保住 DOM 结构和文本上下文。buildElementTree 里,
表格相关元素、带伪元素文本的、SVG、以及"有实际文本内容"的节点也会被 buildElementObject
保留下来,只是标 interactable=false(domUtils.js:1948-1978)——它们给模型提供定位语境
(比如"这个输入框旁边的 label 写着什么")。
3.3 精简:从"全量元素树"到"喂 LLM 的小树"
要解决的小问题: 就算只留可交互元素,属性还是太多(一堆 class、style、data-*)。要再瘦身。
思路:分两步。 先 cleanup_element_tree(项目按页面类型可插拔的清洗函数),再
trim_element_tree 做通用瘦身。主流程里两步接连发生:
# scraper.py:450-451
element_tree = await cleanup_element_tree(page, url, copy.deepcopy(element_tree))
element_tree_trimmed = trim_element_tree(copy.deepcopy(element_tree))
trim_element(scraper.py:883)干三件事:
- 删定位字段:
frame/frame_index从喂模型的树里删掉(模型不需要)。 - 丢无用 ID:
_should_keep_unique_id(scraper.py:857)决定这个节点要不要留id——只有 可交互的、或带 disabled/readonly 状态的、或 hover 才显形的才留;纯结构性节点丢掉 ID 省 token。 - 砍属性:
_trimmed_attributes(scraper.py:959)只保留一张白名单RESERVED_ATTRIBUTES(scraper.py:67,如href/type/placeholder/aria-label/value/name等);_trimmed_base64_data(scraper.py:948)把src/href里的data:base64 大块内容整个丢掉——那是 token 黑洞。
一句话: cleanup 是"按页面定制的清洗",trim 是"通用的属性白名单 + 丢 ID + 去 base64"。留下来的 每个字节都要对得起它占的 token。
3.4 json_to_html:把元素树翻成 LLM 的 HTML
要解决的小问题: 精简后的元素树是 Python dict,模型读 HTML 更顺。得渲染回类 HTML 文本。
思路: 递归把每个 dict 节点拼成 <tag 属性>文本 子节点</tag>(scraped_page.py:45)。几个巧处:
- 超长 href 哈希占位:
href长度 >150 的,替换成{{_<sha256>}}占位符,真值另存到context.hashed_href_map(scraped_page.py:64-70)——省 token,需要时再还原。 - 私有区字符替换成
[icon]:图标字体常用 Unicode 私有区码点,_replace_pua_with_marker(scraped_page.py:32)把它们换成[icon],避免喂给模型一堆乱 码。 need_skyvern_attrs开关:True 时才把unique_id写进 HTML;截图分支渲染时用 False(那次只是 为算 token 数、不需要 ID)。
原理演示(示意,非源码):
# 示意:把一个精简后的元素节点渲染成 HTML
def render(node):
tag = node["tagName"] # 如 "button"
attrs = " ".join(f'{k}="{v}"' for k, v in node.get("attributes", {}).items())
kids = "".join(render(c) for c in node.get("children", [])) # 递归子节点
return f'<{tag} {attrs}>{node.get("text","")}{kids}</{tag}>'
# 重点看:children 递归 + 只拼白名单里剩下的属性
3.5 分屏截图:另一只眼睛
要解决的小问题: 光有元素树不够——布局、视觉重点、验证码之类得靠"看"。但一屏截不全长页面。
思路: take_split_screenshots(page.py:601)从顶开始,滚一屏截一张,直到到底或达上限
MAX_NUM_SCREENSHOTS。判定"到底了"靠比较滚动前后的 scroll_y(相等即到底,也顺便绕开了弹窗
卡住无法滚动的情况,见 scraper.py:418-421 的注释)。
一个省钱细节: 若精简后的元素树 HTML 的 token 数已经超过 DEFAULT_MAX_TOKENS,就把截图数量
压到最多 1 张(scraper.py:458-460)——元素树已经够大了,别再让截图雪上加霜。截完还会滚回原来的
x,y 位置(scraper.py:503-505),不打扰后续操作。
注:
draw_boxes(在元素上画边框)参数已废弃,新代码不该再传(scraper.py:198-201、392-395)。
3.6 元素去重与索引:hash_element
要解决的小问题: 页面上常有一模一样的元素(重复的卡片、列表项);还需要能从 ID 快速反查元素。
思路: build_element_dict(scraper.py:168)一次遍历建五张表,全以 unique_id 为 key:
| 表 | key → value | 用途 |
|---|---|---|
id_to_css_dict | id → [unique_id='..'] | 反查定位(ch03 执行动作) |
id_to_element_dict | id → 元素 dict | 从 ID 拿回完整元素 |
id_to_frame_dict | id → 帧名 | 知道元素在哪个 iframe |
id_to_element_hash | id → 内容哈希 | 判"同一个元素" |
hash_to_element_ids | 哈希 → 一组 id | 找出内容相同的元素 |
哈希怎么算(hash_element,scraper.py:160): 先 clean_element_before_hashing
(scraper.py:146)剥掉易变字段——id、rect(坐标)、frame_index、还有 unique_id
属性本身——再对剩下的内容 json.dumps(sort_keys=True) 求 sha256。关键在剥掉 ID 和坐标:这样
"内容相同但 ID/位置不同"的两个元素会算出同一个哈希,从而被识别为重复。
4. 深入实现:iframe 递归与"帧"边界
网页里的 iframe 是独立文档,跨帧不能直接用一个选择器定位。Skyvern 的做法是逐帧建树再拼起来。
流程(get_interactable_element_tree,scraper.py:641):
主帧 build_tree_from_body(frame_name="main.frame", frame_index=0)
│
▼
get_all_children_frames(page) # 广度遍历收集所有子帧 (scraper.py:565)
│
▼
filter_frames(...) # 剔除已分离/被 scrape_exclude 排除的帧 (scraper.py:577)
│
▼
给每个帧分配稳定 frame_index (存进 context.frame_index_map)
│
▼
for each frame: add_frame_interactable_elements(...) # scraper.py:590
│ ├─ 取 iframe 元素自己的 unique_id
│ ├─ 在该帧里 build_tree_from_body 建子树
│ └─ 把子树挂到父 iframe 元素的 children 下 (scraper.py:629-631)
▼
合并成一棵跨帧的大树 + 一个扁平 elements 列表
几个要点:
- 帧序号进 ID:
frame_index传给 JS 后写进GlobalSkyvernFrameIndex(domUtils.js:1830-1835), 于是该帧里生成的每个unique_id第一位都编码了帧号——ID 全局不撞、且自带归属信息。 - 不可见 iframe 直接跳:对不可见的 iframe 调
frame.evaluate()会卡死,所以先查可见性 (scraper.py:602-604)。 - JS 注入时机:每次
SkyvernFrame.create_instance都会把整个domUtils.js注入该帧 (page.py:620-628,源码从scraper.py:130load_js_script读入)——所以每个帧都有自己的buildTreeFromBody等函数可调。 - 上限保护:
buildTreeFromBody设了maxElementNumber = 15000(domUtils.js:1836),防超大页面 把树建爆。
增量扫描(旁支): IncrementalScrapePage(scraper.py:683)配合 JS 的 MutationObserver
(startGlobalIncrementalObserver)只抓新冒出来的 DOM 增量——用于下拉菜单、自动补全这类
"点一下才动态渲染"的场景,避免整页重扫。它复用同一套 build_element_dict / trim_element_tree。
5. 巧妙之处(可借鉴)
- ID 烙进真实 DOM,而非旁存编号:用一个自定义属性
unique_id让"模型看到的树"和"浏览器里的 节点"共享同一把钥匙,定位天然稳定(domUtils.js:1662)。 - 可见性判定处理了一堆真实坑:option 看父、
display:contents递归子、Shadow DOM 强制可见、 零尺寸图标按钮的 hover/伪元素兜底——每一条都是被真实网站教育出来的(domUtils.js:454-565)。 - 哈希前先剥 ID 和坐标:让"内容相同"的重复元素归并成同一哈希,去重不受位置干扰
(
scraper.py:146-165)。 - token 预算联动:元素树太大就自动砍截图数量到 1;超长 href 哈 希占位、base64 整块丢弃——处处
在跟 token 成本较劲(
scraper.py:458-460、scraped_page.py:64-70、scraper.py:948)。 - 三档精简树:
ScrapedPage提供build_element_tree(标准)、build_economy_elements_tree(去掉 SVG 等次要元素,scraped_page.py:318)、build_lean_elements_tree(进一步压 href/图片/ 查询串,scraped_page.py:407)三个精简档位,按调用点的 token 压力选用,并各自带缓存。
6. 边界与局限
- 重试在生产里等于关闭:
scrape_website有重试外壳,但MAX_SCRAPING_RETRIES在 staging/生产 都设为 0(scraper.py:209-211的醒目注释)——scrape 失败基本一次定生死。 - 空白页会直接失败:
about:blank且无有意义子帧时抛ScrapingFailedBlankPage(scraper.py:423-429);扫不到任何元素且不允许空页时抛NoElementFound(scraper.py:512-513)。 - 可交互判定是启发式:
isInteractable是一长串手写规则 + 样式/事件探测,对高度自定义的组件 (诡异的 web component、纯 JS 合成事件)可能漏判或误判(domUtils.js:908-1118)。 - 不可见 iframe 被整帧跳过:其内容完全不进树(
scraper.py:602-604)。 - 哈希靠内容:两个内容与结构都相同、仅位置不同的元素会被判为"同一个",业务上若 它们语义不同,
去重可能过度(
scraper.py:146)。
7. 底座:Playwright 页面从哪来
感知层不自己开浏览器,它拿的是别人开好的页面。三层底座:
| 部件 | 职责 | 依据 |
|---|---|---|
BrowserContextFactory | 按平台/配置创建 Playwright 浏览器上下文(浏览器参数、下载目录、偏好) | browser_factory.py:392、build_browser_args:426 |
BrowserState | 持有当前上下文与工作页面,管理 must_get_working_page 等 | browser_state.py:15(Protocol),实现见 real_browser_state.py |
BrowserManager | 按 task / workflow_run 复用或新建 BrowserState | browser_manager.py:15 |
scrape 的入口拿页面(scraper.py:415):
# scrape_web_unsafe 开头 —— 必须先有工作页面才能扫
page = await browser_state.must_get_working_page()
must_get_working_page 的实现很朴素:拿不到就抛 MissingBrowserStatePage
(real_browser_state.py:272-276)。感知层只关心"给我一个能用的 Page",浏览器怎么起、代理怎么配、
会话怎么复用,全在这三层底座里,与本章解耦。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号 |
|---|---|---|
| 主流程(重试外壳) | skyvern/webeye/scraper/scraper.py | scrape_website |
| 主流程(真正干活) | skyvern/webeye/scraper/scraper.py | scrape_web_unsafe |
| 主帧+子帧建树编排 | skyvern/webeye/scraper/scraper.py | get_interactable_element_tree |
| iframe 递归挂子树 | skyvern/webeye/scraper/scraper.py | add_frame_interactable_elements |
| 收集/过滤子帧 | skyvern/webeye/scraper/scraper.py | get_all_children_frames / filter_frames |
| 精简元素树 | skyvern/webeye/scraper/scraper.py | trim_element / trim_element_tree / _should_keep_unique_id |
| 属性白名单/去 base64 | skyvern/webeye/scraper/scraper.py | _trimmed_attributes / RESERVED_ATTRIBUTES / _trimmed_base64_data |
| 建 id 索引五表 | skyvern/webeye/scraper/scraper.py | build_element_dict |
| 元素哈希去重 | skyvern/webeye/scraper/scraper.py | hash_element / clean_element_before_hashing |
| 增量扫描 | skyvern/webeye/scraper/scraper.py | IncrementalScrapePage |
| 锚点属性名 | skyvern/constants.py | SKYVERN_ID_ATTR (= "unique_id") |
| 元素树→HTML | skyvern/webeye/scraper/scraped_page.py | json_to_html |
| 最终产物模型 | skyvern/webeye/scraper/scraped_page.py | ScrapedPage |
| 三档精简树 | skyvern/webeye/scraper/scraped_page.py | build_element_tree / build_economy_elements_tree / build_lean_elements_tree |
| 可见性判定 | skyvern/webeye/scraper/domUtils.js | isElementVisible / isHidden |
| 可交互判定 | skyvern/webeye/scraper/domUtils.js | isInteractable |
| 遍历 DOM 建树 | skyvern/webeye/scraper/domUtils.js | buildTreeFromBody / buildElementTree |
| 烙 unique_id / 抽属性 | skyvern/webeye/scraper/domUtils.js | buildElementObject / uniqueId |
| 分屏截图 | skyvern/webeye/utils/page.py | take_split_screenshots |
| 帧内建树(注入 JS) | skyvern/webeye/utils/page.py | SkyvernFrame.build_tree_from_body / create_instance |
| 拿工作页面 | skyvern/webeye/real_browser_state.py | must_get_working_page |
| 浏览器上下文工厂 | skyvern/webeye/browser_factory.py | BrowserContextFactory |
下一章: 模型拿到这棵带 ID 的 HTML 树和截图后怎么规划动作 → ch02 决策层;
动作又如何凭 unique_id 精确落到真实元素 → ch03 执行层。