本文整理自 Michael’s Blog · Hexo 博客工程化实战,并结合本站(Butterfly 主题 + hexo-deployer-git)的实际情况做了适配与补充。

为什么要把博客「工程化」?

写博客这件事,技术人通常的路径是:

  1. 本地 hexo new 创建文章
  2. Markdown 写内容
  3. hexo g && hexo d 手动部署

这条路径在只有几十篇文章时完全够用。但当文章数量突破 400+、你希望保持周更甚至日更节奏时,手动流程就会暴露出几个致命问题:

  • 部署依赖本地环境:换台电脑就写不了
  • 选题靠灵感驱动:没灵感就断更
  • SEO 全靠感觉:标题写得好不好、结构化数据有没有、sitemap 对不对,全凭手感
  • 写完就忘:哪篇文章阅读量高、哪些关键词带来流量,完全没有数据反馈

这篇文章记录的是把 Hexo 博客从「手动写作」改造成「自动化内容工厂」的完整过程,涉及四个核心模块:

  1. 自动部署:push 即发布
  2. AI 辅助选题与写作:选题池 + 多 Worker 并行生产
  3. SEO 工程化:结构化数据、自动 sitemap、内链策略
  4. 阅读量分析:数据采集 → 看板 → 反哺选题

每个模块都有可直接复用的配置和代码。


一、从 hexo deploy 到 push 即发布

1.1 两条部署路线

Hexo 的部署本质上就是把 public/ 这个目录「搬」到托管平台。常见两条路线:

路线 原理 适合
本地 push(本站当前方案) 本地构建后,hexo-deployer-git 把 public/ 强推到 Pages 仓库 文章量不大、想完全掌控本地环境
CI 构建(GitHub Actions) 仓库只存 Markdown,CI 里安装依赖、构建、发布 文章多、多人协作、想换台电脑就能写

本站用的是第一条:_config.yml 中配置 hexo-deployer-git,一条命令完成生成和部署。

1
2
3
4
5
# _config.yml
deploy:
type: git
repo: https://github.com/<user>/<user>.github.io.git
branch: main
1
2
# 构建并部署(--generate 表示先 hexo g 再推送)
hexo deploy --generate

注意:hexo-deployer-git 使用 .deploy_git/ 作为中转仓库,它会把 public/ 里的内容强制推送过去,所以 Pages 仓库里不需要(也不应该)保留源文件。

1.2 GitHub Actions 自动部署

当你希望「写完 Markdown,git push 就完事」时,把构建搬到 CI。在仓库根目录创建 .github/workflows/deploy.yml:

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
name: Deploy Hexo Blog

on:
push:
branches:
- main

# 防止同时触发多次部署,排队执行
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read
pages: write
id-token: write

jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史用于 git log 生成文章列表

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Build
run: npx hexo generate
env:
NODE_ENV: production

- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

### 1.3 进阶:多环境部署

如果你有多个目标(比如 GitHub Pages + 自建服务器),可以用 `needs` 串起来:

