跳到主要内容

给模型装眼睛:DOM 树 → 带编号的可交互元素清单

30 秒导读: LLM 读不懂一张渲染出来的网页,它只会读文本。这一章讲 Nanobrowser 怎么把一整棵活的 DOM 树"翻译"成模型能读的一小段文本——一份形如 [5]<button>Submit />带编号可交互元素清单。那个编号 highlightIndex 是全章的主角:它是模型与真实 DOM 之间的桥接编号,模型只要说"点 5 号",系统就能凭编号找回那个真实的 <button>

本章只讲感知:页面怎么变成文本。至于模型说了"点 5 号"之后,点击/输入如何真正落到那个元素上,是下一章的事;注入进页面的那段文本如何被安全包裹(防提示注入),留给第 4 章


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

一句话定义

感知层 = 给模型装眼睛。 它把浏览器里一张活生生的网页,压缩成一段模型读得懂的纯文本清单,并给每个"能点/能填"的元素编上号。

它要解决的问题

想象你要让 AI 去操作一个网页。网页在浏览器里是一棵庞大的 DOM 树——几千个节点、样式、脚本、看不见的容器。可 LLM 的输入只是文本,它:

  • 看不到渲染后的画面(纯文本模型没有像素);
  • 就算把整段 HTML 塞给它,也太长(几十万 token,烧钱又超窗口),而且噪音太大(布局 div、隐藏节点、追踪脚本全混在一起)。

所以需要有人先替模型"看一眼",把页面里真正能交互的东西挑出来,排成一份短清单。这份清单要满足两个矛盾的要求:够短(省 token)、又够全(别漏掉按钮)。

用起来什么样

模型最终读到的不是 HTML,而是这样一段东西(简化示例):

[Start of page]
[0]<a href=/home>Home />
[1]<button type=submit>Search />
[2]<input placeholder=Search... />
Some plain text that is not clickable
[3]<a>Sign in />
[End of page]

每行开头的 [数字] 就是 highlightIndex。模型想搜索,就输出一个动作 { click_element: { index: 1 } }——它永远只跟编号打交道,从不碰真实的 DOM 节点。

一句话直觉

把它当成"给盲人报菜单": 服务员(感知层)把一整桌看不见的菜(DOM),念成一份带序号的菜单("1 号红烧肉、2 号青菜……")。顾客(LLM)只说"我要 2 号",服务员再凭"2 号"去端那盘真菜。序号就是盲人和真菜之间唯一的桥。


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

主线:从一棵 DOM 到一段文本

整条流水线跨越两个世界:页面世界(注入进网页里跑的 JS)和 后台世界(扩展的 service worker 里跑的 TypeScript)。数据在两界之间以一份"扁平 JSON"传递。

后台世界 (TypeScript) 页面世界 (注入的 JS)
───────────────────── ────────────────────
getClickableElements()

├─ injectBuildDomTreeScripts() ──注入──► buildDomTree.js
│ │
│ chrome.scripting.executeScript ──调用──► window.buildDomTree(args)
│ │ 遍历 document.body
│ │ · 判定可交互 → 发 highlightIndex
│ │ · (可选) 在页面上画高亮框
│ ▼
│ ◄──────── 返回扁平 JSON {rootId, map} ────┘

_constructDomTree() 把扁平 map 重建成对象树
│ 同时填 selectorMap: {highlightIndex → 节点}

DOMState { elementTree, selectorMap }


clickableElementsToString() 把树渲染成 [index]<tag …/> 文本


buildBrowserStateUserMessage() 拼上 scroll/tab 信息, 包裹后送给 LLM

怎么读这张图: 从上往下是一次感知的完整流程。左半边(后台 TypeScript)负责调度和重建,右半边(页面 JS)负责真正接触 DOM。中间那根"返回扁平 JSON"是两个世界唯一的接缝——因为注入脚本和后台不能直接共享对象,只能传可序列化的 JSON。

部件一句话职责

部件干什么在哪个文件
getClickableElements感知层对外的入口,返回 DOMStatebrowser/dom/service.ts:93
buildDomTree.js注入页面、遍历真实 DOM、分配 highlightIndex、可选画高亮chrome-extension/public/buildDomTree.js:1
_constructDomTree把页面返回的扁平 JSON 重建成对象树,并填 selectorMapbrowser/dom/service.ts:408
DOMElementNode树里每个元素节点的类,承载 highlightIndex 等状态browser/dom/views.ts:68
clickableElementsToString把树渲染成 [index]<tag attrs>text /> 文本(省 token 的核心)browser/dom/views.ts:210
selectorMapMap<highlightIndex, 节点>——编号回查真实节点的桥browser/dom/views.ts:559
buildBrowserStateUserMessage把元素清单 + 滚动信息 + tab 列表拼成给 LLM 的 state 消息agent/prompts/base.ts:29

