Skip to content

04 · 页面抽取与变量建模

剪藏的第一道难题不是“保存”,而是把不可预测的网页 DOM 转换成稳定、可编程的内容模型。

一、抽取管线的输入与输出

输入是当前 tab 的真实页面,输出是 ContentResponse

text
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.tsinitializePageContent() 流程最终调用 content-extractor.ts:67sendExtractRequest(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 在抽取前执行:

text
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 异步解析

核心调用:

ts
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提取器提供的扩展变量
schemaOrgDataJSON-LD/结构化数据变量与 trigger
metaTagsmeta:name:* / meta:property:* 类变量
title/author/site/...预设模板变量
wordCount/parseTime统计与模板变量

六、第四步:构造安全的 fullHtml

代码另建 DOMParser 副本,而不是直接修改当前页面:

  1. document.documentElement.outerHTML 解析新 document;
  2. 删除所有 script, style
  3. 删除每个元素的 style 属性;
  4. 遍历 [src], [href],同时处理 srcset
  5. 相对 URL 以 document.baseURI 转成绝对 URL;
  6. 序列化为 cleanedHtml

这不是完整的 XSS sanitizer,而是为“作为文本交给模板/模型”准备的清洁副本。需要实际插入 DOM 的场景仍使用 DOMPurify 或安全 DOM API。

七、第五步:绑定高亮上下文

抽取时同时读取:

ts
highlighter.getHighlights()

并用 Defuddle 结果更新:

text
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 聚合预设字段:

text
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() 递归遍历结构化数据,为模板提供:

text
schema:@Movie.name
schema:@Movie.genre
schema:director[*].name
schema:author.name

renderer 还支持缩写解析:当模板写 schema:genre 时,会在完整 schema key 中寻找匹配。数组访问支持固定索引和 [*] 投影。

这种设计兼顾两类用户:

  • 简单模板用短名;
  • 多 schema 类型冲突时用完整 @Type.path 消歧。

十、CSS selector 是延迟变量

selector:selectorHtml: 不能在 popup 里直接解析,因为目标 DOM 在 content script 所在页面。

浏览器版 resolver 的数据流:

text
renderer 发现 selector:...
  → asyncResolver(name, context)
  → resolveSelector(tabId, name)
  → background route
  → content.ts extractContentBySelector()
  → 返回 text / attribute / outerHTML

API/CLI 不需要跨消息:api.ts 用调用方提供的 parsed document 创建本地 createAsyncResolver(doc)。同一种变量语义,因此有两种环境适配器。

十一、选择器抽取的三种形态

extractContentBySelector(doc, selector, attribute?, extractHtml?)

  • 没有 attribute:取匹配元素文本;
  • 指定 ?href 等 attribute:取属性;
  • selectorHtml::取 HTML;
  • 多个匹配会返回数组,之后可用 joinmapfirst 等过滤器处理。

selector 是强大逃生舱:当通用正文抽取不够时,模板可以直接针对站点 DOM,而不必修改扩展源码。

十二、抽取失败的分层容错

失败行为
shadow flatten超时/异常继续用当前 DOM
Defuddle async超时/异常回退同步 parse
URL 绝对化单个非法 URL保留原值并 warning
content message页面受限/无 listenerbackground 尝试注入或返回错误
selector无匹配返回空值而非中止整个模板
template variable未定义renderer 产生空/错误记录,保留其他输出

原则是“局部数据缺失,不应毁掉整份笔记”。

十三、性能意识

源码有几处明确优化:

  • schema 只在存在 schema trigger 且 URL 未命中时才计算;
  • selector 按需解析,不预扫所有模板表达式;
  • full HTML 在 content 域清理后一次返回;
  • trigger 结果 memoize 30 秒;
  • popup 先建 skeleton,再填异步字段;
  • Reader 可接收 preExtractedContent,避免同一 HTML 重跑 Defuddle。

十四、可复用的设计模式

页面抽取层体现了三个通用模式:

  1. 语义核心 + 环境适配器:浏览器和 CLI 共用变量语义,只替换 DOM/resolver。
  2. 快路径 + 内容回退:async parse 失败走 sync,XPath 失败走 quote。
  3. 丰富中间表示:先保存 HTML、Markdown、schema、meta 等多种形态,让后续模板自由组合。

十五、本章检查点

  • 能解释为什么同时需要 contentselectedHtmlfullHtml
  • 能说出 shadow DOM 与 parseAsync 的超时策略;
  • 能追踪 ContentResponse 到 variables 的转换;
  • 能解释 selector 在浏览器与 CLI 中为何有不同 resolver;
  • 能说明 schema 短名、完整名和数组投影。

基于 Obsidian Web Clipper 1.7.1 源码快照的独立学习笔记