```yaml
deploy-to-server:
needs: build-and-deploy
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout built files
uses: actions/checkout@v4
with:
ref: gh-pages
path: public

- name: Deploy via rsync
uses: burnett01/rsync-deployments@7.0.1
with:
switches: -avzr --delete
path: public/
remote_path: /var/www/blog/
remote_host: ${{ secrets.SERVER_HOST }}
remote_user: ${{ secrets.SERVER_USER }}
remote_key: ${{ secrets.SSH_PRIVATE_KEY }}

1.4 构建缓存优化

文章一多,构建时间就成了问题。加入缓存后构建时间可以从 90s 降到 25s:

1
2
3
4
5
6
7
8
9
- name: Cache Hexo
uses: actions/cache@v4
with:
path: |
node_modules
.deploy_git
key: hexo-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
hexo-${{ runner.os }}-

1.5 自动化预检

部署前加一道 lint 和死链检查,防止发布有问题的内容:

1
2
3
4
5
6
7
8
- name: Check broken links
run: |
npx hexo generate
npx broken-link-checker http://localhost:4000 --recursive --ordered --filter-level 3 || true

- name: Validate HTML
run: |
npx html-validate public/**/*.html || true

二、AI 辅助选题与写作

这是整个工程化改造中投入产出比最高的部分。

2.1 选题池设计

在仓库根目录维护一个 .writing-backlog.md,格式简单到极致:

1
2
3
4
5
6
# Writing Backlog

- [ ] Laravel 事件溯源实战:Event Sourcing + EventStore 落地踩坑
- [ ] Go 并发模式:Channel、Context、errgroup 组合实战
- [ ] Redis 7.0 Function 替代 Lua 脚本:迁移指南与性能对比
- [ ] Kubernetes HPA 自定义指标:基于 Laravel 队列深度的自动扩缩容

- [ ] 表示待写,- [x] 表示已完成。Worker 领取任务时立刻用 sed 把 [ ] 改成 [x] 锁定选题,防止多个 Worker 重复写作。

2.2 AI Worker 流水线

用 cron 配置多个定时 Worker,每个 Worker 独立运行:

1
2
3
4
5
6
7
8
9
10
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│ Worker 1 │ │ Worker 2 │ │ Worker 3 │
│ 每2小时执行 │ │ 每3小时执行 │ │ 每4小时执行 │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────┐
│ .writing-backlog.md │
│ grep → 锁定 → 写文章 → 保存 → 更新选题池 │
└──────────────────────────────────────────────────┘

每个 Worker 的核心逻辑:

1
2
3
4
5
6
7
8
9
10
# 1. 领取选题
TOPIC=$(grep -m1 '^- \[ \]' .writing-backlog.md)

# 2. 立刻锁定(防止其他 Worker 重复领取)
FIRST30=$(echo "$TOPIC" | cut -c1-35)
sed -i '' "s/^- \[ \] ${FIRST30}/- [x] ${FIRST30}/" .writing-backlog.md

# 3. AI 生成文章(交给 AI agent 处理)
# 4. 保存到 source/_posts/<分类>/<文件名>.md
# 5. 更新选题池,追加文件路径

2.3 选题质量控制

不是所有选题都值得写。在选题池里加上质量评估维度:

1
2
3
4
5
# Writing Backlog - 格式说明
# - [ ] 选题标题 | 预估搜索量(高/中/低) | 竞争度(高/中/低) | 优先级(P0/P1/P2)

- [ ] Laravel 事件溯源实战 | 搜索量:中 | 竞争度:低 | 优先级:P0
- [ ] Go 并发模式 | 搜索量:高 | 竞争度:高 | 优先级:P1

选题原则:

  • P0:搜索量中/低 + 竞争度低 = 蓝海词,优先写
  • P1:搜索量高 + 竞争度高 = 需要有差异化角度
  • P2:搜索量低 + 竞争度高 = 不值得写,除非是个人笔记

2.4 自动 Git 提交

Worker 写完文章后自动 commit 并 push:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
git add source/_posts/
git commit -m "post: $(date +%Y-%m-%d) 新文章标题"

---

## 三、SEO 工程化

SEO 不是「写完文章再想」的事,而应该内建到写作流程里。

### 3.1 Frontmatter 规范

每篇文章的 frontmatter 都是 SEO 的一部分:

```yaml
---
title: 主关键词在前,长尾描述在后
description: 用于 meta description,直接影响搜索结果摘要
tags: [关键词1, 关键词2, 关键词3]
categories: [唯一分类]
date: 2026-01-01 10:00:00
updated: 2026-01-02
---

要点:

  • title:主关键词在前,长尾描述在后
  • description:用于 <meta name="description">,直接影响搜索结果摘要
  • tags:3-5 个,覆盖文章核心概念
  • categories:唯一,用于面包屑导航和站点结构

3.2 结构化数据(JSON-LD)

在生成阶段往 <head> 注入 Article 类型的结构化数据:

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
// scripts/structured-data.js
hexo.extend.filter.register('after_render', function(html, data) {
if (data.layout !== 'post') return html;

const jsonLD = {
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": data.title,
"description": data.description || data.title,
"datePublished": data.date.toISOString(),
"dateModified": (data.updated || data.date).toISOString(),
"author": {
"@type": "Person",
"name": "Biko",
"url": "https://biko-duckyo.github.io"
},
"publisher": {
"@type": "Organization",
"name": "Biko的个人小站"
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://biko-duckyo.github.io" + data.path
}
};

const script = '<script type="application/ld+json">' + JSON.stringify(jsonLD, null, 2) + '</script>';
return html.replace('</head>', script + '\n</head>');
});

Hexo 会自动 require scripts/ 目录下的所有文件,所以把这段放进 scripts/ 即可生效,不需要改主题源码。

3.3 Sitemap 自动生成

hexo-generator-sitemap 已经够用,但需要正确配置:

1
2
3
4
5
6
# _config.yml
sitemap:
path: sitemap.xml
rel: true
tags: true
categories: true

