复盘:一条 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
2
3
4
5
# source/_posts/scaffolds/page.md
title: "{{title}}"
description: page默认模版
abbrlink: pagelink
date: "{{date}} {{time}}"

这不是文章,这是 hexo new page 用的页面模板。它的 title 就是占位符本身,date 也是占位符,所以渲染出来的文章标题永远是 {{title}}、发布时间永远是字符串 {{date}} {{time}}。它被当成普通文章送进渲染管线,于是诞生了 /posts/pagelink.html。

顺手查 Tag 页,又冒出来一个 /tags/copilot-conversation/,点进去是 /posts/0.html:

1
2
3
4
5
6
7
8
# source/_posts/copilot/copilot-conversations/agent__xxx.md
epoch: 1791876529103
mode: agent
backendId: codex
sessionId: 01a13429-cf08-7083-8213-2825df351b2f
tags:
- copilot-conversation
abbrlink: '0'

这是 Obsidian Copilot 插件的对话归档,一整个目录 16 个 .md,front-matter 是插件自己写的元数据,abbrlink 还是 ‘0’——其中两个文件的 abbrlink 完全一样,于是它们在 db.json 里互相覆盖,16 篇污染最后只暴露成 1 篇。这个掩盖效应很要命:你看到的症状规模,远小于实际污染规模。

这里我不太同意“这就是点脏数据,删了就完事”的说法。 它已经不是脏数据了,它是污染:它进了 db.json、进了 Tag 索引、进了 sitemap、被静态托管同步到了线上、还被 CDN 缓存住。删源文件只能让下一次构建变干净,线上那几份副本得另外处理。

二、Hexo 凭什么决定“什么算文章”

先给总览。下面这张图是这次的完整问题链路,从目录一直走到你浏览器里看到的那个幽灵页面:

这张图想让你看见什么:能不能变成“文章”,只由两个判定决定——ignore 匹配和 isHiddenFile。而“线上出现幽灵页面”在链条最末端,意味着你在前端犯的错,要先经过渲染、写库、部署、缓存四跳才变得可见;反过来,在前端删掉文件,也不会让末端立刻恢复。这条链路是不对称的,这是所有排查时间的来源。

之所以 source/_posts/scaffolds/page.md 会被当文章,是因为 scaffolds 这个路径段既不以 _ 开头、也不以 . 开头。判定就写在 node_modules/hexo/lib/plugins/processor/common.js 里:

1
2
3
4
5
6
7
8
9
10
11
12
// node_modules/hexo/lib/plugins/processor/common.js:19-21
function isHiddenFile(path) {
return /(^|\/)[_.]/.test(path);
}

// :24-30
function isExcludedFile(path, config) {
if (isTmpFile(path)) return true;
if (isMatch(path, config.exclude)) return true;
if (isHiddenFile(path) && !isMatch(path, config.include)) return true;
return false;
}

这段代码在说明什么:正则 /(^|/)[_.]/ 是按路径段匹配的,不是按文件名。所以 source/_posts/.obsidian/、.copilot/、.agents/、.trash/ 这些点开头的目录天然安全,名叫 .nopost 的目录也一样安全;而 scaffolds/、copilot/ 这种普通名字的目录天然不安全。我仓库里 _posts 下确实混着这两类目录,事故只是其中不安全的那一半发作了。

顺带一句:exclude 帮不上忙。官方文档在 exclude 的示例旁边就明写了 “_posts/hello-world.md # Does not work”,要排除 _posts 里的东西只有两条路——给文件或目录名加 _ 前缀,或者用 skip_render。

三、第二个坑:ignore 是拿绝对路径匹配的

我第一次的写法是这样的,很符合直觉:

1
2
3
ignore:
- '_posts/copilot/**'
- '_posts/scaffolds/**'

这段代码在说明什么:什么也没说明,因为它一条都不生效。hexo generate 不报错、不警告,页面照旧生成。静默失效,这是配置类事故里最难查的一类。

真正的判定在 node_modules/hexo/lib/box/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// :244-246
function isIgnoreMatch(path, ignore) {
return path && ignore && ignore.length && isMatch(path, ignore);
}

// :248-263
function readDirWalker(base, results, ignore, prefix) {
if (isIgnoreMatch(base, ignore)) return Promise.resolve(); // :249 目录级剪枝
return Promise.map(readdir(base).catch(err => {
if (err && err.code === 'ENOENT') return [];
throw err;
}), async path => {
const fullpath = join(base, path); // :255 绝对路径
const stats = await stat(fullpath);
const prefixdPath = `${prefix}${path}`;
if (stats.isDirectory()) {
return readDirWalker(fullpath, results, ignore, `${prefixdPath}/`);
}
if (!isIgnoreMatch(fullpath, ignore)) { // :261 传的是 fullpath
results.push(prefixdPath);
}
});
}