桥接编号:highlightIndex 是什么

在往下钻之前先钉死一个概念,后面全靠它:

highlightIndex 是模型与真实 DOM 之间的桥接编号。 它在两个地方各出现一次,把两个世界缝在一起:

  • 给模型的文本里,它是每行开头的 [5]——模型能读、能引用的唯一句柄;
  • selectorMap 里,它是 key——系统凭它 selectorMap.get(5) 拿回真实的 DOMElementNode

模型永远不知道 xpath、CSS selector 这些"真地址",它只认这个整数。真地址是系统的事(03 章用它去点击)。


3. 核心原理(逐个机制,由浅入深)

3.1 把脚本注入页面:两个世界怎么通信

要解决的小问题: 后台的 TypeScript 想遍历页面 DOM,但它跑在 service worker 里,根本碰不到网页的 document。怎么办?

思路: 把一段 JS 注入到目标标签页里去跑。那段 JS 能直接访问 document,遍历完把结果序列化成 JSON 传回后台。这就是 buildDomTree.js 存在的理由——它是"派到页面里的侦察兵"。

先看入口。_buildDomTreeabout:blank/chrome:// 这类特殊页直接返回一棵空 body(省事、也点不了),否则先注入脚本、再执行它:

// browser/dom/service.ts:136 —— 先注入,再在页面上下文里调用它
await injectBuildDomTreeScripts(tabId);

const mainFrameResult = await chrome.scripting.executeScript({
target: { tabId },
func: args => window.buildDomTree(args), // 在页面里跑
args: [{ showHighlightElements, focusHighlightIndex: focusElement,
viewportExpansion, startId: 0, startHighlightIndex: 0, debugMode }],
});

injectBuildDomTreeScripts(service.ts:596)不傻注:它先用 scriptInjectedFrames(service.ts:582)检查 window.buildDomTree 是否已经存在,只往还没注入的 frame 注,避免重复注入和重复高亮。

注入脚本本身把自己挂到 window.buildDomTree 上(buildDomTree.js:1),接收一组参数——其中 startIdstartHighlightIndex 是为跨 iframe 拼接预留的起始计数器(见 §3.5)。

关键细节: 注入脚本的返回值必须是纯 JSON(可序列化)。所以它绝不能返回真实 DOM 节点对象,只能返回一份"扁平字典" { rootId, map }——map 里每个节点用字符串 id 互相引用(children: ["3","4"]),而不是对象指针。重建成真正的对象树是后台的活(§3.4)。

3.2 哪些元素算"可交互",谁配得到编号

要解决的小问题: 一个页面几千个节点,只有一小撮是"能点能填"的。怎么判定,并且只给它们发编号?

思路: 遍历每个元素时,层层过滤。核心判定链在页面脚本里:

// buildDomTree.js:1384 —— 只有可见 + 顶层(或菜单容器)才检查可交互
nodeData.isVisible = isElementVisible(node);
if (nodeData.isVisible) {
nodeData.isTopElement = isTopElement(node); // 没被别的元素盖住
const role = node.getAttribute('role');
const isMenuContainer = role === 'menu' || role === 'menubar' || role === 'listbox';
if (nodeData.isTopElement || isMenuContainer) {
nodeData.isInteractive = isInteractiveElement(node); // 真能交互吗
nodeWasHighlighted = handleHighlighting(nodeData, node, parentIframe, isParentHighlighted);
}
}

isInteractiveElement(buildDomTree.js:541)是个厚重的判定器:标签白名单(button/a/input…)、role、可交互的鼠标指针样式(doesElementHaveInteractivePointer,buildDomTree.js:611)、甚至尝试用 getEventListeners 探测有没有绑定事件监听器(buildDomTree.js:735)。重点看: 它想尽办法回答"人能不能点它",而不只看标签名。

真正发号在 handleHighlighting 这里藏着两个关键的"省号"规则:

// buildDomTree.js:1183 —— 谁配得到 highlightIndex
function handleHighlighting(nodeData, node, parentIframe, isParentHighlighted) {
if (!nodeData.isInteractive) return false; // ① 不可交互,直接不发号

let shouldHighlight = false;
if (!isParentHighlighted) {
shouldHighlight = true; // 父节点没被高亮 → 我可以高亮
} else {
// ② 父节点已经高亮了:只有我是"独立的交互"才另发号(否则父子重复)
shouldHighlight = isElementDistinctInteraction(node);
}

if (shouldHighlight) {
nodeData.isInViewport = isInExpandedViewport(node, viewportExpansion);
if (nodeData.isInViewport || viewportExpansion === -1) { // ③ 只给视口内的发号
nodeData.highlightIndex = highlightIndex++; // ← 发号!编号自增
// …可选:在页面上画高亮框
}
}
return false;
}

三条"省号"规则,直接决定了清单的干净程度:

规则效果位置
不可交互不发号布局 div、纯文本不进清单buildDomTree.js:1184
父已高亮 + 子非独立交互 → 子不发号避免一个链接里外层 <a> 和内层 <span> 都发号buildDomTree.js:1192
视口外不发号(除非 viewportExpansion === -1)只报当前看得见的,清单随滚动而变buildDomTree.js:1206

关键细节: 编号是遍历顺序(DFS,基本对应视觉从上到下)递增的,highlightIndex++。所以同一个页面不滚动、不变化,编号是稳定的;一旦页面变了,编号会重排——这也是为什么每一步都要重新感知,不能缓存旧编号。

3.3 DOMElementNode:承载编号的节点

要解决的小问题: 后台需要一个类来表示"树里的一个元素",既装 DOM 信息,又装感知层加的状态(编号、是否在视口、是否是新出现的)。

DOMElementNode(views.ts:68)就是这个类。它的字段里,和感知直接相关的是:

字段含义
tagName / attributes标签名和属性(渲染清单时用)
highlightIndex桥接编号;null 表示这个节点没进清单
isInteractive / isTopElement / isInViewport上一节那三道判定的结果
children / parent树结构指针
isNew这个可交互元素相对上一步是不是新出现

isNew 是个有意思的设计——源码注释直说了它的用意:

// views.ts:86 —— isNew 用来告诉模型"状态怎么变了"
/*
### State injected by the browser context.
The idea is that the clickable elements are sometimes persistent from the
previous page -> tells the model which objects are new/_how_ the state has changed
*/
isNew: boolean | null;

渲染时,新元素前缀会带个 *(*[5]<…>),让模型一眼看出"这个是这一步才冒出来的"(见 §3.4 的 highlightIndicator)。

3.4 clickableElementsToString:把树压成省 token 的文本

要解决的小问题: 有了对象树,怎么渲染成那份又短又全的清单?这是整章工程含量最高的一步,核心是省 token

思路: 深度优先遍历这棵树,只为有 highlightIndex 的节点输出一行;其余节点只贡献它们的可见文本。看主循环:

// views.ts:224 —— 只有带 highlightIndex 的节点才生成一行
if (node.highlightIndex !== null) {
const text = node.getAllTextTillNextClickableElement(); // 收集它自己的文字
// …组装 attributesHtmlStr(见下)…
const highlightIndicator = node.isNew ? `*[${node.highlightIndex}]` : `[${node.highlightIndex}]`;
let line = `${depthStr}${highlightIndicator}<${node.tagName}`;
if (attributesHtmlStr) line += ` ${attributesHtmlStr}`;
if (text) line += `>${trimmedText}`;
line += ' />'; // 收尾,注释说这是"1 token"
formattedText.push(line);
}

一行的格式就是 [缩进][index]<tag 属性>文本 />depthStr 用制表符表示层级,让模型能感知嵌套关系。

取文本的巧思: getAllTextTillNextClickableElement(views.ts:184)收集一个可交互元素的文字时,碰到下一个带编号的子元素就停——这样父子不会重复收同一段文字。对应地,渲染纯文本节点时,若它"有一个带编号的祖先"(hasParentWithHighlightIndex,views.ts:42)就跳过,只输出那些不属于任何可交互元素的游离文本(views.ts:328)。

真正的省 token 重头戏——属性三连去噪: 属性(hrefaria-label…)最占字数,所以只保留白名单里的,再做三层裁剪:

