MxHanks' Blog

奔赴山海,保持热爱

0%

Hexo 博客折腾全记录

回顾这些年折腾 Hexo 博客的经历,从初建到复活再到各种功能配置,踩了不少坑也积累了一些经验。这篇文章把整个过程串联起来,既是记录也是分享。

建站之初

从 2021 年 10 月中旬开始,陆陆续续搭建这个自己的网站。由于不会用 GitHub,也不了解 Hexo、Node.js 这一类东西,所以花的时间比大部分人长。

建站路上遇到最坑的点就是下载的 NexT 主题有问题——需要修改主题目录下的 _config.yml 文件,否则会多一个 %20,删去 || 之间的空格即可。后来才发现原来我用的这个 NexT 已经停止维护了,于是换成了最新的 NexT。

反反复复换了很多次主题,但都不尽如人意,所以最后还是用了 NexT。


第一次复活

2023 年 2 月,寒假结束前,我复活了博客。

这个寒假过得有些混乱,但也有些收获。从家里急事回老家开始,在车上看完了《四月是你的谎言》——本来就弹钢琴的我被触动,买了一台雅马哈 PSR-e373 电子琴。发现电子琴可以作为 MIDI 输入器后,还想写一个 Minecraft mod 做实时 MIDI 转换,可惜老电脑带不动。

后来兴趣又回到了编程上,学习了 Python 的 OpenCV 和 PyQt 写了学校科技创新比赛的作品,又学了一点 Lua 语法准备做饥荒 mod。

一个寒假过去,学习没怎么搞,博客倒是又捡起来了。


第二次复活:全面功能配置

2024 年 2 月,再次清空电脑后,一切又重新开始。惊喜地发现上次复活博客也正好是 2 月 2 日——整整一年前的同一天。

这次趁着重新搭建,把博客的各种功能系统地配置了一遍。

LaTeX 支持

博客中经常需要数学公式(尤其是机器学习相关的笔记),LaTeX 支持是刚需。

方案一:hexo-renderer-kramed(适用于 matery 主题)

Hexo 默认的 hexo-renderer-marked 渲染引擎会将 LaTeX 中的下划线 _ 解析为 HTML 的 <i> 标签。hexo-renderer-kramed 修复了这个问题:

1
2
npm uninstall hexo-renderer-marked --save
npm install hexo-renderer-kramed --save

还需要修改 node_modules/kramed/lib/rules/inline.js 解决语义冲突:

1
2
3
4
5
// 第 11 行 escape 变量
escape: /^\\([`*\[\]()#$+\-.!_>])/, // 去掉对 {} 的转义

// 第 20 行 em 变量
em: /^\*((?:\*\*|[\s\S])+?)\*(?!\*)/, // 禁用 _ 作为斜体标记

方案二:hexo-renderer-markdown-it-plus(推荐)

1
2
npm uninstall hexo-renderer-marked --save
npm install hexo-renderer-markdown-it-plus

然后在主题 _config.yml 中启用 KaTeX:

1
2
3
4
5
math:
...
katex:
enable: true
copy_tex: false

踩坑记录:渲染器冲突

配置完成后发现 LaTeX 还是不生效,排查发现是因为同时安装了三个 Markdown 渲染器,它们互相冲突:

插件 支持 $...$ 最终状态
hexo-renderer-marked ❌ 卸载
hexo-renderer-kramed ✅,但修改 ❌ 卸载
hexo-renderer-markdown-it-plus ✅ 内置 KaTeX ✅ 保留

Hexo 7 中,多个渲染器注册同一扩展名(.md)时,最后注册的胜出。按加载顺序,hexo-renderer-marked 总是覆盖前面的,而它根本不处理 $...$ 数学公式

修复方法:

1
npm uninstall hexo-renderer-marked hexo-renderer-kramed --save

同时在站点 _config.yml 中配置 markdown_it_plus 插件并显式启用 KaTeX:

1
2
3
4
5
6
7
8
9
markdown_it_plus:
highlight: true
html: true
plugins:
- plugin:
name: '@iktakahiro/markdown-it-katex'
enable: true
options:
throwOnError: false

另外,NexT 主题默认 math.per_page: true,只有 front-matter 中声明了 katex: true 的文章才会加载 KaTeX 的 CSS/JS。所以每篇用到数学公式的文章都需要加上:

1
2
3
---
katex: true
---

做完这三步,$E=mc^2$$$\frac{1}{x^2-1}$$ 就能正常渲染了。

需要注意的是,KaTeX 不支持 \begin{equation} 环境,如果文章中用到了需要改写成 \begin{aligned} 等 KaTeX 支持的写法,或者换用 MathJax 引擎。

SSH Deploy Key 配置

GitHub 在国内连接不稳定,终端用 git 命令常失败,所以配置 SSH 密钥来提升部署稳定性。

生成 SSH 密钥对

1
ssh-keygen -t rsa -N '' -f /home/${USER}/.ssh/id_rsa_github_blog -q

.pub 结尾的是公钥。

添加 Deploy Key

进入 GitHub 仓库设置页,填写标题,粘贴公钥内容,勾选 “Allow write access”,点击 Add key。

配置 SSH Config

Linux~/.ssh/config):

1
2
3
Host github.com
Hostname ssh.github.com
IdentityFile ~/.ssh/id_rsa_github_blog

WindowsC:\Users\Administrator\.ssh\config):

配置方法大体相同,如果遇到端口 22 被屏蔽:

1
2
3
4
Host github.com
Hostname ssh.github.com
IdentityFile ~/.ssh/id_rsa_github_blog
Port 443

出现以下提示时键入 yes 即可:

1
2
3
The authenticity of host '[ssh.github.com]:443' can't be established.
ED25519 key fingerprint is SHA256:...
Are you sure you want to continue connecting (yes/no/[fingerprint])?

LeanCloud 评论与阅读量

评论功能(Valine)

LeanCloud 仪表盘的应用凭证中找到 AppID 和 AppKey,配置 NexT 主题的 _config.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Valine
valine:
enable: true
appid: [appid]
appkey: [appkey]
notify: false
verify: false
placeholder: Just go go
avatar: mm
guest_info: nick,mail,link
pageSize: 10
language:
visitor: false
comment_count: true
recordIP: true

阅读量功能

在 LeanCloud 中新建应用,数据存储 → 结构化数据 → 新建 Class,命名为 Counter,选择无限制。然后在主题 _config.yml 中配置:

1
2
3
4
5
6
leancloud_visitors:
enable: true
app_id: <your app id>
app_key: <your app key>
server_url:
security: false

Gitee / GitHub 镜像同步

虽然 Gitee 的实名认证没通过,但可以设置为从 GitHub 自动同步。

在 Gitee 仓库 → 管理 → 仓库镜像管理 → 添加镜像,设为 Pull 方向,选择镜像仓库和 GitHub 私人令牌。

申请 GitHub Token:用户头像 → Settings → Developer setting → Personal access tokens → Generate new token,勾选 repoadmin:repo_hook 字段。

详细教程参考 Gitee 官方文档

SEO 优化

生成 Sitemap

1
2
npm install hexo-generator-sitemap --save
npm install hexo-generator-baidu-sitemap --save

_config.yml 中配置:

1
2
3
4
sitemap:
path: sitemap.xml
baidusitemap:
path: baidusitemap.xml

推送到必应

进入 Bing Webmasters 进行配置。

文件目录管理

随着文章增多以及 Obsidian 记录习惯的养成,决定在 _posts 目录下按类别建立文件夹,每篇文章单独建文件夹,图片和文章放在同一级。

移动后发现 {% post_link %} 标签会报错,改用替代方案后解决。之前配置的拼音地址在分类目录上起了作用——提前铺路总是好事。

适配 Obsidian 双链引用

使用 hexo-backlink 插件将 Obsidian 的 [[WikiLink]] 转换为 Hexo 站内文章链接:

1
npm install hexo-backlink

_config.yml 中配置:backlink: true

Obsidian 中的设置:设置 → 文件和链接 → 新链接格式选择"文件的相对路径",并开启"使用 [[维基链接]]"。

字数统计

使用 hexo-word-counter 统计文章字数及阅读时间:

1
2
npm install hexo-word-counter
hexo clean

在站点 _config.yml 中添加:

1
2
3
4
5
6
7
8
9
symbols_count_time:
symbols: true
time: true
total_symbols: true
total_time: true
exclude_codeblock: false
awl: 4
wpm: 275
suffix: "mins."

在 NexT 主题 _config.yml 中设置 item_text_total: true

参数说明:中文为主的博客推荐设置 awl: 2wpm: 300


Hexo 默认生成的文章地址是类似 YEAR/MONTH/DAY/TITLE 的长路径,既不利于 SEO 也不利于链接持久化。

1
npm install hexo-permalink-pinyin --save
1
2
3
permalink_pinyin:
enable: true
separator: "-"

但这个方法仍然依赖文章标题,改了标题链接也会变。

方案二:hexo-abbrlink(短链接,推荐)

hexo-abbrlink 基于文章标题生成固定短链接(CRC16 或 CRC32),文章内容不变链接就不变,不依赖分类和标题。

1
npm install hexo-abbrlink --save

_config.yml 中配置:

1
2
3
4
5
6
7
8
# abbrlink config
abbrlink:
alg: crc32 # crc16(default) or crc32
rep: hex # dec(default) or hex
force: false # false(default), 设为 true 会重新生成所有 abbrlink

# 关键:需要在 permalink 中使用 :abbrlink
permalink: posts/:abbrlink/

踩坑记录

之前安装了这个插件但一直没生效,排查后发现原因很简单:permalink 没有使用 :abbrlink

插件在 before_post_render 阶段工作,它会读取(或生成)文章的 abbrlink 值并注入到数据中。但 Hexo 最终生成链接时,只用 permalink 模式中声明的变量。如果 permalink 里只有 :category:title,那 :abbrlink 再好看也不会出现在 URL 里。

所以配置 hexo-abbrlink 的关键就两步:

  1. 安装插件 + 写好 abbrlink 配置段
  2. 修改 permalinkposts/:abbrlink/(或其他含 :abbrlink 的模式)

这样生成的链接就是稳定的短链接了:

1
2
https://mxhanks.github.io/posts/48721/    # 而不是 /技术踩坑/hexopostassetfix/
https://mxhanks.github.io/posts/21936/ # 而不是 /软件工程/javabase/

旧链接兼容

开启 abbrlink 后,之前以旧格式发布的所有页面链接都会变化。被搜索引擎收录或外部引用的旧链接会 404。建议:

  • 更新 sitemap 让搜索引擎重新抓取
  • 如果使用 GitHub Pages 或 Nginx,可配置 301 重定向将旧路径永久指向新路径

post_asset_folder 图片路径修复

问题背景

我的博客文章组织和大多数人不太一样——习惯用 Obsidian 写文章,并且让图片和 markdown 文件保持在同一级目录。

目录结构类似:

1
2
3
4
5
6
7
source/_posts/
├── 人工智能/
│ ├── Regression/
│ │ ├── Regression.md ← 文章
│ │ ├── output_6_1.png ← 图片(与 md 同级)
│ │ └── output_15_0.png
│ └── ...

在 Hexo 开启 post_asset_folder: true 的情况下,图片却一直无法正常显示——<img>src 指向了根目录 /output_6_1.png,自然是 404。

原因分析

_config.yml 启用以下配置时:

1
2
3
4
post_asset_folder: true
marked:
prependRoot: true
postAsset: true

Hexo 会为每篇文章创建一个同名子文件夹作为资源目录。例如文章 my-post.md 的预期资源目录是 my-post/

但我的文章本身就在文件夹中,且文件夹名和文章名相同(Regression/Regression.md)。Hexo 计算的 asset_dir 变成了:

1
source/_posts/人工智能/Regression/Regression/

注意出现了两层 Regression/——第一层是文章所在的目录,第二层是 Hexo 自动追加的同名子文件夹。而我的图片放在第一层(与 Regression.md 同级),不在这个二层子文件夹里。

所以在构建流程中:

  1. Processing 阶段scanAssetDir 扫描 .../Regression/Regression/ → 目录不存在 → 没注册任何 PostAsset
  2. hexo-renderer-marked 渲染postAsset: true 查找 PostAsset.findById("...output_6_1.png") → 没找到 → 回退到 url_for("output_6_1.png") → 输出 <img src="/posts/688035c/output_6_1.png" > → 404

解决方案

核心思路是在渲染之前纠正图片 URL,在生成阶段把图片复制到正确位置。用了一个 Hexo 脚本(scripts/fix_asset_dir.js)实现两个过滤器:

Phase 1:注册 PostAsset(before_generate,优先级 1)

render_post 之前运行,扫描符合"同名目录"模式的文章目录,为同目录下的图片手动注册 PostAsset,让资源生成器把图片复制到 public 目录中。

Phase 2:修复 HTML 中的 <img> 路径(after_post_render

文章渲染完成后,将 <img src="/posts/688035c/output_6_1.png" > 替换为正确的绝对路径。

最终效果

项目 修复前 修复后
<img src> /output_6_1.png /posts/28955/output_6_1.png
图片文件在 public/ 不存在 ❌ 存在 ✅

多篇受影响的文章(Regression、Classification、Regularization、STM32 等)均自动修复,无需修改 markdown 源文件。

为什么不改源文件?

Obsidian 和 Hexo 共享同一套源文件。Obsidian 中图片就是和 md 同级的相对路径,改了 Hexo 的构建逻辑去适配它,比反过来改 Obsidian 配置更可持续。


自建服务器部署

博客一直部署在 GitHub Pages 上,国内偶有不稳定。手头有一台云服务器(CentOS + 宝塔面板),就把博客也同步部署上去,实现双线访问。

需求

  • 保留 GitHub Pages 部署不变
  • 增加自建服务器同步
  • hexo d 一条命令搞定两边
  • 不需要手动上传文件

方案对比

方案 原理 Windows 兼容性
hexo-deployer-rsync 本地 rsync → 远程 rsync over SSH ❌ Windows 无 rsync
hexo-deployer-sftp Node.js ssh2 库直连 SFTP ⚠️ 密钥格式问题
自定义 tar+ssh tar 打包 → SSH pipe → 远端解压

前两个方案在 Windows 上都遇到了问题,最终选择了第三种。

SSH 密钥配置

1
2
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_blog_server -N "" -C "blog-server"
ssh-copy-id -i ~/.ssh/id_rsa_blog_server root@47.115.49.75

配置 ~/.ssh/config

1
2
3
4
Host 47.115.49.75
Hostname 47.115.49.75
IdentityFile ~/.ssh/id_rsa_blog_server
User root

Nginx 配置

1
2
3
4
5
6
7
server {
listen 80;
server_name _;
root /www/wwwroot/default;
index index.html;
...
}

核心:tar + SSH 管道

1
cd public && tar cf - . | ssh root@47.115.49.75 "tar xf - -C /www/wwwroot/default"

原理:tar cf - . 把当前目录打包输出到 stdout,通过 SSH 管道发送到远端解压。

优点:不需要 rsync、tar 和 ssh 在 Git Bash 中都是原生可用、一条管道效率高。

踩坑:Hexo 7 的事件 vs 过滤器

第一次尝试用 hexo.extend.filter.register('after_deploy', ...) 来挂载服务器部署,但脚本加载了却从不执行。查了 Hexo 7 源码才发现问题——部署完成时触发的是事件而不是过滤器

1
2
// node_modules/hexo/dist/plugins/console/deploy.js
this.emit('deployAfter'); // ← 触发的是事件

所以应该用:

1
2
3
4
5
// ❌ 没用——Hexo 没有 'after_deploy' 过滤器
hexo.extend.filter.register('after_deploy', function() { ... });

// ✅ 正确——监听 deployAfter 事件
hexo.on('deployAfter', function() { ... });

最终脚本

完整脚本见 scripts/deploy-to-server.js,Hexo 会自动加载 scripts/ 目录下的 .js 文件,无需额外配置。部署时执行顺序:

  1. hexo-deployer-git 推送到 GitHub Pages
  2. Hexo 触发 deployAfter 事件
  3. 脚本执行 tar+ssh 同步到私有服务器

密钥格式教训

Windows 上用 ssh-keygen 默认生成 OpenSSH 格式,但很多 Node.js 的 SSH 库(ssh2、node-ssh 等)只支持 PEM 格式。如果需要转换:

1
ssh-keygen -p -m PEM -f ~/.ssh/id_rsa_xxx -P "" -N ""

注意转换后公钥不变,不需要重新添加到服务器。

最终效果

项目 说明
GitHub Pages https://mxhanks.github.io
自建服务器 http://47.115.49.75
部署命令 hexo g && hexo d 两边同步

LeanCloud 替代:自建阅读量计数 API

背景

博客一直用 LeanCloud 做文章阅读量统计,NexT 主题内置了 leancloud_visitors 支持,配合前端的 lean-analytics.swig 模板工作得很好。

但 LeanCloud 宣布将在 2027 年 1 月 停止服务,需要找一个替代方案。刚好手头有自建服务器,决定写一个兼容 LeanCloud API 的计数服务。

方案选型

NexT 主题其实内置了三个统计方案:

方案 维护成本 可控性 备注
LeanCloud 即将关停
不蒜子 (Busuanzi) 第三方服务,历史数据无法迁移
Firebase Firestore ⚠️ Google 服务,国内访问慢
自建 API(最终选择) 已有服务器,数据完全可控

自建服务架构

1
浏览器 (fetch) → Nginx (/counter-api/) → Node.js (Express) → SQLite (counter.db)

NexT 主题的前端 JS(lean-analytics.swig)会调用三个接口:

操作 HTTP 请求
查询某篇文章的计数 GET /1.1/classes/Counter?where={"url":"..."}
首次访问创建记录 POST /1.1/classes/Counter
递增计数 PUT /1.1/classes/Counter/:id {"time":{"__op":"Increment","amount":1}}

用 Express + better-sqlite3 写了个约 100 行的服务,完整模拟了这三个接口。前端代码不需要改一行——只要在主题配置中把 server_url 指向自建服务即可。

数据迁移

LeanCloud 导出的 JSON 中有 44 条计数记录,但因为改过文章链接(从日期路径 /2024/02/12/title/ 和拼音分类路径 /人工智能/classification/ 迁移到 /posts/:abbrlink/),大部分旧 URL 已经失效。

写了一个迁移脚本做三件事:

  1. 删除 time ≤ 1 的记录(只有自己点过一次的无效数据)
  2. 通过 URL 中的拼音关键词匹配现有文章(如 regularzation/posts/9044/
  3. 合并同篇文章的多个旧 URL 计数(同一篇文章可能有过日期路径和拼音路径两条记录)

匹配结果示例:

1
2
3
4
5
6
7
302  /posts/57901/  ← Windows mklink 使用
49 /posts/2931/ ← Luogu P5707 题解
28 /posts/48643/ ← CSP-J 游记
41 /posts/30549/ ← Python 文件夹映射
...

16 篇文章,共 609 次访问记录全部保留。

部署

服务部署在 CentOS Stream 10 上,用 systemd 管理进程:

1
2
3
4
5
6
7
8
9
10
# 安装 Node.js
dnf install -y nodejs

# 上传服务代码
scp -r server/ root@server:/opt/blog-counter
cd /opt/blog-counter && npm install --production

# systemd 服务:开机自启 + 崩溃重启
systemctl enable blog-counter
systemctl start blog-counter

Nginx 反代配置:

1
2
3
4
5
location /counter-api/ {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

主题配置只需改 server_urlthemes/next/_config.yml):

1
2
3
4
5
6
leancloud_visitors:
enable: true
server_url: "http://47.115.49.75/counter-api"
app_id: ""
app_key: ""
security: false

踩坑记录

迁移脚本的 Bug:第一次跑迁移时,匹配到的旧记录虽然插入了新 URL,但旧记录本身没有删除。结果数据库里同时存在新旧两条记录,计数翻倍。排查后发现是 toDelete 数组漏加了匹配记录的 rowid,补上后重新跑就正常了。

SSH 连接偶发退出码 255:连续执行多条 SSH 命令时,偶尔会出现 Exit code 255。原因可能是服务器资源紧张或 SSH 会话被复用。解决方法:加 sleep 间隔或将多个命令合并到一次 SSH 连接中执行。

NexT 主题的 hostname 校验导致计数不显示:部署完成后发现页面阅读数始终显示为 0,API 返回数据却是正常的。排查发现 NexT 的 lean-analytics.swig 模板中有这么一行:

1
if (CONFIG.hostname !== location.hostname) return;

网站 _config.yml 中配置的 url: http://mxhanks.github.io/ 决定了 CONFIG.hostname 的值。但自建服务器部署在 47.115.49.75 上,location.hostname 是 IP 地址,两者不相等,计数脚本直接 return 了。

修复方法很简单——当使用自定义 server_url(自建 API)时,跳过这个 hostname 校验:

1
if (!server_url && CONFIG.hostname !== location.hostname) return;

这样既保留了原版在 LeanCloud 下的保护行为(防止开发环境污染计数),又让自建 API 场景下能够正常工作。

HTTPS 问题

计数器 API 目前通过 HTTP 提供服务。博客在 GitHub Pages(HTTPS)上访问时,浏览器会因混合内容策略阻止 HTTP 请求,所以计数只在自建服务器版本上生效。

解决方案是申请一个域名配 Let’s Encrypt 证书,备案完成后将 stats.你的域名.com 解析到服务器,即可同时服务两个站点。

完整代码

服务端代码不到 100 行,放在博客项目的 server/ 目录下:

1
2
3
4
5
6
7
server/
├── package.json
├── index.js # 主服务(Express + SQLite)
├── import.js # LeanCloud 数据导入
├── migrate.js # 旧 URL 迁移匹配
├── counter.db # SQLite 数据库
└── blog-counter.service # systemd 单元文件

写在最后

从 2021 年初建到现在,博客的形态变了很多次。从一开始连 GitHub 都不会用,到后来自己写 Hexo 脚本、配服务器,每一步踩坑都成了经验。这些折腾的价值或许不只是技术本身——它让我习惯了记录、整理和分享,而这一点大概是写博客最大的收获。

如果你也在折腾 Hexo 博客,希望这篇文章能帮你少走一些弯路。