与买桂花同载酒 🌙

Hexo 自定义插件实录:给博客加一个统计命令

前言

上周整理博客仓库时发现一个问题:scripts/ 目录里躺着三个图片迁移脚本——那是当初做 WebP 图片迁移时写的一次性工具。问题在于,Hexo 会自动加载并执行 scripts/ 目录下的每个 .js 文件,且每次执行任何 hexo 命令都会跑一遍。也就是说,每次 hexo g,三个迁移脚本都会白白扫描全部文章 dry-run 一轮。

把它们挪到 tools/(改成手动 node tools/xxx.js 执行)之后,scripts/ 空了。空着也是空着,正好写个真的插件——顺便把 Hexo 的插件机制摸清楚。这篇文章记录整个过程:插件能干什么、怎么写、每一行在做什么。

先搞清楚:scripts/ 是个什么目录

Hexo 有个”魔法目录”:项目根下的 scripts/,里面的每个 .js 文件都会在执行任何 hexo 命令时被自动 require,不需要在 package.json 里登记。文件里有一个全局变量 hexo,就是 Hexo 实例本身——类比浏览器页面里的 window

关键认知是:npm 插件和 scripts/ 文件用的是同一套 API。你 package.json 里那些 hexo-generator-searchdbhexo-blog-encrypt,和 scripts/ 里的自写脚本,调的都是 hexo.extend.filter.register(...) 这类接口,区别只是加载来源不同。写本地插件,本质上就是”把 npm 插件搬到自己手里写”。

所以两个目录的分工现在是:

目录 加载方式 放什么
scripts/ 每次命令自动执行 常驻插件(每次构建都该跑的逻辑)
tools/ 手动执行 一次性工具(迁移脚本等)

插件能干什么:三类最常用的扩展点

Hexo 的扩展点都挂在 hexo.extend 下面,最常用的三个:

扩展点 作用 类比
filter 过滤器 挂进渲染流水线,加工每篇文章 给每篇文章订阅 Loaded 事件
generator 生成器 凭空造一个新页面路由 构建时往输出目录塞个自己算出来的文件
console 命令 给 CLI 加一条新命令 自定义命令绑定

此外还有 renderer(教 Hexo 认识新文件格式)、helper(主题模板里用的自定义函数)、injector(往所有页面注入脚本,统计代码就这么加)等等。你现在用的搜索、RSS、站点地图、文章加密,全部是这些 API 搭出来的。

目标:一个站点统计插件

想做个能回答”我这博客到底写了多少字”的东西。产出两个入口:

  1. npx hexo stats —— 终端直接打印统计
  2. npx hexo generate 时产出 stats.json —— 部署后可以公网访问

先看效果,npx hexo stats

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
━━ 站点统计 · 与买桂花同载酒 ━━
文章 151 篇(含加密 13 篇)
总字数 353,787 字,通读约 14.7 小时

按年:
2026 ************************ 92 篇 · 226,556 字
2025 **** 16 篇 · 33,041 字
2024 * 1 篇 · 2,109 字
2023 ** 6 篇 · 11,858 字
2022 ** 9 篇 · 38,677 字
...
2010 * 1 篇 · 2,478 字
2009 * 1 篇 · 1,306 字

最长三篇:
1. 《置身钉内》22,110 字(约 55 分钟)
2. 《暴力破解的文章-2》17,283 字(约 43 分钟)
3. 《一个程序员的奋斗史》9,446 字(约 24 分钟)

算完才知道:17 年、151 篇、35 万字——通读自己的博客要 14.7 个小时,还挺有成就感的。

构建时则会在 public/ 下多出 stats.json,部署后访问 https://你的域名/stats.json 就能拿到机器可读的版本,以后想做个数据面板可以直接消费它。

代码逐段讲解

完整代码放在文末附录,这里按模块拆。

工具函数:中英混排字数统计

1
2
3
4
5
6
7
8
function countWords(text) {
const plain = String(text)
.replace(/```[\s\S]*?```/g, ' ') // 代码块不计入正文
.replace(/<[^>]+>/g, ' '); // 去 HTML 标签
const cjk = (plain.match(/[一-龥]/g) || []).length;
const en = (plain.replace(/[一-龥]/g, ' ').match(/[A-Za-z0-9]+/g) || []).length;
return cjk + en;
}