步骤做什么位置
白名单过滤只保留 DEFAULT_INCLUDE_ATTRIBUTES 里的属性views.ts:5views.ts:236
值去重多个属性值完全相同时(且长度>5),只留 includeAttributes 里最先出现的那个views.ts:241
role 冗余去除role 和标签名一样(如 <button role=button>)就删掉 roleviews.ts:274
文本冗余去除aria-label/placeholder/title 和元素文本一样,就删掉(别报两遍)views.ts:279
单值截断每个属性值用 capTextLength(value, 15) 截到 15 字符views.ts:292

那段"值去重"逻辑,源码作者自己在注释里坦白它是经验之谈:

// views.ts:241 —— 相同属性值只留一个,省 token
// If value of any of the attributes is the same as ANY other value attribute
// only include the one that appears first in includeAttributes
// WARNING: heavy vibes, but it seems good enough for saving tokens

DEFAULT_INCLUDE_ATTRIBUTES(views.ts:5)本身就是精挑的一串:title/type/name/role/value/placeholder/aria-label/href 等——都是**帮模型判断"这是什么控件、干嘛用"**的高信息属性,布局用的 class/style 一律不进。

3.5 selectorMap:编号回查真实节点的桥

要解决的小问题: 模型输出"点 5 号"后,系统怎么从 5 找回那个真实的 DOMElementNode?

思路: 在重建树的同时,顺手建一张编号 → 节点的哈希表。这就是 selectorMap(类型 Map<number, DOMElementNode>,views.ts:559)。

重建发生在 _constructDomTree(service.ts:408),两趟遍历:第一趟把每个扁平 JSON 节点 _parse_node(service.ts:463)成对象,顺便登记进 selectorMap;第二趟按 children id 把父子指针连起来:

// service.ts:416 —— 第一趟:建节点,带 highlightIndex 的登记进 selectorMap
for (const [id, nodeData] of Object.entries(jsNodeMap)) {
const [node] = _parse_node(nodeData);
if (node === null) continue;
nodeMap[id] = node;
if (node instanceof DOMElementNode && node.highlightIndex != null) {
selectorMap.set(node.highlightIndex, node); // ← 桥的另一端在这里搭好
}
}

到这里,桥的两端都齐了:

给 LLM 的文本 selectorMap (系统私有)
───────────── ───────────────────
[5]<button>Submit /> ◄── 5 ──► 5 → DOMElementNode{tagName:'button', xpath:…}
▲ ▲
模型能读、能引用的 系统能凭它拿到真地址
唯一句柄 (xpath/CSS,交给 03 章去点)

getClickableElements 最终把 { elementTree, selectorMap } 打包成 DOMState(service.ts:109views.ts:557)返回。至此感知完成:一份人读的文本 + 一张机读的回查表,共享同一套 highlightIndex

3.6 跨 iframe:拼一棵完整的树

要解决的小问题: 页面里嵌了 iframe(尤其跨域的),主 frame 的脚本进不去它的 document,里面的按钮会漏掉。怎么补?

思路: 主 frame 遍历时,进不去的 iframe 会在节点上留个 error 属性。后台检测到这些"可见却加载失败"的 iframe,逐个单独注入脚本去它自己的 frame 里跑一遍,再把结果缝回主树。

关键点是编号不能撞车。所以每个子 frame 的 buildDomTree 都从"上一个 frame 用到的最大编号 + 1"开始数——这就是 startId/startHighlightIndex 参数的用途:

// service.ts:253 —— 每个子 frame 从上一个 frame 的最大编号之后继续
args: [{
showHighlightElements, focusHighlightIndex: focusElement, viewportExpansion,
startId: maxNodeId + 1,
startHighlightIndex: maxHighlightIndex + 1, // 编号接着往下,不重复
debugMode,
}],

缝合发生在 constructFrameTree(service.ts:212):它把子 frame 的 map 并进主 map(service.ts:278),再把子 frame 的根节点 id push 进对应 iframe 节点的 children(service.ts:291)。"对应哪个 iframe 节点"_locateMatchingIframeNode(service.ts:335)按尺寸/名字/URL/标题匹配——先严格匹配,失败再退回近似匹配(service.ts:367)。

关键细节: 只对"真的失败且可见"的 iframe 补跑——_visibleIFramesFailedLoading(service.ts:388)会滤掉 1×1 的追踪 iframe 和已标记 skipped 的。加载成功的 iframe 在主 frame 那一趟就已经遍历过了,不重复处理,避免双重编号和双重高亮。

