06 · 变量、选择器与过滤器
模板引擎提供“怎么执行”,变量与过滤器定义“能表达什么”。这是 Web Clipper 面向不同网站保持可扩展性的关键层。
一、变量的五个来源
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()。大致顺序:
- 当前局部 scope(
for/set); - 外层 scopes;
- 全局 variables;
- 特殊 schema 解析;
- async/deferred resolver;
- 找不到则
undefined。
成员访问支持:
{{ author.name }}
{{ authors[0].name }}当中间对象是 null/undefined 时返回 undefined,不让模板因一个字段缺失崩溃。
四、Schema 变量
Schema.org 数据来自页面 JSON-LD。源码支持:
{{ 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 语法
三种常见形式:
{{ 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 变量
模板中的:
{{ "Summarize this article in three bullets" }}
{{ prompt:"Suggest five tags" | list }}会被 collectPromptVariables() 收集、去重并编号为 prompt_1、prompt_2。Interpreter 一次请求把所有 prompt 一起送给模型,要求返回:
{
"prompts_responses": {
"prompt_1": "...",
"prompt_2": "..."
}
}响应再按 key 替换回模板,并应用原 prompt 后的 filters。这里“批量请求而非每个变量请求一次”显著减少网络延迟和费用。
八、过滤器注册表
filters.ts 有两个并行注册表:
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 的状态:
inQuote
inRegex
curlyDepth
parenDepth
current buffer只有在所有嵌套状态关闭时,| 才是过滤器边界,第一个合法 : 才是名称/参数边界。
新 AST 路径已经预解析 FilterExpression,直接走 applyFilterDirect(),避免二次拆解;旧路径保留给 deferred processor 和兼容调用。
十、参数验证
复杂过滤器暴露 validator:
| filter | 校验重点 |
|---|---|
calc | 算式格式与操作数 |
date_modify | 时间偏移表达式 |
map | item => expression 结构 |
replace | old/new 参数数量与引号 |
slice | start/end 数字 |
template | 模板占位语法 |
list | 列表类型 |
nth | 索引 |
object | keys/values 等模式 |
round | 小数位 |
safe_name | 目标平台 |
validator 返回 { valid, error } 而不是抛异常,便于设置页一次展示多个问题。
十一、特殊过滤器上下文
markdown 和 fragment_link 依赖当前 URL:
markdown没传 base 时自动用currentUrl,正确解析相对链接;fragment_link自动附加currentUrl,生成指向原页面文本片段的链接。
这说明过滤器并非纯 (value, param) => value;registry 外层负责注入环境上下文,具体实现仍保持简单。
十二、数组如何穿过字符串接口
历史接口把 FilterFunction 定义为接收 string,返回 string 或 array;renderer 的值又可能是对象。源码采用务实的 JSON 桥:
- 非字符串输入
JSON.stringify; - 过滤器返回看似
[/{开头的字符串时尝试 parse; - 链中允许数组继续传递;
- 最终结果统一转字符串。
这不是最强类型的设计,却保住了旧过滤器接口与新 AST 集合语义的兼容。
十三、map 为什么特别复杂
map:item => item.name 实际内嵌一个微型表达式。过滤器参数解析必须避免把箭头函数加引号,validator 还要确认变量名与表达式存在。
它使模板能对 schema 数组做投影:
{{ 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。
十六、新增过滤器的步骤
- 在
utils/filters/<name>.ts实现纯函数; - 若参数复杂,导出 validator;
- 在
filters.tsimport; - 添加
filterMetadata; - 添加
filters执行注册; - 加独立
*.test.ts,覆盖空值、Unicode、错误参数与链式输入; - 确认 AST path 和 legacy string path 结果一致;
- 更新用户文档。
十七、本章检查点
- 能区分预设、schema、selector、prompt、model 变量的就绪时间;
- 能解释 selector 的三种取值方式;
- 能说出 filters 与 filterMetadata 的职责差异;
- 能解释 AST direct path 与 legacy string path;
- 能按来源把新变量放到正确边界。