跳到主要内容

纯文本 DOM 脱水:从活 DOM 到带编号的元素表

30 秒导读: 一个网页里的 GUI Agent,要么"看截图"要么"读文本"。Page Agent 走的是读文本这条路——它把浏览器里活生生的 DOM 树,压缩成一张像这样的表:[5]<a role=button>快速开始 />。每个可交互元素前面一个 [编号],LLM 只要说"点 5 号",系统就能精确落到那个真实的 <a> 上。本章讲的就是这套"脱水"管线:DOM 怎么变成文本 + 一张 编号 → 真实元素 的映射表。

本章只讲 "读"(DOM → 文本 + selectorMap);"写"(拿到编号后怎么真正点击/输入)属于 工具层与动作落地。整个 Agent 大循环见 ReAct 反思循环


1. 这是什么(零基础也能懂)

先讲清楚要解决的问题

大语言模型看不见网页。你把一个电商页面丢给它,它拿到的要么是一张图片(需要视觉模型 + 坐标定位),要么是一坨几万行的 HTML(充满 <div class="css-1x2y3z">、内联样式、脚本,90% 是噪音)。

两条路都不好走。截图贵、坐标不稳;原始 HTML 太长、太脏,还没告诉模型"哪些能点"。

Page Agent 的答案:脱水成一张编号表

Page Agent 选择不用截图。它把 DOM 树"脱水"(dehydrate)——挤掉所有对决策没用的水分,只留下:

  • 能交互的元素(链接、按钮、输入框、可滚动区……),每个配一个从 0 开始的编号;
  • 可见的正文文本(让模型知道页面在讲什么);
  • 父子缩进(让模型看懂结构层级)。

最后产出一段纯文本,长这样:

[0]<a aria-label=page-agent.js 首页 />
[1]<div >P />
[2]<div >page-agent.js
UI Agent in your webpage />
[3]<a >文档 />
[4]<a aria-label=查看源码(在新窗口打开)>源码 />
UI Agent in your webpage
用户输入需求,AI 理解页面并自动操作。
[5]<a role=button>快速开始 />
[6]<a role=button>查看文档 />
无需后端

(这段真实样例就抄自源码里的文档注释 packages/page-controller/src/dom/index.ts:180-186。)

怎么读这张表

记号含义
[5]这是第 5 号可交互元素,LLM 可以用 "5" 来指名操作
*[5]带星号 = 相比上一帧新出现的元素(见 §6)
<a role=button>标签名 + 保留下来的关键属性
>快速开始该元素的可读文本
/>一行的结束标记(仿 JSX 自闭合,纯粹为了好断行)
缩进(Tab)父子层级:缩进更深 = 是上面某元素的后代
没有 [n] 的裸文本行页面上的普通可见文字(如 "无需后端")

一句话直觉

把 DOM 想成一棵枝繁叶茂的大树,脱水就是"修剪 + 挂号牌": 砍掉看不见、点不了的枝叶,给还留着的、能操作的果子挂上 [0] [1] [2] 的号牌,再拍平成一张清单交给 LLM。LLM 报号,系统凭号牌找回真实那颗果子。


2. 顶层全景(它大概怎么转)

一次"读页面"的完整数据流

从 Agent 循环里的一句 await pageController.getBrowserState() 开始,到 LLM 手里那段文本,数据这样流动:

getBrowserState() ← LLM 每一步都调它拿页面

┌────────────┼─────────────────────────┐
│ │ │
getPageInfo() updateTree() 组装 header/footer
量滚动位置 │ (标题+页面尺寸+
(纯几何) ▼ "上面还有N像素")
┌──────────────┐
│ getFlatTree │ ← 调 dom_tree/index.js 这个提取引擎
│ (活 DOM → │ 遍历真实 DOM,判定"可交互",
│ FlatDomTree)│ 发 highlightIndex,存直接引用 ref
└──────┬───────┘
│ FlatDomTree = { rootId, map:{ id → 节点 } }
┌────────────┼──────────────────────┐
▼ ▼ ▼
flatTreeToString getSelectorMap getElementTextMap
拍平成缩进文本 编号 → 真实元素 编号 → 那一行文本
(给 LLM 读) (给动作层落地) (给动作层做提示)
│ │ │
▼ ▼ ▼
simplifiedHTML selectorMap elementTextMap
(content) Map<int, node> Map<int, string>

