- 前端
【免费下载链接】obsidian-clipper
Highlight and capture the web in your favorite browser. The official Web Clipper extension for Obsidian.
本指南以 obsidian-clipper 仓库中的测试固件 src/utils/fixtures/expected/edge-cases.md 为切入点,逐层拆解 Web Clipper 模板引擎在"边缘场景"下的真实行为:CSS 选择器匹配不到任何元素时属性如何落盘、选择器值经过过滤器链后如何被规整、静态字面量与数字类型的属性如何写入 YAML frontmatter。读完你将掌握该模板引擎的完整渲染调用链(Defuddle 提取 → 变量构建 → 模板编译 → frontmatter 生成),并理解这些行为如何在 template-integration.test.ts 中通过 fixture 快照得到持续回归验证,可直接复用同一套方法验证你自己编写的 Clipper 模板。
一、认识 edge-cases fixture:一份"最小但刁钻"的输入输出对
edge-cases是模板引擎回归测试中的一组 fixture,由三份文件组成:
| 文件 | 角色 |
|---|---|
| templates/edge-cases.html | 模拟被抓取的网页(输入) |
| templates/edge-cases.json | 用户配置的裁剪模板(输入) |
| expected/edge-cases.md | 期望的最终 Markdown 笔记(输出基线) |
测试逻辑是:把 HTML 喂给模板、执行与扩展/CLI 完全一致的裁剪流水线,然后将实际输出与期望基线做精确比对。三份文件的内容如下。
网页输入(HTML),页面上方用 HTML 注释声明了模拟 URL:
<!-- {"url": "https://example.com"} --> <!DOCTYPE html> <html> <head><title>Test</title></head> <body> <p class="test">Hello</p> <p>Some content here for word count.</p> </body> </html>模板输入(JSON),包含 5 个属性,其中两个属性刻意指向一个不存在的 CSS 类.nonexistent:
{ "noteNameFormat": "{{title}}", "noteContentFormat": "Title: {{title}} ({{words}} words)\n\n{{content}}", "properties": [ { "name": "title", "value": "{{title}}", "type": "text" }, { "name": "empty_selector", "value": "{{selector:.nonexistent}}", "type": "text" }, { "name": "filtered_empty", "value": "{{selector:.nonexistent|wikilink|join}}", "type": "text" }, { "name": "static", "value": "[[Books]]", "type": "text" }, { "name": "words", "value": "{{words}}", "type": "number" } ] }期望输出(Markdown),即关联文档的完整内容:
--- title: "Test" empty_selector: filtered_empty: static: "[[Books]]" words: 7 --- Title: Test (7 words) Hello Some content here for word count.这份输出同时验证了 5 类行为:普通文本属性的引号化输出、空选择器属性以"空值键"形式保留、过滤器链在空输入下保持稳定、静态字面量原样通过、数字属性以无引号数值输出,以及{{words}}变量对正文词数的准确统计。下面逐一还原每一行的产生过程。
二、测试驱动:fixture 如何跑通完整的裁剪流水线
template-integration.test.ts 是运行这批 fixture 的测试宿主。它通过runFixture()复刻了扩展与 CLI 的完整渲染路径,核心步骤与 src/api.ts 的clip()一一对应:
- 解析 HTML:用
linkedom的parseHTML得到文档对象(文件头部注释解释:{{content}}经 turndown 转换时需要 DOM,因此该测试文件声明// @vitest-environment jsdom); - Defuddle 提取:
new DefuddleClass(document, { url }).parse()抽取标题、正文、作者、字数等元数据; - HTML 转 Markdown:
createMarkdownContent(defuddleResult.content, url)(来自defuddle/full); - 构建变量字典:
buildVariables()将 Defuddle 结果展开为{{title}}、{{words}}、{{content}}等键; - 编译模板:
compileTemplate()分别编译笔记名、每个属性值与正文格式串; - 类型化属性格式化:
formatPropertyValue(value, type, templateValue); - 生成 frontmatter:
generateFrontmatter(compiledProperties, typeMap); - 拼装全文:非空 frontmatter 与编译后正文拼接。
其中 URL 不是硬编码的,而是从 HTML 文件头部的<!-- {"url": "..."} -->注释解析而来(template-integration.test.ts)。期望基线不存在时,测试会报错并提示:Run with UPDATE_FIXTURES=1 to create it.——即支持用环境变量UPDATE_FIXTURES=1一键重建基线(saveExpected会写入fixtures/expected/目录)。日常回归只需执行:
npm test或针对单个用例:
npx vitest run src/utils/template-integration.test.ts三、边界一:空选择器{{selector:.nonexistent}}如何变成空属性
模板中empty_selector属性的值{{selector:.nonexistent}}指向页面中不存在的 CSS 类。在浏览器扩展中,这个变量会经过 src/utils/variables/selector.ts 的processSelector():先解析出选择器.nonexistent,通过browser.runtime.sendMessage向内容脚本发送extractContent消息,内容脚本用querySelectorAll查询——src/utils/shared.ts 的extractContentBySelector()在匹配数为 0 时直接返回空字符串'':
const elements = doc.querySelectorAll(selector); if (elements.length === 0) { return ''; }于是属性编译结果为''。接下来进入类型化格式化:text类型不做任何改写(见 formatPropertyValue 的default分支)。最终 frontmatter 生成器在 generateFrontmatter 的default分支中判断:
frontmatter += property.value.trim() !== '' ? ` "${escapeDoubleQuotes(property.value)}"\n` : '\n';空值因此被渲染成只有键、没有值的 YAML 行——即期望输出中的empty_selector:。这个设计保证了:即使某个站点的选择器没有命中任何元素,笔记的属性字段依然存在,只是留空,便于后续在 Obsidian 中补填,而不是整条属性被静默丢弃。
四、边界二:过滤器链作用于空值{{selector:.nonexistent|wikilink|join}}
filtered_empty属性在同一个空选择器上追加了过滤器链|wikilink|join,用于验证"过滤器处理空输入"的稳定性。它的执行路径与上一节略有不同:processSelector()在拿到空字符串后会继续调用 src/utils/filters.ts 的applyFilters():
export function applyFilters( value: string | any[], filterString: string, currentUrl?: string, ): string { return applyFiltersWithRegistry(value, filterString, diagnosticFilters, { variables: {}, context: { currentUrl }, }); }applyFilters使用的clipperFilters注册表(src/utils/filters.ts)由四部分构成:
export const clipperFilters: Readonly<FilterRegistry<ClipperTemplateContext>> = Object.freeze({ ...standardFilters, ...htmlFilters, markdown: markdownFilter, fragment_link: fragmentLinkFilter, });其中standardFilters(来自 Knap 引擎,包含wikilink、join等通用过滤器)、htmlFilters(Knap 的 HTML 专项过滤器)、Clipper 自定义的markdown(复用 Defuddle 的 HTML→Markdown 转换,见 src/utils/filters/markdown.ts)与fragment_link。注意 filters.ts 中的diagnosticFilters是一个 Proxy,当模板中写错过滤器名(如拼写错误)时,会在编译阶段向控制台输出Invalid filter: xxx的警告,属于非致命诊断。
在|wikilink|join作用于空字符串时,Knap 的过滤器实现会保持幂等,输出仍为''——期望基线中的filtered_empty:正是这一稳定性的证据。若某个过滤器在空输入下抛错,测试将立即失败,这正是该用例存在的意义:保证 Clipper 面对"选择器抽空 + 过滤器链"的组合不会崩溃,也不会输出非法 YAML。
五、边界三:静态字面量[[Books]]原样通过并正确转义
static属性的值[[Books]]不含任何{{...}}变量。在编译阶段,src/utils/template-compiler.ts 的compileTemplate()通过 Knap 引擎渲染时,会保留这类纯文本(模板引擎只在遇到{{表达式时才做变量替换)。因此属性值原样为[[Books]]——这正是 Obsidian 内链的写法,说明 Clipper 允许属性值中直接嵌入[[链接]]或任意 Markdown 语法。
随后 frontmatter 生成器对text类型做了引号包裹与转义:escapeDoubleQuotes()处理值内可能出现的双引号,最终输出:
static: "[[Books]]"这验证了 YAML 序列化的两个细节:属性值中的方括号[[]]不会破坏 YAML 结构(因为整体被双引号包裹),且 Obsidian 风格的内链文本可以安全地往返于 frontmatter 之中。若属性名本身含有特殊字符(冒号、空格、数字开头等),generateFrontmatter 还会自动为其加上引号,避免生成非法 YAML。
六、边界四:数字类型{{words}}与词数统计
words属性是number类型,值来自{{words}}变量。该变量的源头是 Defuddle 对页面正文的统计:buildVariables()在 src/utils/shared.ts 中将其写入变量字典:
'{{words}}': (params.wordCount ?? 0).toString(),对本 fixture 的 HTML 而言,正文只有两个段落:"Hello"(1 词)与 "Some content here for word count."(6 词),合计 7 词,与期望输出中的words: 7精确吻合。这也解释了noteContentFormat中的({{words}} words)是如何计算出 "Test (7 words)" 的。
数字类型的处理分两步:
- 格式化阶段:formatPropertyValue 的
number分支先用value.replace(/[^\d.-]/g, '')剥离所有非数字字符,再parseFloat规范化; - 序列化阶段:generateFrontmatter 的
number分支输出不带引号的裸数值:
case 'number': { const numericValue = property.value.replace(/[^\d.-]/g, ''); frontmatter += numericValue ? ` ${parseFloat(numericValue)}\n` : '\n'; break; }最终在 YAML 中呈现为words: 7而非words: "7",确保 Obsidian 能把该属性识别为数字类型属性,可参与数值排序、筛选等操作。同理,date/datetime、checkbox、multitext(数组)各有独立的序列化分支——完整逻辑见 src/utils/shared.ts。
七、正文与笔记名的编译:从 fixture 到完整笔记
除属性外,fixture 还验证了笔记名与正文模板。noteNameFormat为{{title}},Defuddle 从<title>Test</title>提取标题,经sanitizeFileName清洗后生成笔记名Test。noteContentFormat为"Title: {{title}} ({{words}} words)\n\n{{content}}",其中{{content}}是正文的 Markdown 形式:
Hello Some content here for word count.两者拼装后即得到期望输出中 frontmatter 分隔线之后的部分。这里还可以观察到一个细节:buildVariables()对所有核心变量都做了.trim()处理(src/utils/shared.ts),因此即便 Defuddle 返回的正文首尾带有空白,最终笔记也不会出现多余的换行或空格。
八、从 fixture 到生产:同一条流水线在 CLI 与 API 中的复用
edge-casesfixture 的价值在于它不是孤立的测试玩具——runFixture()的每一步都与生产代码同源。在浏览器扩展之外,src/api.ts 的clip()提供了环境无关的编程式入口,调用方只需提供html、url、template与一个DocumentParser即可;src/cli.ts 则在此基础上提供命令行封装:
obsidian-clipper <url> -t <template.json> -o output.mdCLI 支持--template指向单文件或模板目录(目录模式会按 URL 前缀/正则/schema 触发器自动匹配模板,见 src/api.ts 的matchTemplate)、--html -从标准输入读取 HTML、--property-types传入额外的属性类型映射,以及--open/--uri将结果送入 Obsidian(优先走 Obsidian CLI,不可用时回退到obsidian://URI 协议,见 src/utils/cli-utils.ts)。
换言之,你在浏览器剪藏扩展、Node.js 脚本(通过package.json的exports暴露的./api入口)或命令行中体验到的模板行为,与这份 fixture 基线所断言的行为完全一致——任何一处渲染逻辑的回归,都会在运行npm test时被edge-cases(及其姊妹 fixture:minimal、goodreads、schema-rich、youtube、imdb)第一时间拦截。
九、小结:把 fixture 方法论迁移到自己的模板上
总结edge-cases这份基线带给我们的五条可迁移结论:
- 选择器失配是常态:页面改版、付费墙、动态渲染都可能导致
{{selector:...}}抽空,Clipper 会将属性渲染为空键而非报错中断; - 过滤器链对空值必须幂等:
|wikilink|join等组合在空输入下保持稳定输出空字符串; - 静态值与内链可以直接进属性:
[[Books]]等字面量会被 YAML 安全引号化; number类型属性会以裸数值输出:依赖formatPropertyValue与generateFrontmatter的类型感知双阶段处理;{{words}}由 Defuddle 正文统计驱动:正文内容直接决定属性值,测试用最小 HTML 精确锁定该数值(7)。
当你在 src/utils/fixtures/templates 下新增一个模板 JSON 与对应 HTML,并在缺少期望输出时以UPDATE_FIXTURES=1生成基线后,该模板即可被 template-integration.test.ts 纳入自动回归——这正是本项目保证 40+ 本地化语言、多浏览器构建下模板行为始终一致的基础设施之一。相关核心实现可继续深入阅读:template-compiler.ts、variables/selector.ts、filters.ts、shared.ts。
- 前端
【免费下载链接】obsidian-clipper
Highlight and capture the web in your favorite browser. The official Web Clipper extension for Obsidian.
相关推荐
Twig 模板引擎 capitalize 过滤器解析:首字母大写的实现原理与边界行为
Twig 模板引擎 capitalize 过滤器解析:首字母大写的实现原理与边界行为 本文围绕 Twig 官方文档中的 capitalize 过滤器展开,从模板
后端openJiuwen DeepSearch 候选实体相关性过滤:deepsearch_filter_relative_info 提示词模板与状态验证调用链解析
openJiuwen DeepSearch 候选实体相关性过滤:deepsearch_filter_relative_info 提示词模板与状态验证调用链解析
人工智能大模型AI AgentRAG深度研究搜索引擎后端代码智能体Fathom Lite代码生成模板引擎:选择与使用
Fathom Lite代码生成模板引擎:选择与使用 Fathom Lite是一款专注于隐私保护的网站分析工具,采用Golang和Preact构建。它提供了简单易
数据分析后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考