04 · 页面抽取与变量建模
剪藏的第一道难题不是“保存”,而是把不可预测的网页 DOM 转换成稳定、可编程的内容模型。
一、抽取管线的输入与输出
输入是当前 tab 的真实页面,输出是 ContentResponse:
Document
├─ selection
├─ shadow DOM
├─ meta/link/schema
├─ article candidates
└─ stored highlights
│
▼
ContentResponse
├─ content / selectedHtml / fullHtml
├─ title / author / description / site
├─ url-derived domain
├─ image / favicon / published / language
├─ wordCount / parseTime
├─ schemaOrgData / metaTags / extractedContent
└─ highlights这里保留了三种粒度的正文:
content:Defuddle 清洗后的正文 HTML;selectedHtml:用户当前选区;fullHtml:去脚本、去样式、绝对化 URL 后的完整页面。
它们分别服务 {{content}}、{{selection}} 和高级自定义/AI 上下文。
二、请求从哪里发起
popup.ts 的 initializePageContent() 流程最终调用 content-extractor.ts:67 的 sendExtractRequest(tabId)。后者不直接 tabs.sendMessage,而是通过 background 的 sendMessageToTab 路由,原因包括:
- 当前 tab 可能是独立
reader.html; - content script 可能尚未注入;
- popup/side panel 对 tab 访问方式不同;
- background 可以统一修复或返回错误。
extractPageContent() 包装请求并处理失败;initializePageContent() 再把响应变成模板所需变量。
三、第一步:展平 Shadow DOM
现代站点大量使用 Web Components,正文可能藏在 open shadow root 中。普通 document.documentElement.outerHTML 不会包含 shadow tree。
content.ts 在抽取前执行:
Promise.race([
flattenShadowDom(document),
3000ms timeout
])展平过程需要 main world helper,因为 isolated content world 对某些页面对象的可见性有限。3 秒上限体现了一个明确取舍:尽量完整,但不能让 popup 永久等待。
四、第二步:捕获 selection
若 window.getSelection() 存在非空 Range,代码 clone 选区内容到临时 div,再通过 serializeChildren() 取 HTML。
为什么不只用 selection.toString()?因为选区里的链接、强调、列表和图片结构会丢失。保留 HTML 后,后续 selection|markdown 才能得到结构化 Markdown。
五、第三步:Defuddle 异步解析
核心调用:
const defuddle = new Defuddle(document, { url: document.URL })
const defuddled = await Promise.race([
defuddle.parseAsync(),
parseTimeout
]).catch(() => defuddle.parse())为什么优先 parseAsync()?因为 transcript 等变量可能需要异步获取。为什么保留同步回退?真实浏览器环境中,别的扩展、页面 fetch monkey patch 或网络限制可能让异步解析挂住。
Defuddle 返回的不只是正文:
| 字段 | 用途 |
|---|---|
content | 清洗后的主正文 HTML |
variables | 提取器提供的扩展变量 |
schemaOrgData | JSON-LD/结构化数据变量与 trigger |
metaTags | meta:name:* / meta:property:* 类变量 |
title/author/site/... | 预设模板变量 |
wordCount/parseTime | 统计与模板变量 |
六、第四步:构造安全的 fullHtml
代码另建 DOMParser 副本,而不是直接修改当前页面:
- 从
document.documentElement.outerHTML解析新 document; - 删除所有
script, style; - 删除每个元素的
style属性; - 遍历
[src], [href],同时处理srcset; - 相对 URL 以
document.baseURI转成绝对 URL; - 序列化为
cleanedHtml。
这不是完整的 XSS sanitizer,而是为“作为文本交给模板/模型”准备的清洁副本。需要实际插入 DOM 的场景仍使用 DOMPurify 或安全 DOM API。
七、第五步:绑定高亮上下文
抽取时同时读取:
highlighter.getHighlights()并用 Defuddle 结果更新:
setPageTitle(defuddled.title)
updatePageDomainSettings({ site, favicon })这让高亮存储不仅有 URL 和片段,还带适合在 highlights library 展示的标题、站点和图标。
八、ContentResponse 如何进入变量表
initializePageContent() 的职责可以分成三步。
1. 处理 highlights
processHighlights(content, highlights) 把保存的高亮重新映射进抽取正文,确保 {{content}} 可以反映用户标注。处理过程先过滤和排序,再尝试 XPath 与内容回退。
2. HTML 转 Markdown
Defuddle 的 createMarkdownContent() 负责把清洗后 HTML 转为 Markdown。URL 作为 base 参与链接与资源解析。
3. 调用 buildVariables()
shared.ts:40 聚合预设字段:
title, author, description, url, domain
content, contentHtml, selection, fullHtml
favicon, image, published, site, language, wordCount
highlights, schemaOrgData, metaTags, extractedContent
date/time 派生值最终变量对象不仅是 Record<string, string>;schema 和 selector 在执行时可能产生数组/对象,renderer 也因此以 any 支持成员访问和循环。
九、Schema.org 如何展开
addSchemaOrgDataToVariables() 递归遍历结构化数据,为模板提供:
schema:@Movie.name
schema:@Movie.genre
schema:director[*].name
schema:author.namerenderer 还支持缩写解析:当模板写 schema:genre 时,会在完整 schema key 中寻找匹配。数组访问支持固定索引和 [*] 投影。
这种设计兼顾两类用户:
- 简单模板用短名;
- 多 schema 类型冲突时用完整
@Type.path消歧。
十、CSS selector 是延迟变量
selector: 与 selectorHtml: 不能在 popup 里直接解析,因为目标 DOM 在 content script 所在页面。
浏览器版 resolver 的数据流:
renderer 发现 selector:...
→ asyncResolver(name, context)
→ resolveSelector(tabId, name)
→ background route
→ content.ts extractContentBySelector()
→ 返回 text / attribute / outerHTMLAPI/CLI 不需要跨消息:api.ts 用调用方提供的 parsed document 创建本地 createAsyncResolver(doc)。同一种变量语义,因此有两种环境适配器。
十一、选择器抽取的三种形态
extractContentBySelector(doc, selector, attribute?, extractHtml?):
- 没有 attribute:取匹配元素文本;
- 指定
?href等 attribute:取属性; selectorHtml::取 HTML;- 多个匹配会返回数组,之后可用
join、map、first等过滤器处理。
selector 是强大逃生舱:当通用正文抽取不够时,模板可以直接针对站点 DOM,而不必修改扩展源码。
十二、抽取失败的分层容错
| 层 | 失败 | 行为 |
|---|---|---|
| shadow flatten | 超时/异常 | 继续用当前 DOM |
| Defuddle async | 超时/异常 | 回退同步 parse |
| URL 绝对化 | 单个非法 URL | 保留原值并 warning |
| content message | 页面受限/无 listener | background 尝试注入或返回错误 |
| selector | 无匹配 | 返回空值而非中止整个模板 |
| template variable | 未定义 | renderer 产生空/错误记录,保留其他输出 |
原则是“局部数据缺失,不应毁掉整份笔记”。
十三、性能意识
源码有几处明确优化:
- schema 只在存在 schema trigger 且 URL 未命中时才计算;
- selector 按需解析,不预扫所有模板表达式;
- full HTML 在 content 域清理后一次返回;
- trigger 结果 memoize 30 秒;
- popup 先建 skeleton,再填异步字段;
- Reader 可接收
preExtractedContent,避免同一 HTML 重跑 Defuddle。
十四、可复用的设计模式
页面抽取层体现了三个通用模式:
- 语义核心 + 环境适配器:浏览器和 CLI 共用变量语义,只替换 DOM/resolver。
- 快路径 + 内容回退:async parse 失败走 sync,XPath 失败走 quote。
- 丰富中间表示:先保存 HTML、Markdown、schema、meta 等多种形态,让后续模板自由组合。
十五、本章检查点
- 能解释为什么同时需要
content、selectedHtml、fullHtml; - 能说出 shadow DOM 与
parseAsync的超时策略; - 能追踪 ContentResponse 到 variables 的转换;
- 能解释 selector 在浏览器与 CLI 中为何有不同 resolver;
- 能说明 schema 短名、完整名和数组投影。