怎么读这张图: 上半部分是"入口",getBrowserState 是 LLM 的唯一取数口;中间 getFlatTree 是重活(遍历真实 DOM);下半部分是同一棵 FlatDomTree 被消费三次——一次变文本给模型看,一次变映射表给"写"用,一次变文本映射做辅助。

部件一句话职责

部件干什么在哪个文件(符号)
getBrowserStateLLM 取数总入口,组装 header/content/footerpackages/page-controller/src/PageController.ts:129
updateTree刷新一帧:清高亮→提取→拍平→建三张表packages/page-controller/src/PageController.ts:174
getFlatTree调提取引擎,标记"新元素"packages/page-controller/src/dom/index.ts:57
dom_tree/index.js(buildDomTree)真正的提取引擎,遍历活 DOMpackages/page-controller/src/dom/dom_tree/index.js:1472
flatTreeToStringFlatDomTree → 缩进编号文本packages/page-controller/src/dom/index.ts:193
getSelectorMap编号 → 真实元素节点packages/page-controller/src/dom/index.ts:496
getElementTextMap编号 → 那一行文本packages/page-controller/src/dom/index.ts:510
getPageInfo量视口/页面尺寸/滚动位置packages/page-controller/src/dom/getPageInfo.ts:1
cleanUpHighlights清掉上一帧画的高亮框packages/page-controller/src/dom/index.ts:528

主线走一遍(高层)

getBrowserState() 内部按这个顺序跑(PageController.ts:129-165):

  1. urltitlegetPageInfo()(滚动几何);
  2. await updateTree() —— 重活在这:提取 DOM、拍平文本、建三张表;
  3. simplifiedHTMLcontent;
  4. 用 pageInfo 拼 header(标题 + 页面尺寸 + "上面还有多少")和 footer("下面还有多少" / [End of page]);
  5. 返回 { url, title, header, content, footer }

后面各节就把这条主线上的关键环节逐个拆开。


3. 核心引擎:dom_tree/index.js——从活 DOM 到 FlatDomTree

这节讲最底层那台"提取机":它遍历浏览器里真实的 DOM,判定谁能交互、给能交互的发编号,最后吐出一个扁平的 FlatDomTree

3.1 它从哪来:browser-use 的移植 + 一堆 @edit

这个文件不是从零写的,而是从著名开源项目 browser-use(Python 版浏览器 Agent)的同名脚本移植过来的。文件头写得很清楚(dom_tree/index.js:1-22):

// 示意,非源码(摘自文件头注释)
/**
* @file port from browser-use
* @match 0.5.9 d51b6e73daff7165fdd3e44debd667e7f5f7fdc5
* search @edit for all the changed lines.
*/

所以读这个文件有个诀窍:@edit 就能找到 Page Agent 相对上游的所有改动。主要改了这些:

@edit 改动意图位置
export + 可传 interactiveBlacklist/Whitelist让宿主项目能加/减"可交互"判定index.js:707-712
direct dom ref节点上直接挂 ref = node 真实元素引用index.js:1650
no need for xpath砍掉 xpath 生成(有 ref 就不需要)index.js:1603
scrollable element detection新增可滚动容器识别index.js:488
add extra field节点挂 extra 存滚动等附加数据index.js:54-58
exclude aria-hidden跳过 aria-hidden=true 子树index.js:1496
data-browser-use-ignore / data-page-agent-ignore让页面显式标注"别抓我"index.js:1489

为什么强调这个?因为上游更新时,认得 @edit 就知道哪些是本项目的定制、不能被覆盖。这也是本文档"随上游同步"策略的一部分。

3.2 输出长什么样:FlatDomTree

引擎不返回嵌套树,而是一张扁平哈希表(dom_tree/type.ts:4-7):

// 示意,非源码
interface FlatDomTree {
rootId: string // 根节点(body)的 id
map: Record<string, DomNode> // id → 节点,所有节点拍平存这
}