几个点:

  • /.../ 是 JS 的正则字面量,C# 要 new Regex("..."),JS 两个斜杠包起来就是对象。结尾的 g 表示替换所有匹配——忘了加 g 只换第一处,这是 C# 转 JS 的头号陷阱。
  • [\s\S]*? 匹配代码块:\s 空白、\S 非空白,并集等于”任意字符包括换行”(正则的 . 默认不跨行);? 懒惰匹配,遇到第一个闭合就停,防止两段代码块连成一片。
  • [一-龥] 是中文区间: 是 U+4E00, 是 U+9FA5,覆盖常用汉字。先数中文的”字”,再把中文抹掉数英文的”词”,两者相加——比直接 .length 更接近真实阅读量。
  • match(...) 匹配不到返回的是 null 而不是空数组,|| [] 就是防御这个(等价 C# 的 ?? new List<>())。

① 过滤器:给每篇文章挂上字数

1
2
3
4
5
hexo.extend.filter.register('before_post_render', function (data) {
const words = countWords(data.raw || data.content || '');
data.stats = { words, minutes: readingMinutes(words) };
return data; // 过滤器必须 return 数据,丢了返回值文章就丢了
});

register(时机, 处理函数) 相当于 button.Click += handler,事件源是 Hexo 流水线,每篇文章触发一次。参数 data 就是当前文章对象,front-matter 里的字段都在上面。JS 对象随时可加属性,不用预定义类——这里直接给文章动态挂了个 stats 字段。

两个坑值得记:

  1. 必须 return data。过滤器是传送带,不把数据递回去,下游拿到的就是 undefined,这篇文章直接丢了。
  2. 时机选 before_post_render 而不是 after_post_render。我的博客用了 hexo-blog-encrypt 加密 13 篇文章,它挂在 after 阶段——等它跑完,文章内容已经是密文,字数统计会变成乱码计数。选在加密之前统计,加密文章也能算出真实字数。过滤器是有先后顺序的,时机选对就没冲突。

② 生成器:产出 stats.json

1
2
3
4
5
6
7
8
hexo.extend.generator.register('stats', function (locals) {
const stats = aggregate(locals.posts.toArray());
const payload = Object.assign(
{ site: hexo.config.title, url: hexo.config.url, generatedAt: new Date().toISOString() },
stats
);
return { path: 'stats.json', data: JSON.stringify(payload, null, 2) };
});

生成器拿到 locals——Hexo 递过来的全站数据快照,locals.postslocals.categories 都在里面。你现在用的搜索、RSS、站点地图插件,干活的入口都是这个函数。

返回值就是产出物:path 是相对 public/ 的文件路径(也是访问 URL),data 是文件内容。不写 layout 字段就是裸文件直出(json/xml/txt 都这么造);写了就会套主题模板渲染成网页。JSON.stringify(x, null, 2) 里的 2 是缩进两格美化输出。

hexo.config 就是 _config.yml 解析后的对象,YAML 键直接变属性。

③ 命令:npx hexo stats

1
2
3
4
5
6
7
hexo.extend.console.register('stats', '打印全站文章统计', {}, function () {
const h = this; // 回调里的 this 就是 hexo 实例
return h.load().then(() => {
const s = aggregate(h.locals.get('posts').toArray());
// ...打印表格...
});
});

四个参数:命令名、描述、命令行选项(空对象占位)、执行函数。

h.load() 让 Hexo 扫描 source/ 装载数据库,返回 Promise——JS 的异步任务对象,相当于 C# 的 Task,.then(...) 相当于 ContinueWith。必须返回这个 Promise,Hexo 靠它知道命令什么时候执行完、该不该退出进程。

this 是 JS 和 C# 差异最大的地方:C# 的 this 由类定义决定,JS 的 this 由调用方式决定(谁调用指向谁)。框架回调你的函数时把 hexo 实例设成了 this,但为了稳妥,进回调先 const h = this 存进局部变量——一眼看清来源,也不怕作用域里 this 变义。

验证命令真的注册上了

写完插件跑 npx hexo --help,能看到 stats 和 13 个内置命令并排出现:

1
2
3
4
5
6
7
Commands:
clean Remove generated files and cache.
generate Generate static files.
new Create a new post.
server Start the server.
...
stats 打印全站文章统计

这揭示了一个有意思的事实:Hexo 本身只是个命令分发器。你敲 hexo stats,它启动 → 加载所有插件 → 拿命令名去注册表里查 → 查到就调用注册的函数,查不到就打印 Usage 报错。那些内置命令并不是什么特殊通道,同样是 Hexo 核心代码启动时用一模一样的 register() 注册进去的。这也解释了为什么 scripts/ 里的文件”每次命令都会执行”——分发器必须在启动阶段把所有注册工作跑完,才知道你输入的名字该分发给谁。

删掉这个文件,npx hexo stats 就会报 Usage 错误——命令跟着插件一起消失,零配置残留。

C# 程序员速查表

这次写下来攒的对照,给同样背景的人:

C# JavaScript
Regex.Replace(s, @”..”, “”) s.replace(/../g, ‘’)(记得 g)
a ?? b a \ \ b
list.Select(x => …) arr.map(x => …)
list.Where(…) arr.filter(…)
list.Aggregate(0, …) arr.reduce(…)
OrderBy(不改原序列) slice().sort()(必须先复制)
$”…{x}…” ...${x}...
Task + ContinueWith Promise + .then(…)
this 永远指当前类 this 看调用方式,进回调先存 const h = this

特别标注 slice().sort():JS 的 sort()原地修改(同 C# 的 List.Sort),而 LINQ 的 OrderBy 返回新序列。从 LINQ 过来的人不先 .slice() 复制,会把原数组顺序也改掉。

后记

一些收尾细节:

  • 这个插件每次构建都会跑(包括 GitHub Actions 的 CI),但 151 篇文章数一遍字数是毫秒级的,开销可忽略。
  • stats.json 会发布到公网,只含统计数字不含正文,介意的话删文件即可。
  • 卸载 = 删掉整个 scripts/site-stats.js,无任何残留。

回头看,Hexo 的插件体系就是一套”控制反转”:你不调用框架,而是把函数登记到框架的各个时机上,框架在合适的时刻来调用你。和 WPF 里写事件处理器、写 Attached Behavior 是同一种思路——想通这一点,再打开 searchdb、encrypt 这些插件的源码,基本都能看懂了。

附录:完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/* scripts/site-stats.js —— 本地插件:站点统计 */
'use strict';

function countWords(text) {
const plain = String(text)
.replace(/```[\s\S]*?```/g, ' ')
.replace(/<[^>]+>/g, ' ');
const cjk = (plain.match(/[一-龥]/g) || []).length;
const en = (plain.replace(/[一-龥]/g, ' ').match(/[A-Za-z0-9]+/g) || []).length;
return cjk + en;
}

function readingMinutes(words) {
return Math.max(1, Math.round(words / 400));
}

/* ① 过滤器:文章渲染前,把字数挂到文章对象上 */
hexo.extend.filter.register('before_post_render', function (data) {
const words = countWords(data.raw || data.content || '');
data.stats = { words, minutes: readingMinutes(words) };
return data;
});

/* 公共聚合逻辑:一份代码,同时喂给 ② 和 ③ */
function aggregate(posts) {
const items = posts.map(p => ({
title: p.title,
year: p.date ? p.date.year() : 0,
words: (p.stats && p.stats.words) || countWords(p.raw || ''),
encrypted: Boolean(p.password),
category: (p.categories.toArray()[0] || {}).name || '未分类'
}));

const totalWords = items.reduce((s, x) => s + x.words, 0);
const yearMap = new Map();
const catMap = new Map();
for (const it of items) {
const y = yearMap.get(it.year) || { year: it.year, posts: 0, words: 0 };
y.posts++; y.words += it.words; yearMap.set(it.year, y);
const c = catMap.get(it.category) || { name: it.category, posts: 0, words: 0 };
c.posts++; c.words += it.words; catMap.set(it.category, c);
}

return {
posts: items.length,
encrypted: items.filter(x => x.encrypted).length,
totalWords,
readingTimeMinutes: readingMinutes(totalWords),
byYear: [...yearMap.values()].sort((a, b) => b.year - a.year),
byCategory: [...catMap.values()].sort((a, b) => b.posts - a.posts),
longest: items.slice().sort((a, b) => b.words - a.words).slice(0, 3)
.map(x => ({ title: x.title, words: x.words, minutes: readingMinutes(x.words) }))
};
}

/* ② 生成器:产出 /stats.json */
hexo.extend.generator.register('stats', function (locals) {
const stats = aggregate(locals.posts.toArray());
const payload = Object.assign(
{ site: hexo.config.title, url: hexo.config.url, generatedAt: new Date().toISOString() },
stats
);
return { path: 'stats.json', data: JSON.stringify(payload, null, 2) };
});

/* ③ 控制台命令:npx hexo stats */
hexo.extend.console.register('stats', '打印全站文章统计', {}, function () {
const h = this;
return h.load().then(() => {
const s = aggregate(h.locals.get('posts').toArray());
console.log('\n━━ 站点统计 · ' + h.config.title + ' ━━');
console.log(`文章 ${s.posts} 篇(含加密 ${s.encrypted} 篇)`);
console.log(`总字数 ${s.totalWords.toLocaleString()} 字,通读约 ${(s.readingTimeMinutes / 60).toFixed(1)} 小时`);
console.log('\n按年:');
const max = Math.max.apply(null, s.byYear.map(y => y.posts));
for (const y of s.byYear) {
const bar = '*'.repeat(Math.max(1, Math.round(y.posts / max * 24)));
console.log(` ${y.year} ${bar} ${y.posts} 篇 · ${y.words.toLocaleString()} 字`);
}
console.log('\n最长三篇:');
s.longest.forEach((x, i) =>
console.log(` ${i + 1}. 《${x.title}${x.words.toLocaleString()} 字(约 ${x.minutes} 分钟)`));
console.log('');
});
});