Skip to content

06 · 变量、选择器与过滤器

模板引擎提供“怎么执行”,变量与过滤器定义“能表达什么”。这是 Web Clipper 面向不同网站保持可扩展性的关键层。

一、变量的五个来源

text
Defuddle preset ─┐
selection/page ──┤
Schema.org ──────┼─ buildVariables() → RenderContext.variables
meta tags ───────┤
custom extract ──┘

selector:* ───────── async resolver → 页面 DOM
prompt:* ─────────── Interpreter → LLM response
model* ───────────── current model config

变量不是都在同一时间出现。预设和 schema 在抽取后就绪;selector 到模板执行时才查询 DOM;prompt 要等 Interpreter 返回后才替换。

二、预设变量

buildVariables() 把抽取结果归一为模板可用名称。常用集合:

类别变量示例
页面身份title, url, domain, site
作者时间author, published, 当前 date/time 派生值
正文content, contentHtml, selection, fullHtml
媒体image, favicon
描述统计description, language, wordCount
用户标注highlights
扩展提取Defuddle variables
元数据schema 与 meta 派生键

同一内容同时保留 HTML 和 Markdown,是为了把格式选择留给模板,而不是抽取层提前锁死。

三、变量解析的优先级

renderer 的普通 identifier 最终走 resolveVariable()。大致顺序:

  1. 当前局部 scope(for/set);
  2. 外层 scopes;
  3. 全局 variables;
  4. 特殊 schema 解析;
  5. async/deferred resolver;
  6. 找不到则 undefined

成员访问支持:

liquid
{{ author.name }}
{{ authors[0].name }}

当中间对象是 null/undefined 时返回 undefined,不让模板因一个字段缺失崩溃。

四、Schema 变量

Schema.org 数据来自页面 JSON-LD。源码支持:

liquid
{{ schema:name }}
{{ schema:@Movie.genre }}
{{ schema:director[0].name }}
{{ schema:director[*].name | join:", " }}

解析要处理三个问题:

  • 页面可能有多个 schema object 或嵌套数组;
  • @type 可能是字符串或数组;
  • schema 值可能本身是 JSON 字符串,需要尝试 parse。

星号投影返回数组,让过滤器继续 map/join,而不是在 resolver 内强制拼成字符串。

五、Meta 与自定义提取变量

Defuddle 暴露 metaTags 与 extracted variables。变量构建层将可识别的元数据加入统一表,使模板不必直接遍历 DOM。

这层的意义是稳定命名:站点 HTML 结构可以变,但标准 meta property 的语义较稳定。只有稳定元数据不足时,才应退到 selector。

六、Selector 语法

三种常见形式:

liquid
{{ selector:.price }}
{{ selector:a.canonical?href }}
{{ selectorHtml:article .summary }}

解释:

  • selector: 默认取文本;
  • ?attribute 取指定属性;
  • selectorHtml: 取 HTML;
  • 多匹配项可能返回数组;
  • selector 中的引号/空白由 tokenizer 特别处理。

为什么 selector 是 async

浏览器 popup 与页面 DOM 不在一个 realm,解析需要一次 runtime/tabs 消息往返。CLI 版虽然本地同步可查 DOM,也统一包装成 Promise,以保持 compiler 接口一致。

七、Prompt 变量

模板中的:

liquid
{{ "Summarize this article in three bullets" }}
{{ prompt:"Suggest five tags" | list }}

会被 collectPromptVariables() 收集、去重并编号为 prompt_1prompt_2。Interpreter 一次请求把所有 prompt 一起送给模型,要求返回:

json
{
  "prompts_responses": {
    "prompt_1": "...",
    "prompt_2": "..."
  }
}

响应再按 key 替换回模板,并应用原 prompt 后的 filters。这里“批量请求而非每个变量请求一次”显著减少网络延迟和费用。

八、过滤器注册表

filters.ts 有两个并行注册表:

text
filters[name]        → 执行函数
filterMetadata[name] → example + 参数 validator

执行与校验分离,但共享名称集合 validFilterNames。目前可按用途分组:

文本大小写与命名

lower, upper, capitalize, title, camel, pascal, snake, kebab, uncamel, safe_name, trim

集合与结构

first, last, nth, slice, split, join, unique, reverse, merge, map, object, length