为什么拍平?因为父子关系不靠嵌套对象表达,而靠节点里存子节点的 id 数组(children: string[])。这样整棵树是一个大字典,任意节点 O(1) 取到,序列化也方便(重点看 type.tschildrenstring[] 而非对象数组)。

节点分两类,字段刻意区分 undefinedfalse:

节点类型关键字段说明
文本节点 TextDomNodetype:'TEXT_NODE'textisVisible页面上的一段文字
元素节点 ElementDomNodetagNameattributeschildrenisVisibleisTopElementisInteractivehighlightIndexrefextra一个 DOM 元素

真正遍历的入口是 buildDomTree(document.body)(dom_tree/index.js:1739),它递归下钻,每个节点算好各种布尔标志,塞进 DOM_HASH_MAP,返回 id。

3.3 谁算"可交互"?——isInteractiveElement

这是全引擎最有含量的判定(dom_tree/index.js:699)。它要回答:这个元素,LLM 值不值得给它一个编号去点?判定是多路 OR,命中任一即算可交互:

isInteractiveElement(el)

┌──────────────────────┼───────────────────────────┐
│白/黑名单 │光标是"手型"等 │标签是 a/button/
│(宿主定制,先判) │interactiveCursors │input/select...
│index.js:707 │(pointer/text/move...) │(且没 disabled)
└───────┬───────────────┴───────┬─────────────────────┘
│ │ "Genius fix":光标对了直接算
▼ ▼
┌───────────────────────────────────────────────────┐
│ role/aria-role ∈ interactiveRoles (button/tab...) │
│ contenteditable=true │
│ class 含 button/dropdown-toggle,aria-haspopup... │
│ 有 click/mousedown 等事件监听器(getEventListeners) │
│ isScrollableElement(el) → 可滚动容器也算 │
└───────────────────────────────────────────────────┘

作者自己在注释里点破了最妙的一招(index.js:695,原文英文):"最后发现,最管用的判据竟是看计算出来的 cursor 光标样式"。凡是 cursor: pointer / text / grab / ... 的,基本就是设计者想让用户点的——光标命中直接 return true(index.js:774-784)。

标准表单元素(a/button/input/select/textarea/...)还要额外排除禁用态:光标是 not-allowed、有 disabled/readonly 属性、或 element.disabled 为真,一律判非交互(index.js:816-849)。

3.4 发编号 = highlightIndex,而且要防重复

判定可交互只是第一步,还得决定给不给编号。关键难点:一个可交互元素里面套着另一个可交互元素(比如按钮里嵌了个菜单项),会不会两个都发号、导致 LLM 困惑?

handleHighlighting(index.js:1420)处理这个:

  • 父节点被高亮 → 本节点可高亮;
  • 父节点被高亮 → 只有当本节点是**"独立交互"**(isElementDistinctInteraction,index.js:1292)时才另发号,否则认为"点它等于点父亲",不发号。

只有决定要高亮的,才 highlightIndex++ 拿到下一个号(index.js:1444)。这个自增计数就是文本里 [0] [1] [2]... 的来源。

3.5 viewportExpansion = -1:抓全页,不只视口内

浏览器 Agent 常见做法是只抓"当前视口能看到的"元素。Page Agent 默认反过来——抓整页

默认值定在 index.ts:18:

// 示意,非源码
const DEFAULT_VIEWPORT_EXPANSION = -1 // -1 = 全页;0 = 只视口;正数 = 视口外扩 N 像素

-1 触发几处"短路":

  • isTopElement:视口外的元素本来靠 elementFromPoint 判"是不是最上层",但视口外该 API 返回 null。所以 -1一律当作 top element(index.js:966-970);
  • isTextNodeVisible:-1 时跳过视口相交检测,只查父元素可见性(index.js:566-582);
  • handleHighlighting:-1 时所有可交互元素都发号,不管在不在视口(index.js:1443)。

作者在注释里诚实标注了这个取舍(index.ts:14-17):因为 isTopElement 依赖 elementFromPoint、视口外失效,所以中间那些正数扩展值其实没实际意义,真正有区别的只有 -10