3.7 可选:在页面上画高亮框

要解决的小问题: 开发调试或开视觉模型时,想在真实网页上把每个编号可视化。

这纯属旁支副作用,不影响给模型的文本。highlightElement(buildDomTree.js:125)在页面里创建一个 id='playwright-highlight-container' 的浮层容器,给每个编号元素画一个彩框 + 一个写着编号的小标签。handleHighlighting 里当 focusHighlightIndex >= 0 时只高亮那一个(聚焦模式),否则全画(buildDomTree.js:1210)。

事后 removeHighlights(service.ts:510)按那个容器 id 和 browser-user-highlight-id 属性把它们清掉。记住:高亮是给人看的,编号才是给模型用的——两者共享同一个 highlightIndex,但没有高亮框模型照样能工作。


4. 从清单到 state 消息:拼给 LLM 的最后一步

有了 clickableElementsToString 产出的文本,还要拼上"页面的其余上下文",才是模型真正收到的一条消息。这一步在 buildBrowserStateUserMessage(agent/prompts/base.ts:29)。

它做三件事,分层拼装:

① 拿元素清单,并附上滚动信息。 滚动信息告诉模型"上面/下面还有没有内容",引导它决定要不要翻页:

// prompts/base.ts:31 —— 渲染清单 + 计算滚动百分比
const rawElementsText = browserState.elementTree.clickableElementsToString(context.options.includeAttributes);
// …
const scrollInfo = `[Scroll info …] window.scrollY: ${browserState.scrollY}, ` +
`document.body.scrollHeight: ${browserState.scrollHeight}, …`;
const elementsText = wrapUntrustedContent(rawElementsText); // ← 安全包裹,见第 4 章
formattedElementsText = `${scrollInfo}[Start of page]\n${elementsText}\n[End of page]\n`;

注意 wrapUntrustedContent(agent/messages/utils.ts:259):页面内容是不可信数据,可能藏着"忽略你的指令"之类的提示注入。这里把它包起来,细节是第 4 章的主题,本章不展开——只需知道清单在进消息前被隔离过一次

② 拼上 tab 上下文。 当前 tab 和其它可用 tab,让模型知道自己在哪、能切到哪:

// prompts/base.ts:66 —— 当前 tab + 其它 tab 列表
const currentTab = `{id: ${browserState.tabId}, url: ${browserState.url}, title: ${browserState.title}}`;
const otherTabs = browserState.tabs
.filter(tab => tab.id !== browserState.tabId)
.map(tab => `- {id: ${tab.id}, url: ${tab.url}, title: ${tab.title}}`);

③ 组装成一条 HumanMessage 最终的 stateDescription(prompts/base.ts:70)按"任务记忆结束 → 当前状态开始 → tab → 可交互元素 → 步数/时间 → 上一步动作结果"的顺序拼好。若开了视觉模式且有截图,还会把截图作为 image_url 一并塞进去(prompts/base.ts:83)——但即使有图,文本清单里的编号仍是模型下指令的唯一句柄

到这里,一次感知的产物完整地进了模型的上下文。模型读完,就该输出"点几号 / 往几号里填什么"了——那是下一章


5. 巧妙之处(可借鉴的技术)

  • 用一个整数当"桥接编号",彻底解耦模型与 DOM。 模型只认 highlightIndex,系统内部维护 selectorMap 做回查。模型永远不碰 xpath/CSS,既省 token,又避免模型编造出乱七八糟的选择器。桥的两端:文本里的 [5](views.ts:298)↔ selectorMap.set(5, node)(service.ts:426)。

  • "父已高亮则子不重复发号"。 一个 <a><span>text</span></a> 只报一行,不会内外都编号。见 isElementDistinctInteraction 判定(buildDomTree.js:1192)。

  • 属性值去重 + 截断这套"省 token 玄学"。 相同的长值只留一个、和文本重复的属性删掉、每个值截 15 字——作者注释直言"heavy vibes but good enough"(views.ts:241)。真实工程里,省 token 常常就是这种务实的启发式。

  • 只报视口内的元素,让清单随滚动而变。 isInExpandedViewport 门槛(buildDomTree.js:1206)把清单控制在"当前看得见的一屏",避免一次把整页几百个元素全塞给模型。

  • 跨 frame 用连续计数器缝一棵树。 startHighlightIndex 让每个 iframe 接着上一个编号数(service.ts:254),缝回主树后模型看到的仍是一份连号的统一清单,感知不到 frame 边界。

  • isNew*[index] 标注新元素。 让模型知道"这一步页面变出了什么新东西",辅助判断上一步动作是否生效(views.ts:298,语义注释在 views.ts:86)。