Markdown 生成

markdown, blockquote, callout, link, wikilink, image, list, table, footnote, fragment_link

HTML 清洗

remove_html, remove_tags, replace_tags, strip_tags, remove_attr, strip_attr, strip_md / stripmd, html_to_json

数字与时间

calc, round, number_format, date, date_modify, duration

通用变换

replace, template, decode_uri, unescape

九、过滤器链如何解析

旧路径 applyFilters(value, filterString) 需要从原始字符串拆链。它不能直接 split('|')split(':'),因为参数可能含这些字符。

splitFilterString()parseFilterString() 复用 parser-utils 的状态:

text
inQuote
inRegex
curlyDepth
parenDepth
current buffer

只有在所有嵌套状态关闭时,| 才是过滤器边界,第一个合法 : 才是名称/参数边界。

新 AST 路径已经预解析 FilterExpression,直接走 applyFilterDirect(),避免二次拆解;旧路径保留给 deferred processor 和兼容调用。

十、参数验证

复杂过滤器暴露 validator:

filter校验重点
calc算式格式与操作数
date_modify时间偏移表达式
mapitem => expression 结构
replaceold/new 参数数量与引号
slicestart/end 数字
template模板占位语法
list列表类型
nth索引
objectkeys/values 等模式
round小数位
safe_name目标平台

validator 返回 { valid, error } 而不是抛异常,便于设置页一次展示多个问题。

十一、特殊过滤器上下文

markdownfragment_link 依赖当前 URL:

  • markdown 没传 base 时自动用 currentUrl,正确解析相对链接;
  • fragment_link 自动附加 currentUrl,生成指向原页面文本片段的链接。

这说明过滤器并非纯 (value, param) => value;registry 外层负责注入环境上下文,具体实现仍保持简单。

十二、数组如何穿过字符串接口

历史接口把 FilterFunction 定义为接收 string,返回 string 或 array;renderer 的值又可能是对象。源码采用务实的 JSON 桥:

  1. 非字符串输入 JSON.stringify
  2. 过滤器返回看似 [/{ 开头的字符串时尝试 parse;
  3. 链中允许数组继续传递;
  4. 最终结果统一转字符串。

这不是最强类型的设计,却保住了旧过滤器接口与新 AST 集合语义的兼容。

十三、map 为什么特别复杂

map:item => item.name 实际内嵌一个微型表达式。过滤器参数解析必须避免把箭头函数加引号,validator 还要确认变量名与表达式存在。

它使模板能对 schema 数组做投影:

liquid
{{ schema:author | map:item => item.name | join:", " }}

但复杂逻辑更推荐原生 {% for %},因为 AST 作用域与错误定位更清晰。

十四、格式化与清洗不是一回事

  • markdown:HTML → Markdown,保留语义;
  • strip_tags:删除指定标签但可保留内容;
  • remove_tags:按实现规则连标签/内容处理;
  • remove_html:大范围清除 HTML;
  • strip_md:Markdown → 纯文本;
  • safe_name:面向文件系统清理非法字符。

模板作者应先确定目标语义,再选过滤器;错误组合容易造成重复转义或内容丢失。

十五、新增变量的正确边界

新增变量前先判断来源:

来源应修改的位置
Defuddle 已有字段buildVariables()
Schema 派生schema variable resolver
DOM 即时查询selector 或新 async resolver
AI 生成prompt/model processor
设置/运行时状态明确环境适配器,不要污染 API core

并同步 parser 的预设变量集合,否则运行正常但编辑器会误报 unknown variable。

十六、新增过滤器的步骤

  1. utils/filters/<name>.ts 实现纯函数;
  2. 若参数复杂,导出 validator;
  3. filters.ts import;
  4. 添加 filterMetadata
  5. 添加 filters 执行注册;
  6. 加独立 *.test.ts,覆盖空值、Unicode、错误参数与链式输入;
  7. 确认 AST path 和 legacy string path 结果一致;
  8. 更新用户文档。

十七、本章检查点

  • 能区分预设、schema、selector、prompt、model 变量的就绪时间;
  • 能解释 selector 的三种取值方式;
  • 能说出 filters 与 filterMetadata 的职责差异;
  • 能解释 AST direct path 与 legacy string path;
  • 能按来源把新变量放到正确边界。

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