4. 拍平成文本:flatTreeToString

这节讲怎么把上面那棵 FlatDomTree 变成 LLM 真正读到的那段缩进编号文本。核心工作有三件:重建父子、裁属性、发编号行。

对应 browser-use 里的 clickable_elements_to_string(index.ts:171-192 注释指明)。入口 flatTreeToString(flatTree, includeAttributes, keepSemanticTags)(index.ts:193)。

4.1 先把扁平表重建成带 parent 指针的树

拍平表只有"子 id 数组",不能往上走。函数先 buildTreeNode 递归重建成 TreeNode,再 setParentReferences 补上 parent 指针(index.ts:240-295)——因为后面判断"文本要不要输出"需要往上查祖先有没有编号

4.2 一个元素怎么变成一行

对每个highlightIndex 的元素,processNode 拼出那一行(index.ts:315-417)。拼装顺序:

[编号] <tag attr=val attr=val data-scrollable="..." >文本 />
│ │ │ │ │ │
highlight tagName 裁剪后的属性 滚动数据 截到下个 收尾
Indicator (若可滚动) 可交互前的文本

新元素前缀 *[编号],否则 [编号](index.ts:378-381)。缩进用 \t.repeat(depth) 表达父子(index.ts:317)。

4.3 属性裁剪:去噪的关键

原始元素属性一大堆,直接塞进去会淹没 LLM。flatTreeToString 做四层裁剪:

第一层——白名单。 只保留 DEFAULT_INCLUDE_ATTRIBUTES 里那些对交互有意义的(index.ts:198-227):title/type/checked/name/role/value/placeholder/alt/aria-label/aria-expanded/...。其中带 @edit 注释的是本项目加的:idfor(更好处理表单)、target(判断是否新窗口打开)、aria-haspopup/aria-controls/aria-owns(绝对定位的下拉菜单)、contenteditable。匹配支持通配符 *(matchAttributes + globToRegex,index.ts:113-148)。

第二层——值去重。 若多个属性的值一样(且长度 >5),只留第一个。比如 nameid 都是 "submit-button",留一个就够(index.ts:333-352)。

第三层——去掉冗余的 role。 如果 role 的值恰好等于 tagName(比如 <button role=button>),role 是废话,删掉(index.ts:355-357)。

第四层——去掉和文本重复的标签。 这是很聪明的一手:aria-labelplaceholdertitle 若其值和该元素可读文本一模一样,就没必要既写属性又写文本,删属性(index.ts:360-368)。

裁完,每个值还会被 capTextLength(value, 20) 截到 20 字符,防止某个超长属性撑爆一行(index.ts:372)。

4.4 元素文本:采到下一个可交互元素为止

一行里 > 后面的文本,由 getAllTextTillNextClickableElement(index.ts:466)采集。规则:从本元素往下收集所有文本子孙,但一旦碰到另一个带编号的后代就停——因为那段文本属于那个后代,不该并到父亲头上(index.ts:474-481)。

4.5 裸文本:只输出"真正看得见"的

不属于任何编号元素的普通文本(如样例里的 "无需后端"),要不要单独成行?条件很严(index.ts:443-457):

  1. 祖先里没有任何带编号的元素(hasParentWithHighlightIndex 为假)——否则它已经被上面某个 [n] 的文本吸收了;
  2. 直接父元素 isVisible isTopElement

两条都满足才作为裸文本行输出。这样保证 LLM 读到的每段文字都是"真的能看见、且不重复"的。

4.6 data-scrollable:告诉 LLM "这块能滚"

§3.3 提过可滚动容器也算可交互。到了文本阶段,若节点 extra.scrollable 为真,行里会注入一段合成属性(index.ts:390-402):

[12]<div data-scrollable="top=0, bottom=840" ...>... />

top/bottom/left/right 是四个方向还能滚多少像素(数据来自 isScrollableElement,dom_tree/index.js:537-547)。这让 LLM 知道"这个区域内部还能往下滚 840 像素",从而能对局部滚动容器下指令,而不只会滚整个窗口。

4.7 语义标签(可选)

