复盘:一条 ignore 配置引发的博客内容污染
复盘:一条 ignore 配置引发的博客内容污染
先说结论:这次事故的重点不是“我把目录放错了”,而是一条看起来完全正确、而 Hexo 连一个字都不警告的 ignore 配置,静默地什么都没干。
我在 _config.yml 里按官方文档的风格写了 _posts/copilot/**,跑 hexo generate 一切正常、退出码 0、没有任何 warning、文章列表也不报错——而线上依旧挂着两篇我不认识的文章。等把源码翻出来才发现,自己同时踩了两个坑:一个是目录位置,一个是 Hexo 6.x 用绝对路径去做 ignore 匹配。第二个坑才是真正浪费时间的那个。
一、症状:一篇标题叫占位符的文章
事情是从一个奇怪的内链开始的。我在首页刷到一篇文章,标题显示成 {{title}} 这种字面量,点进去正文是空的,URL 是 /posts/pagelink.html。
顺着 URL 猜 abbrlink,.md 文件名可能被改过,但 front-matter 里的 abbrlink: pagelink 一定还在。全仓 grep 一次就定位到了:
1 | # source/_posts/scaffolds/page.md |
这不是文章,这是 hexo new page 用的页面模板。它的 title 就是占位符本身,date 也是占位符,所以渲染出来的文章标题永远是 {{title}}、发布时间永远是字符串 {{date}} {{time}}。它被当成普通文章送进渲染管线,于是诞生了 /posts/pagelink.html。
顺手查 Tag 页,又冒出来一个 /tags/copilot-conversation/,点进去是 /posts/0.html:
1 | # source/_posts/copilot/copilot-conversations/agent__xxx.md |
这是 Obsidian Copilot 插件的对话归档,一整个目录 16 个 .md,front-matter 是插件自己写的元数据,abbrlink 还是 ‘0’——其中两个文件的 abbrlink 完全一样,于是它们在 db.json 里互相覆盖,16 篇污染最后只暴露成 1 篇。这个掩盖效应很要命:你看到的症状规模,远小于实际污染规模。
这里我不太同意“这就是点脏数据,删了就完事”的说法。 它已经不是脏数据了,它是污染:它进了 db.json、进了 Tag 索引、进了 sitemap、被静态托管同步到了线上、还被 CDN 缓存住。删源文件只能让下一次构建变干净,线上那几份副本得另外处理。
二、Hexo 凭什么决定“什么算文章”
先给总览。下面这张图是这次的完整问题链路,从目录一直走到你浏览器里看到的那个幽灵页面:
flowchart LR
A[普通目录放进 source/_posts] --> B[readDirWalker 遍历目录]
B --> C{ignore 是否命中目录本身}
C -->|是| D[目录层剪枝 整棵子树跳过]
C -->|否| E{isHiddenFile 判定路径段首字符}
E -->|是 下划线或点| F[当作隐藏文件跳过]
E -->|否| G[注册为文章并渲染]
G --> H[写入 db.json 与 public]
H --> I[静态托管部署]
I --> J[自定义域名经 CDN 缓存]
J --> K[线上出现幽灵页面]
这张图想让你看见什么:能不能变成“文章”,只由两个判定决定——ignore 匹配和 isHiddenFile。而“线上出现幽灵页面”在链条最末端,意味着你在前端犯的错,要先经过渲染、写库、部署、缓存四跳才变得可见;反过来,在前端删掉文件,也不会让末端立刻恢复。这条链路是不对称的,这是所有排查时间的来源。
之所以 source/_posts/scaffolds/page.md 会被当文章,是因为 scaffolds 这个路径段既不以 _ 开头、也不以 . 开头。判定就写在 node_modules/hexo/lib/plugins/processor/common.js 里:
1 | // node_modules/hexo/lib/plugins/processor/common.js:19-21 |
这段代码在说明什么:正则 /(^|/)[_.]/ 是按路径段匹配的,不是按文件名。所以 source/_posts/.obsidian/、.copilot/、.agents/、.trash/ 这些点开头的目录天然安全,名叫 .nopost 的目录也一样安全;而 scaffolds/、copilot/ 这种普通名字的目录天然不安全。我仓库里 _posts 下确实混着这两类目录,事故只是其中不安全的那一半发作了。
顺带一句:exclude 帮不上忙。官方文档在 exclude 的示例旁边就明写了 “_posts/hello-world.md # Does not work”,要排除 _posts 里的东西只有两条路——给文件或目录名加 _ 前缀,或者用 skip_render。
三、第二个坑:ignore 是拿绝对路径匹配的
我第一次的写法是这样的,很符合直觉:
1 | ignore: |
这段代码在说明什么:什么也没说明,因为它一条都不生效。hexo generate 不报错、不警告,页面照旧生成。静默失效,这是配置类事故里最难查的一类。
真正的判定在 node_modules/hexo/lib/box/index.js:
1 | // :244-246 |
这段代码在说明什么:base 是 Box 的绝对路径(这个仓库是 /Users/youqi/u7_blog/source/_posts),fullpath = join(base, path) 更是绝对路径。所以第 261 行交给 micromatch 的永远是 /Users/youqi/u7_blog/source/_posts/copilot/x.md 这种整串,而 _posts/copilot/** 被 makeRe 编译成整串锚定的正则,开头就对不上,永不命中。
模式本身非法时 toRegExp 会 log.warn;但模式语法合法却匹配不到任何东西时,Hexo 一声不吭。这就是“配置写了、等于没写”的机制。
正确的写法必须用 **/ 前缀去吃掉那段绝对路径:
1 | # _config.yml |
这段代码在说明什么:**/ 负责匹配掉 /Users/youqi/u7_blog/source/ 这段前缀,剩下的部分才和真实路径对齐。官方文档的 ignore 示例本身也是这个形态(**/foo、**/themes/*/foo),只是它没解释“为什么非要 **/”,我自己也没往那个方向想。
四、为什么 scaffolds 不带 /* 反而更狠
这里有个容易写反的方向问题:直觉上 **/_posts/scaffolds/* 比 **/_posts/scaffolds “更精确”,实际上前者弱得多。我在仓库里直接跑了 micromatch 验证(Hexo 6.3.0 依赖同一个库):
1 | const { isMatch } = require('micromatch'); |
这段代码在说明什么:**/_posts/scaffolds 只匹配目录本身,一个文件都不匹配;**/_posts/scaffolds/* 只匹配直接子文件,目录本身不匹配。两者不是强弱关系,是打在完全不同的位置上。
| 模式 | 命中目录本身 | 命中目录下直接文件 | 命中深层文件 |
|---|---|---|---|
_posts/scaffolds/** |
否 | 否 | 否 |
**/_posts/scaffolds/* |
否 | 是 | 否 |
**/_posts/scaffolds |
是 | 否 | 否 |
**/_posts/copilot/* |
否 | 是 | 否 |
**/_posts/copilot |
是 | 否 | 否 |
差别之所以是质变,在于第 249 行那句 if (isIgnoreMatch(base, ignore)) return Promise.resolve();——一旦模式命中目录本身,整棵子树连 readdir 都不会发生。而只命中文件时,目录照样被进入、逐层递归,你必须为每一层深度各补一条模式。
我这次是运气好才暴露出来的:Copilot 的目录不止一层,copilot/copilot-conversations/*.md 有 16 个,还有 copilot/skills/openartifacts-publish/SKILL.md 这种更深的。写 **/_posts/copilot/* 能挡住第一层,深层照样漏,而且是那种“我明明配了 ignore 啊”的漏。
flowchart TD A[ignore 模式写在哪个层级] --> B[匹配到目录本身] A --> C[只匹配到子文件] B --> D[readDirWalker 第一行直接返回] D --> E[整棵子树不再递归 深层文件全挡住] C --> F[目录仍被进入 递归继续向下] F --> G[必须为每一层深度各补一条模式] G --> H[漏一层就漏一篇 且不报错] E --> I[文章数从 79 回到 77]
这张图想让你看见什么:同一个 ignore 选项,写在不同粒度上,走的是两条完全不同的代码路径——一条在进入目录前就返回,一条要在每个文件上反复判定。前者是 O(1) 剪枝,后者是 O(深度) 补丁。凡是“排除整个目录”的意图,就该让它命中目录本身。
五、修复与验证:从 79 到 77
改动只有 _config.yml 那两行(外加一份 .gitignore,下一节说),但验证要做全。我实际执行的是 hexo clean && hexo generate——不清缓存的话 db.json 里已有条目可能残留,这点我踩过:只跑 generate 时文章数没立刻回到预期。
| 检查项 | 期望 | 修复后实测 |
|---|---|---|
| 文章数(generate 输出) | 79 → 77 | 77 |
/posts/pagelink.html |
404 | 404 |
/posts/0.html |
404 | 404 |
/tags/copilot-conversation/ |
404 | 404 |
| 任意一篇正常文章 | 200 | 200 |
对照文章必须一起验。只测 404 是有盲区的:如果把 ignore 写成 **/_posts,上面三个 URL 都会 404,看起来“修好了”,其实整站文章全没了。
事后再复核一次也有旁证:现在 db.json 里 Post 模型已经没有 pagelink、0.html、copilot 相关的任何 key,说明污染没有留在增量缓存里。
六、这里我不太同意“用 .gitignore 就够了”
出事后不止一个人跟我说:加 .gitignore 不就行了。我当时也这么想过,后来发现这个思路只对了一半。
.gitignore 和 hexo ignore 的边界完全不同:前者决定“哪些文件进仓库”,后者决定“哪些文件被 Hexo 扫描”。一个目录完全可以同时是“不该进仓库”和“会被 Hexo 扫描”的。我这仓库的实际情况正好把三种组合都占全了:
| 目录 | 需要 .gitignore | 需要 hexo ignore | 原因 |
|---|---|---|---|
source/_posts/copilot/ |
需要 | 需要 | 工具数据,既不进仓库也不该被扫 |
source/_posts/.copilot/ .agents/ |
需要 | 不需要 | 点开头,isHiddenFile 已经挡掉 |
source/_posts/scaffolds/ |
不能 | 需要 | 模板必须留在仓库,但绝不能当文章发 |
第三行是决定性的反例:scaffolds/page.md 是 hexo new page 要读的模板,把它塞进 .gitignore 等于把脚手架扔了;它必须被 git 跟踪,同时必须被 Hexo 忽略。所以 .gitignore 在这件事上不是“更彻底的方案”,它连这个问题的一半都覆盖不到。
我的判断:判定标准只有一条——除了 git 之外,还有谁会读这个目录。git 读完就没别人读,.gitignore 足够;Hexo 还要读一遍,就必须写 hexo ignore。真正的治本在更前面一层:非文章的东西,一开始就别放进 source/_posts。
七、三类东西永远不该放进 source/_posts
source/_posts 在 Hexo 里不是一个“文件夹”,是一个输入队列:除 _ 和 . 开头的路径段以外,一切都会被当文章。所以下面三类东西放进去,等于预约一次事故:
- 模板与脚手架。scaffolds/、页面模板、任何带
{{title}}、{{date}}占位符的文件。它们的正确位置是项目根的 scaffolds/,不是 source/_posts/scaffolds/。 - 工具的本地数据目录。Obsidian Copilot 的对话归档、.obsidian/、.trash/、.nopost、各种插件缓存。这些目录归工具所有,它们的 front-matter 不是写给 Hexo 的,abbrlink 撞车是必然。
- 别人系统的导出物。AI 会话记录、导出日志、抓取结果。它们的字段结构(sessionId、usage、epoch)根本不在 Hexo 的 schema 里,Hexo 只会用默认值把它们硬塞成一篇文章。
我踩过的坑:我曾经以为“文件名随便写、反正会被当成文章”是 Hexo 的宽容。它不是宽容,它是没有判断力——它没有能力区分“作者写的文章”和“恰好是 .md 的东西”,这个判断只能由目录结构来承担。
八、部署成功不等于线上生效
配置改完、generate 干净、本地 curl 200,我就以为结束了。结果刷新自定义域名,看到的还是旧页面。
第一次抓下来 92KB,页面里还有那段明文内容;隔一会儿再抓,123KB,变成新页面了。中间什么都没改。原因是自定义域名前面有一层 CDN,静态托管那边的文件已经更新,边缘节点还在吐旧副本。
sequenceDiagram participant L as 本地 clean 与 generate participant R as git 仓库 participant H as 静态托管 participant C as 自定义域名 CDN participant B as 我的浏览器 L->>R: 提交两条 ignore 与清理后的 public R->>H: 触发构建并部署 H->>C: 回源取到新页面 C-->>B: 仍返回旧缓存 92KB B->>C: 刷新后再次请求 C-->>B: 返回新页面 123KB
这张图想让你看见什么:部署完成和线上生效之间隔着一次缓存失效,而且这次失效不会通知你。注意我是靠字节数变化(92KB 到 123KB)才确认拿到的是两份不同内容——状态码两次都是 200,只看 curl -I 看不出任何异常。
我的理解:验证“页面已消失”这件事,只看 HTTP 状态码不够,得同时看内容指纹(文件大小、或者一段特征字符串还在不在)。后来的做法是先 curl 拿正文 grep 那段特征文案,确认确实是新内容;拿不到再加查询串绕缓存,还是不行才去刷 CDN。
九、下次直接照做的清单
- 把非文章目录挡在 source/_posts 之外:脚手架放项目根 scaffolds/,工具数据放到 source/ 之外的路径,这一步能消掉大部分事故。
- ignore 一律用
**/前缀,并且匹配目录本身:写'**/_posts/copilot',不要写'_posts/copilot/**',也别画蛇添足加/*。 - 改完 ignore 必须 hexo clean 再 generate,并核对文章数的变化符不符合预期——数字不动就是没生效。
- 验证要成对:被排除的 URL 验 404,同时挑一篇正常文章验 200,防止排除过头。
- 线上验证看内容不看状态码:比对文件大小或 grep 特征字符串,避免把 CDN 旧缓存当成修复成功。
- 同时需要 .gitignore 和 hexo ignore 时,想清楚是哪一个在起作用;scaffolds/ 这种必须入库又必须被忽略的目录,就是两者的分界线。
最后留一句自我提醒:这类配置坑的共同特征不是“配错了会报错”,而是“配错了什么都不发生”。对静默失败的东西,验证必须以数字变化为依据,而不是以“命令跑通了”为依据。