提交到 Google Search Console 后,确认 robots.txt 包含:

1
Sitemap: https://<your-site>/sitemap.xml

3.4 内链策略

文章之间的内链是 SEO 的重要信号。可以在写作流水线里加一步「相关阅读」注入:

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
# scripts/inject_internal_links.py
import re
import os

def find_related_posts(current_tags, posts_dir):
"""根据 tags 相似度找到相关文章"""
related = []
for filename in os.listdir(posts_dir):
if not filename.endswith('.md'):
continue
filepath = os.path.join(posts_dir, filename)
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
tags_match = re.search(r'tags:\s*\n((?:\s+-?\s*.+\n)*)', content)
if tags_match:
post_tags = set(re.findall(r'-\s+(.+)', tags_match.group(1)))
overlap = len(current_tags & post_tags)
if overlap >= 2:
title_match = re.search(r'title:\s*(.+)', content)
if title_match:
related.append((overlap, title_match.group(1).strip(), filename))
related.sort(reverse=True)
return related[:5]

def inject_links(content, related_posts):
"""在文章末尾注入相关阅读"""
if not related_posts:
return content
links = "\n".join([
"- [" + title + "](/" + filename.replace('.md', '') + "/)"
for _, title, filename in related_posts
])
return content + "\n\n## 相关阅读\n\n" + links + "\n"

3.5 自动化 SEO 审计

在 CI 里加一段 frontmatter 完整性检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
- name: SEO Audit
run: |
for file in source/_posts/**/*.md; do
if ! grep -q "description:" "$file"; then
echo "⚠️ Missing description: $file"
fi
if ! grep -q "categories:" "$file"; then
echo "⚠️ Missing categories: $file"
fi
done

# 检查是否有重复的 title
grep -r "^title:" source/_posts/ | sort | uniq -d

四、阅读量分析

写完就忘是内容创作最大的浪费。把数据采集回来,才能形成「写作 → 反馈 → 选题」的正循环。

4.1 数据采集

方案有三档,按投入从低到高:

方案 成本 数据粒度 隐私
不蒜子(busuanzi) 免费、一行代码 站点 PV/UV、单篇阅读量 第三方统计
Umami / Plausible(自托管) 一台小服务器 页面级 PV、来源、停留 数据自有
GA4 + 自定义事件 免费、配置复杂 全量行为、可做漏斗 Google

本站当前用的是不蒜子,配置在 Butterfly 的 busuanzi 开关里,零成本但粒度粗。想要按文章、按来源分析,就得换 Umami 这类自托管方案。

4.2 看板

数据落到 SQLite / Postgres 后,用 Metabase 或 Grafana 做一个最简单的看板,关注四个指标:

  • 单篇 PV/UV 排行:哪些文章真的有人看
  • 流量来源:搜索 / 直链 / 社交,各占多少
  • 搜索关键词:哪些词带来了流量(Search Console)
  • 跳出率与停留:内容是否真的被读完

4.3 反哺选题

看板的最终目的是回写进选题池:

1
2
3
4
# Writing Backlog

- [x] Hexo 博客工程化实战 | 已完成,PV 排名 #3
- [ ] Hexo 博客工程化实战(续):阅读量看板搭建 | 承接高 PV 文章 | 优先级:P0

经验: 一篇文章 PV 高,就写它的「续集 / 进阶 / 姊妹篇」——这是最省力的选题来源,因为需求已经被验证过了。


五、本站的落地清单

结合本站(Butterfly + GitHub Pages)现状,按投入产出比排序的改造顺序:

  1. 短期:在 scripts/ 里加 JSON-LD 结构化数据;补全每篇文章的 description
  2. 中期:把部署迁移到 GitHub Actions(换台电脑也能写);接入自托管 Umami
  3. 长期:建选题池 + AI Worker 流水线;每周复盘一次看板,把高 PV 主题做成系列

一句话总结:手动部署解决「能不能发」,工程化解决「能不能持续发」。 文章数过了 100 篇,后者才是真正的瓶颈。

git push origin main

1
2
3

push 触发 CI 自动部署,形成闭环:

选题 → AI 写作 → git push → CI 构建 → 部署上线

1
2
3
4

publish_dir: ./public
publish_branch: gh-pages
commit_message: "deploy: ${{ github.event.head_commit.message }}"

这里发布到 gh-pages 分支。如果站点仓库本身就是 <user>.github.io(例如本站),对应分支要改成 main。