若开 keepSemanticTags,nav/header/footer/dialog/menu/aside 这些地标标签即使不可交互也会以 <nav> ... </nav> 形式保留结构(index.ts:40-50419-442),空标签会被回收删掉。默认关闭——注释也提醒(index.ts:33-36)它和分页滚动一起用可能让 LLM 困惑,慎用。


5. 编号回真身:getSelectorMapgetElementTextMap

LLM 说"点 5 号",系统怎么知道 5 号是页面上哪个真元素?靠这两张辅助表。文本给模型看,映射表给动作层用。

5.1 getSelectorMap:编号 → 真实元素节点

遍历 FlatDomTree,凡是"可交互且有 highlightIndex"的节点,存进 Map<number, InteractiveElementDomNode>(index.ts:496-508):

// 示意,非源码
for (const key of Object.keys(flatTree.map)) {
const node = flatTree.map[key]
if (node.isInteractive && typeof node.highlightIndex === 'number') {
selectorMap.set(node.highlightIndex, node) // 5 → 那个节点(含 ref 真实元素)
}
}

这里 node.ref 就是 §3.1 提到的 @edit direct dom ref——直接握着真实 DOM 元素,不用 xpath 反查。动作层拿编号从这张表取到 ref,就能真正 .click()(落地细节见 03 章)。

5.2 getElementTextMap:编号 → 那一行文本

这张表反过来从已生成的文本里正则抠出来(index.ts:510-526):

// 示意,非源码
const regex = /^\[(\d+)\]<[^>]+>([^<]*)/ // 抓行首的 [数字] 和其后文本

它把每个 [编号] 开头的行原样存成 编号 → 整行字符串。用途是给动作结果做人类可读提示:比如点击成功后返回 "✅ Clicked element ([5]快速开始 />)"(见 PageController.ts:248,255),让日志和 LLM 都看得懂到底点了啥。

