跳到主要内容

感知层:把网页变成 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 的 HTMLscraped_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 怎么生成(uniqueIddomUtils.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

第一关 可见性 isElementVisibledomUtils.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)。

第二关 隐藏 isHiddendomUtils.js:713display:nonehidden 属性,但给"其实是提交 按钮却带 hidden 属性"开了后门(:718-728)。

第三关 可交互 isInteractabledomUtils.js:908 一长串规则,本质是"这个东西点了/填了有用吗":

命中即算可交互依据
有 ARIA widget roledomUtils.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=optiondiv/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=falsedomUtils.js:1948-1978)——它们给模型提供定位语境 (比如"这个输入框旁边的 label 写着什么")。

3.3 精简:从"全量元素树"到"喂 LLM 的小树"

要解决的小问题: 就算只留可交互元素,属性还是太多(一堆 classstyledata-*)。要再瘦身。

思路:分两步。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_elementscraper.py:883)干三件事:

  1. 删定位字段frame / frame_index 从喂模型的树里删掉(模型不需要)。
  2. 丢无用 ID_should_keep_unique_idscraper.py:857)决定这个节点要不要留 id——只有 可交互的、或带 disabled/readonly 状态的、或 hover 才显形的才留;纯结构性节点丢掉 ID 省 token。
  3. 砍属性_trimmed_attributesscraper.py:959)只保留一张白名单 RESERVED_ATTRIBUTESscraper.py:67,如 href/type/placeholder/aria-label/value/name 等);_trimmed_base64_datascraper.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_mapscraped_page.py:64-70)——省 token,需要时再还原。
  • 私有区字符替换成 [icon]:图标字体常用 Unicode 私有区码点,_replace_pua_with_markerscraped_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_screenshotspage.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-201392-395)。

3.6 元素去重与索引:hash_element

要解决的小问题: 页面上常有一模一样的元素(重复的卡片、列表项);还需要能从 ID 快速反查元素。

思路: build_element_dictscraper.py:168)一次遍历建五张表,全以 unique_id 为 key:

key → value用途
id_to_css_dictid → [unique_id='..']反查定位(ch03 执行动作)
id_to_element_dictid → 元素 dict从 ID 拿回完整元素
id_to_frame_dictid → 帧名知道元素在哪个 iframe
id_to_element_hashid → 内容哈希判"同一个元素"
hash_to_element_ids哈希 → 一组 id找出内容相同的元素

哈希怎么算(hash_elementscraper.py:160):clean_element_before_hashingscraper.py:146)剥掉易变字段——idrect(坐标)、frame_index、还有 unique_id 属性本身——再对剩下的内容 json.dumps(sort_keys=True) 求 sha256。关键在剥掉 ID 和坐标:这样 "内容相同但 ID/位置不同"的两个元素会算出同一个哈希,从而被识别为重复。


4. 深入实现:iframe 递归与"帧"边界

网页里的 iframe 是独立文档,跨帧不能直接用一个选择器定位。Skyvern 的做法是逐帧建树再拼起来

流程(get_interactable_element_treescraper.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 列表

几个要点:

  • 帧序号进 IDframe_index 传给 JS 后写进 GlobalSkyvernFrameIndexdomUtils.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:130 load_js_script 读入)——所以每个帧都有自己的 buildTreeFromBody 等函数可调。
  • 上限保护buildTreeFromBody 设了 maxElementNumber = 15000domUtils.js:1836),防超大页面 把树建爆。

增量扫描(旁支): IncrementalScrapePagescraper.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-460scraped_page.py:64-70scraper.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 且无有意义子帧时抛 ScrapingFailedBlankPagescraper.py:423-429);扫不到任何元素且不允许空页时抛 NoElementFoundscraper.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:392build_browser_args:426
BrowserState持有当前上下文与工作页面,管理 must_get_working_pagebrowser_state.py:15(Protocol),实现见 real_browser_state.py
BrowserManager按 task / workflow_run 复用或新建 BrowserStatebrowser_manager.py:15

scrape 的入口拿页面(scraper.py:415):

# scrape_web_unsafe 开头 —— 必须先有工作页面才能扫
page = await browser_state.must_get_working_page()

must_get_working_page 的实现很朴素:拿不到就抛 MissingBrowserStatePagereal_browser_state.py:272-276)。感知层只关心"给我一个能用的 Page",浏览器怎么起、代理怎么配、 会话怎么复用,全在这三层底座里,与本章解耦。


8. 代码地图(导航索引)

主题文件路径符号
主流程(重试外壳)skyvern/webeye/scraper/scraper.pyscrape_website
主流程(真正干活)skyvern/webeye/scraper/scraper.pyscrape_web_unsafe
主帧+子帧建树编排skyvern/webeye/scraper/scraper.pyget_interactable_element_tree
iframe 递归挂子树skyvern/webeye/scraper/scraper.pyadd_frame_interactable_elements
收集/过滤子帧skyvern/webeye/scraper/scraper.pyget_all_children_frames / filter_frames
精简元素树skyvern/webeye/scraper/scraper.pytrim_element / trim_element_tree / _should_keep_unique_id
属性白名单/去 base64skyvern/webeye/scraper/scraper.py_trimmed_attributes / RESERVED_ATTRIBUTES / _trimmed_base64_data
建 id 索引五表skyvern/webeye/scraper/scraper.pybuild_element_dict
元素哈希去重skyvern/webeye/scraper/scraper.pyhash_element / clean_element_before_hashing
增量扫描skyvern/webeye/scraper/scraper.pyIncrementalScrapePage
锚点属性名skyvern/constants.pySKYVERN_ID_ATTR (= "unique_id")
元素树→HTMLskyvern/webeye/scraper/scraped_page.pyjson_to_html
最终产物模型skyvern/webeye/scraper/scraped_page.pyScrapedPage
三档精简树skyvern/webeye/scraper/scraped_page.pybuild_element_tree / build_economy_elements_tree / build_lean_elements_tree
可见性判定skyvern/webeye/scraper/domUtils.jsisElementVisible / isHidden
可交互判定skyvern/webeye/scraper/domUtils.jsisInteractable
遍历 DOM 建树skyvern/webeye/scraper/domUtils.jsbuildTreeFromBody / buildElementTree
烙 unique_id / 抽属性skyvern/webeye/scraper/domUtils.jsbuildElementObject / uniqueId
分屏截图skyvern/webeye/utils/page.pytake_split_screenshots
帧内建树(注入 JS)skyvern/webeye/utils/page.pySkyvernFrame.build_tree_from_body / create_instance
拿工作页面skyvern/webeye/real_browser_state.pymust_get_working_page
浏览器上下文工厂skyvern/webeye/browser_factory.pyBrowserContextFactory

下一章: 模型拿到这棵带 ID 的 HTML 树和截图后怎么规划动作 → ch02 决策层; 动作又如何凭 unique_id 精确落到真实元素 → ch03 执行层