这段代码在说明什么: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
2
3
4
5
# _config.yml
# Hexo 6.x 的 ignore 是用「绝对路径」去匹配的,所以模式要带 **/ 前缀
ignore:
- '**/_posts/copilot'
- '**/_posts/scaffolds'

这段代码在说明什么:**/ 负责匹配掉 /Users/youqi/u7_blog/source/ 这段前缀,剩下的部分才和真实路径对齐。官方文档的 ignore 示例本身也是这个形态(**/foo、**/themes/*/foo),只是它没解释“为什么非要 **/”,我自己也没往那个方向想。

四、为什么 scaffolds 不带 /* 反而更狠

这里有个容易写反的方向问题:直觉上 **/_posts/scaffolds/* 比 **/_posts/scaffolds “更精确”,实际上前者弱得多。我在仓库里直接跑了 micromatch 验证(Hexo 6.3.0 依赖同一个库):

1
2
3
4
5
6
7
8
9
10
const { isMatch } = require('micromatch');
const dir = '/Users/youqi/u7_blog/source/_posts/scaffolds';
const file = dir + '/page.md';
const deep = '/Users/youqi/u7_blog/source/_posts/copilot/copilot-conversations/x.md';

isMatch(dir, ['**/_posts/scaffolds']); // true
isMatch(file, ['**/_posts/scaffolds']); // false
isMatch(dir, ['**/_posts/scaffolds/*']); // false
isMatch(file, ['**/_posts/scaffolds/*']); // true
isMatch(deep, ['**/_posts/copilot/*']); // false ← 深层文件挡不住

这段代码在说明什么:**/_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 啊”的漏。

这张图想让你看见什么:同一个 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 里不是一个“文件夹”,是一个输入队列:除 _ 和 . 开头的路径段以外,一切都会被当文章。所以下面三类东西放进去,等于预约一次事故:

  1. 模板与脚手架。scaffolds/、页面模板、任何带 {{title}}、{{date}} 占位符的文件。它们的正确位置是项目根的 scaffolds/,不是 source/_posts/scaffolds/。
  2. 工具的本地数据目录。Obsidian Copilot 的对话归档、.obsidian/、.trash/、.nopost、各种插件缓存。这些目录归工具所有,它们的 front-matter 不是写给 Hexo 的,abbrlink 撞车是必然。
  3. 别人系统的导出物。AI 会话记录、导出日志、抓取结果。它们的字段结构(sessionId、usage、epoch)根本不在 Hexo 的 schema 里,Hexo 只会用默认值把它们硬塞成一篇文章。

我踩过的坑:我曾经以为“文件名随便写、反正会被当成文章”是 Hexo 的宽容。它不是宽容,它是没有判断力——它没有能力区分“作者写的文章”和“恰好是 .md 的东西”,这个判断只能由目录结构来承担。

八、部署成功不等于线上生效

配置改完、generate 干净、本地 curl 200,我就以为结束了。结果刷新自定义域名,看到的还是旧页面。

第一次抓下来 92KB,页面里还有那段明文内容;隔一会儿再抓,123KB,变成新页面了。中间什么都没改。原因是自定义域名前面有一层 CDN,静态托管那边的文件已经更新,边缘节点还在吐旧副本。

这张图想让你看见什么:部署完成和线上生效之间隔着一次缓存失效,而且这次失效不会通知你。注意我是靠字节数变化(92KB 到 123KB)才确认拿到的是两份不同内容——状态码两次都是 200,只看 curl -I 看不出任何异常。

我的理解:验证“页面已消失”这件事,只看 HTTP 状态码不够,得同时看内容指纹(文件大小、或者一段特征字符串还在不在)。后来的做法是先 curl 拿正文 grep 那段特征文案,确认确实是新内容;拿不到再加查询串绕缓存,还是不行才去刷 CDN。

九、下次直接照做的清单

  1. 把非文章目录挡在 source/_posts 之外:脚手架放项目根 scaffolds/,工具数据放到 source/ 之外的路径,这一步能消掉大部分事故。
  2. ignore 一律用 **/ 前缀,并且匹配目录本身:写 '**/_posts/copilot',不要写 '_posts/copilot/**',也别画蛇添足加 /*。
  3. 改完 ignore 必须 hexo clean 再 generate,并核对文章数的变化符不符合预期——数字不动就是没生效。
  4. 验证要成对:被排除的 URL 验 404,同时挑一篇正常文章验 200,防止排除过头。
  5. 线上验证看内容不看状态码:比对文件大小或 grep 特征字符串,避免把 CDN 旧缓存当成修复成功。
  6. 同时需要 .gitignore 和 hexo ignore 时,想清楚是哪一个在起作用;scaffolds/ 这种必须入库又必须被忽略的目录,就是两者的分界线。

最后留一句自我提醒:这类配置坑的共同特征不是“配错了会报错”,而是“配错了什么都不发生”。对静默失败的东西,验证必须以数字变化为依据,而不是以“命令跑通了”为依据。