注意这张表只认 [n]<...> 开头的行,*[n] 新元素行因为多了个星号,当前正则匹配不到——是个已知的小取舍(inferred,由正则 ^\[ 推断)。

5.3 三张表在 updateTree 里一起建

updateTree(PageController.ts:174-219)是一帧刷新的编排者,顺序:

  1. beforeUpdate 事件、记时间戳;
  2. 若开了遮罩,临时放行鼠标事件(遮罩细节见 05 章);
  3. cleanUpHighlights() 清上一帧的高亮框;
  4. 组装黑名单(config 里的 + 页面上标了 data-page-agent-not-interactive 的);
  5. getFlatTree(...) 提取;
  6. flatTreeToString(...)simplifiedHTML;
  7. getSelectorMapselectorMapgetElementTextMapelementTextMap(先各自 .clear());
  8. isIndexed = true(之后动作才被允许,否则 assertIndexed 抛错,PageController.ts:235);
  9. afterUpdate 事件。

6. 新元素标记:*[index]newElementsCache

这节讲那个星号。为什么要标"新元素"?因为 LLM 常关心"我上一步操作后,页面冒出了什么新东西"(比如点了下拉,选项弹出来了)。

标记逻辑在 getFlatTree(index.ts:57-111)里。核心是一个模块级的 WeakMap(index.ts:55):

// 示意,非源码
const newElementsCache = new WeakMap<HTMLElement, string>()

每次提取后,遍历所有可交互节点的 ref:如果这个真实元素之前没见过(不在 WeakMap 里),就记下它、并把节点标 isNew = true(index.ts:97-107)。于是 flatTreeToString 给它加星号 *[index]

用 WeakMap 的好处:元素被 GC 回收时缓存自动清,不漏内存。key 是真实元素引用而非编号,所以即使编号重排,同一个元素也不会被误判成新的。

代码里也坦白了当前实现的局限(index.ts:91-108 注释):

  • browser-use 用内容 hash(位置+属性)判同一元素,能应对"元素删了又加""跨页面复用"等情况;这里只用引用相等,是"先简单做";
  • 有一段被注释掉的"按 URL 区分"逻辑,作者判断"太严格,元素可以跨页面存在",所以放弃。

一句话:星号是"和上一帧比,这是新面孔"的启发式提示,尽力而为、不保证精确——这符合项目 "traceability over success rate" 的调性。


7. 页面几何与 header/footer:让 LLM 知道"还能往哪滚"

光有元素表还不够,LLM 得知道"页面多大、我现在滚到哪、上下还有没有内容"。这由 getPageInfo + getBrowserState 的拼装完成。

7.1 getPageInfo:纯几何测量

getPageInfo(getPageInfo.ts:1-42)是无副作用的量尺,读几个 DOM 尺寸算出:

字段含义
viewport_width/height视口大小(window.innerWidth/Height)
page_width/height整页大小(scrollWidth/scrollHeight 取最大)
scroll_x/y当前滚动偏移
pixels_above/below视口上方/下方还有多少像素没露出
pages_above/below/total_pages上述换算成"多少屏"
current_page_position当前滚到全页的百分比

pixels_below = max(0, page_height - (innerHeight + scroll_y))(getPageInfo.ts:14)——就是"页面总高减去已经看到的部分"。

7.2 getBrowserState 拼 header/footer

getBrowserState(PageController.ts:129-165)把几何数字翻译成给 LLM 的自然语言提示。

header 三段拼起来(PageController.ts:140-155):

Current Page: [标题](https://...)
Page info: 1280x800px viewport, 1280x3000px total page size, 0.0 pages above, 2.8 pages below, 3.8 total pages, at 0% of page

Interactive elements from top layer of the current page (full page):

[Start of page]
  • 第二行是 pageInfo 的自然语言化;
  • 那句 "Interactive elements ... (full page)" 还是 "(... inside the viewport)",取决于 viewportExpansion 是不是 -1(PageController.ts:144-147)——默认全页;
  • 末尾 [Start of page]:全页模式或已在顶部时显示;否则显示 "... N pixels above - scroll to see more ..."(PageController.ts:149-153)。

footer(PageController.ts:158-162)同理:下方还有内容且非全页时提示 "... N pixels below - scroll to see more ...",否则 [End of page]

最终 { url, title, header, content, footer } 交回 Agent 循环。LLM 读到的完整一屏,就是 header + content(编号表) + footer。注意:默认全页模式下,滚动提示基本恒为 [Start/End of page]——因为一次就把整页元素都给了,滚动更多是留给"局部可滚容器"(§4.6 的 data-scrollable)用的。


8. 高亮的生命周期:画框与清框

提取时引擎会在页面上真画出彩色边框 + 编号标签(调试可见,也用于遮罩下的视觉反馈)。本节只讲"读"侧关心的:这些框怎么被清掉,避免污染下一帧。

highlightElement(dom_tree/index.js:167)为每个发号元素画一个 fixed 定位的彩框 + 角标,并注册 scroll/resize 时重定位的节流回调,把清理函数推进全局数组 window._highlightCleanupFunctions(index.js:405-412)。

cleanUpHighlights(index.ts:528-537)就是把这个数组里的清理函数全跑一遍、清空。它在两处被调:

  • updateTree 开头(PageController.ts:184)——每次刷新先清旧框;
  • URL 一变就清:监听 popstate/hashchange/beforeunload,以及优先用 Navigation API、退化则 500ms 轮询 URL(index.ts:540-569)。因为 SPA 换页后旧框的坐标全废了,必须立刻清掉。

这几处 @edit 让高亮不会"跨页残留"——是"读"管线保持每帧干净的收尾细节。


9. 巧妙之处(可带走的技术)

  • cursor 光标当"可交互"的主判据。 与其枚举无穷的标签/role/事件,不如信设计者的光标意图:cursor:pointer 基本等于"可点"。一招覆盖绝大多数场景(dom_tree/index.js:774-784,作者自称 "Genius fix")。

  • 属性"和文本重复就删"。 aria-label 值 == 可读文本时,只留文本。省 token 又不丢信息(index.ts:360-368)。

  • 节点直接挂真实元素 ref,弃用 xpath。 @edit direct dom ref 让"编号 → 真身"是 O(1) 的引用,不必 xpath 反查、也不怕 xpath 因 DOM 变动失效(dom_tree/index.js:1650index.ts:502-503)。

  • WeakMap 追踪"新元素"。 以真实元素为 key,GC 自动清、跨帧稳定,不受编号重排影响(index.ts:55)。

  • data-scrollable 合成属性。 把"内部还能滚多少"编进文本,让 LLM 能操作局部滚动容器而非只会滚窗口(index.ts:390-402)。

  • 全页模式 -1 + 诚实标注局限。 默认抓整页省去多轮滚动;同时在注释里直说"中间的正数扩展值没实际意义",不掩盖设计约束(index.ts:14-17)。


10. 边界与局限(诚实)

  • 纯 DOM,不理解视觉。 靠 CSS 背景图画的按钮、canvas 里的控件、纯视觉状态(颜色变化)一律读不到——这是"不用截图"的固有代价。

  • isTopElement 的正数视口扩展形同虚设。 因依赖 elementFromPoint,视口外返回 null,所以实际只有 -1(全页)和 0(视口)两档有意义(index.ts:14-17)。

  • "新元素"判定很粗。 只按引用相等,处理不了"删了又加""内容变了但还是同一元素"等,作者明确标注是"先简单做"(index.ts:91-96)。

  • getElementTextMap 不认 *[n] 行。 正则 ^\[ 匹配不到带星号的新元素行(inferred,index.ts:517)。

  • 可滚动检测有阈值。 小于 4 像素的可滚距离当作不可滚(dom_tree/index.js:523),极小的滚动区可能被忽略。

  • getEventListeners 依赖调试环境。 靠事件监听器判交互的分支在普通页面上下文常拿不到监听器,只能退化到 onclick 等属性检测(dom_tree/index.js:902-948)。


11. 横向对比

维度Page Agent(本章)browser-use(上游)
运行位置浏览器页内(注入的库)外部 Playwright 驱动
元素定位直接 ref(DOM 引用)xpath / css selector
提取脚本移植自 browser-use,带 @edit原版 dom_tree/index.js
默认范围全页(viewportExpansion=-1)常用视口内

同 shelf 的其它浏览器 Agent 若走视觉路线(截图 + 坐标),则完全不需要本章这套 DOM 脱水——两条路的分野,正是本章开头那个"看图 vs 读文本"的选择。


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

主题文件符号
取数总入口 / header-footer 组装packages/page-controller/src/PageController.tsgetBrowserState
一帧刷新编排packages/page-controller/src/PageController.tsupdateTree
索引前置校验packages/page-controller/src/PageController.tsassertIndexed
调提取引擎 + 标记新元素packages/page-controller/src/dom/index.tsgetFlatTreenewElementsCache
视口扩展默认值packages/page-controller/src/dom/index.tsDEFAULT_VIEWPORT_EXPANSIONresolveViewportExpansion
FlatDomTree → 文本packages/page-controller/src/dom/index.tsflatTreeToString
属性通配匹配packages/page-controller/src/dom/index.tsmatchAttributesglobToRegex
采文本到下个可交互元素packages/page-controller/src/dom/index.tsgetAllTextTillNextClickableElement
编号 → 真实元素packages/page-controller/src/dom/index.tsgetSelectorMap
编号 → 行文本packages/page-controller/src/dom/index.tsgetElementTextMap
清高亮 + URL 变化监听packages/page-controller/src/dom/index.tscleanUpHighlights
DOM 遍历引擎packages/page-controller/src/dom/dom_tree/index.jsbuildDomTree
可交互判定packages/page-controller/src/dom/dom_tree/index.jsisInteractiveElement
发编号 / 去重packages/page-controller/src/dom/dom_tree/index.jshandleHighlightingisElementDistinctInteraction
最上层判定packages/page-controller/src/dom/dom_tree/index.jsisTopElement
可滚动检测packages/page-controller/src/dom/dom_tree/index.jsisScrollableElement
画高亮框packages/page-controller/src/dom/dom_tree/index.jshighlightElement
页面几何测量packages/page-controller/src/dom/getPageInfo.tsgetPageInfo
树类型定义packages/page-controller/src/dom/dom_tree/type.tsFlatDomTreeInteractiveElementDomNode