6. 边界与局限

  • 深度不可信的 iframe 拿不到。 带限制性 sandbox(无 allow-same-origin)的 iframe 直接标 error 不再尝试(buildDomTree.js:1427);跨域策略挡住的也只能靠"逐 frame 补跑"救回一部分,救不回的就是盲区。

  • 视口外的东西默认看不见。 除非把 viewportExpansion 设成 -1(全给编号,buildDomTree.js:1206),否则模型每一步只感知一屏,复杂长页面必须靠反复滚动 + 反复感知。

  • 可交互判定是启发式,会漏会误。 isInteractiveElement(buildDomTree.js:541)靠标签/role/指针样式/事件监听器综合猜。用自定义组件、纯 JS 绑事件而无语义标签的站点,可能漏判某个"其实能点"的元素。

  • 编号每步重排,不能跨步复用。 页面一变,highlightIndex 从 0 重新数。所以感知必须每步重跑,旧编号无效——这是设计使然,不是 bug。

  • 属性去重是"vibes",可能误删。 那套值去重启发式(views.ts:241)在极端情况下可能删掉本应保留的属性,作者自己也标了 WARNING


7. 横向对比

感知层是所有 browser agent 的共性难题——如何把网页压成 LLM 能读的表示。Nanobrowser 走的是 "DOM → 带编号可交互元素清单" 路线(源自 browser-use 一脉),核心是 highlightIndex 桥接编号。它与另一类方案(纯视觉/坐标点击、或无障碍树 accessibility tree)的取舍不同:文本清单省 token、可精确回指,但依赖 DOM 判定的准确性;视觉方案更抗"无语义组件",但更贵、更易点偏。

本章讲的是感知;它下游紧接着的是:


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

主题文件路径符号名
感知层入口,返回 DOMStatechrome-extension/src/background/browser/dom/service.tsgetClickableElements
注入脚本 + 调用 window.buildDomTreechrome-extension/src/background/browser/dom/service.ts_buildDomTree
只往未注入的 frame 注入chrome-extension/src/background/browser/dom/service.tsinjectBuildDomTreeScripts / scriptInjectedFrames
跨 iframe 逐 frame 补跑并缝合chrome-extension/src/background/browser/dom/service.tsconstructFrameTree
匹配失败 iframe 对应的节点chrome-extension/src/background/browser/dom/service.ts_locateMatchingIframeNode
扁平 JSON 重建成树 + 填 selectorMapchrome-extension/src/background/browser/dom/service.ts_constructDomTree / _parse_node
清除页面高亮chrome-extension/src/background/browser/dom/service.tsremoveHighlights
页面里遍历 DOM 的注入脚本chrome-extension/public/buildDomTree.jswindow.buildDomTree / buildDomTree
判定可交互chrome-extension/public/buildDomTree.jsisInteractiveElement
发放 highlightIndex(省号规则)chrome-extension/public/buildDomTree.jshandleHighlighting
父已高亮时判子是否独立交互chrome-extension/public/buildDomTree.jsisElementDistinctInteraction
在页面画高亮框(可选)chrome-extension/public/buildDomTree.jshighlightElement
元素节点类,承载 highlightIndex/isNewchrome-extension/src/background/browser/dom/views.tsDOMElementNode
树 → [index]<tag attrs>text /> 文本chrome-extension/src/background/browser/dom/views.tsclickableElementsToString
收集到下个可交互元素为止的文本chrome-extension/src/background/browser/dom/views.tsgetAllTextTillNextClickableElement
进清单的属性白名单chrome-extension/src/background/browser/dom/views.tsDEFAULT_INCLUDE_ATTRIBUTES
编号 → 节点回查表chrome-extension/src/background/browser/dom/views.tsDOMState.selectorMap
属性值截断chrome-extension/src/background/browser/util.tscapTextLength
清单 + 滚动 + tab 拼成 state 消息chrome-extension/src/background/agent/prompts/base.tsbuildBrowserStateUserMessage
不可信内容安全包裹(详见 04)chrome-extension/src/background/agent/messages/utils.tswrapUntrustedContent