3641 字
18 分钟
十年老blog搬家 - 从Hexo到Cloudflare Workers

起因很没面子:想更新一篇文章,hexo 命令找不到了。

-g 装回 hexo-clinpm install,再 hexo gen --deploy,推上去,打开 https://zlotus.github.io/ ——Page not found

那一瞬间我以为十年的东西没了。(后来发现只是 GitHub Pages 在异步重建,刷新一下就好了,虚惊一场。)

但这一惊也够了:一个我十年没动过的工具链,靠着五个全局依赖和一次 force push 苟活着,哪天真塌了我连怎么塌的都不知道。趁着还没塌,换掉。

顺便,我早就用腻 Hexo 了。

0 定个调子#

目标三条,按优先级:

  1. 换掉 Hexo → Astro
  2. GitHub Pages → Cloudflare Workers
  3. 旧 URL 一个都不能死

第三条是硬约束。十年攒下来的外链和搜索引擎收录都指着 /年/月/日/标题/ 这个格式,改了就等于把自己的历史删了。后面会看到,整场搬家里最费劲的就是这一条

CAUTION

选 Workers 不选 Pages。CF 正在把 Pages 吸收进 Workers,新功能只进 Workers,2026 年开新项目官方建议就是 Workers + Static Assets。别再 wrangler pages deploy 了。

选 Astro 的理由也很实在:@astrojs/cloudflare 能让预渲染的静态页/api/* 动态接口共存在同一个 Worker、同一次部署里。以后想加评论、加阅读计数,不用拆项目。

主题挑了 Fuwari——骨架跟我原来的 icarus 一样(左边 profile、中间正文、右边目录),迁移的心理落差最小。

1 摸底:先别写代码#

动手前先把老站摸清楚。这一步一行代码都别写,纯调查,结论如下:

Hexo 版本5.4.0(不是 7.x)
文章数83 篇
permalink:year/:month/:day/:title/:title文件名不是标题
部署方式hexo-deployer-git force-push public/无 CI
图片19 张,8MB
数学公式MathJax 2.7.5,只有行内 $...$
RSS压根没有hexo-generator-feed 就没装过)

RSS 那条挺打脸的——我一直以为我有 RSS。翻遍 public/ 连个 atom.xml 都没有。没有历史包袱反而轻松,新建一个就行。

然后是这次搬家的头号坑

CAUTION

83 篇文章全部缺少 front-matter 的前导 ---

Hexo 允许你省略开头那行分隔符(只有结尾有 ---),但 gray-matter / Astro 要求两边都有。不补这一行,整个 front-matter 会被当成正文渲染出来。

迁移脚本干的第一件事就是补它。

还有一堆 Hexo 专有的标签插件要处理:

标签次数怎么办
{% raw %}83 对里面是 Bulma 提示框 HTML → 转成 Fuwari 的 :::note
<!-- more -->83截断标记
{% asset_img %}15转成普通 markdown 图片
{% link %}4转成普通链接
{% iframe %}1引用的文件早丢了,线上其实一直是坏的

最后两个畸形文件名,看着手痒也不能改

  • rbp3-upgrade-raspbian.md.md —— URL 真的就是 /2018/02/15/rbp3-upgrade-raspbian.md/
  • writing-os-in-rust-on-rpi_.md —— 尾巴上真的挂着一个下划线

改了就是断链。忍住。

2 迁移脚本:能重跑、能空跑、能吐日志#

83 篇不可能手改。写个脚本,三个要求:幂等(重跑结果一样)、dry-run(先看不写)、转换日志(改了什么一目了然)。

原始 markdown 是唯一真相,一个字节都不动,转换结果全写到新目录。

Terminal window
node scripts/migrate.mjs --dry-run # 空跑,只看日志
node scripts/migrate.mjs --only transformer # 单篇调试
node scripts/migrate.mjs --force # 真写(会先清空输出目录,所以要显式 --force)

核心是个「遮罩」的把戏。代码块里的东西一个字都不能碰——你总不希望我把你文章里演示 {% raw %} 用法的那段代码给”转换”了。所以先把代码块整段替换成占位符,处理完正文,再塞回去:

// 先把围栏代码块、行内代码换成占位符,正文处理完再塞回来。
// ⚠️ 两层遮罩必须用不同的哨兵,否则内层还原时会把外层的占位符一起吃掉。
// 哨兵用不可打印的控制字符,保证它绝不可能出现在正文里。
const MARK_FENCE = "\u0000";
const MARK_INLINE = "\u0001";

这个坑我是真踩了:一开始两层用同一个标记,内层一还原,外层占位符全被啃了。更蠢的是我最早用 " N " 这种人类可读的占位符——结果它把正文里的数字也吃了。

front-matter 的字段映射:

# Hexo # Fuwari
date: → published:
categories: → category: # 数组变单数,取第一个
title/tags/description # 同名,不动
# 外加一行 slug: 2024/01/06/文件名

那行 slug: 就是保住旧 URL 的关键,下一节说。

跑完的统计:

admonition=58 assetImg=15 img=1 link=4 iframe=1
mathFix=4 fenceFix=1 langFix=16

3 校验:写校验器比写脚本还重要#

脚本跑完了,怎么知道它没偷偷吃掉我的文章?

写了四道校验:

校验查什么
check-content.mjs转换前后,代码块逐字节比对 + 正文可见文字比对
check-math.mjs每个公式塞进 KaTeX 真渲染一遍
check-urls.mjs拿旧站全部 URL 发真实 HTTP 请求
check-render.mjs新旧两站渲染出来的 HTML 比代码块的文字量

然后就抓到了这次最阴的一个 bug。

六个反引号#

有一篇文章里,代码围栏用了六个反引号。Hexo 用的 marked,遇到任何 ``` 就闭合围栏;而 CommonMark 规定闭合围栏必须不短于开启围栏

六个反引号开头,三个反引号闭不上它。结果就是:从那一行开始,剩下的大半篇文章全被吞进了代码块。

CAUTION

恐怖的地方在于:前三道校验全都没发现它。

因为在源码层面,字符一个不少,代码块内容也一模一样。只有把两边真渲染成 HTML 再比,才看得出来新站的代码块里凭空多出来几千个字。

教训:只在源码层面校验,是看不见渲染器差异的。

我的校验器自己是错的#

check-content.mjs 第一版用 /<[^>]+>/ 剥 HTML 标签,然后报了三篇”内容丢失”。

我盯了半天——是 Rust 的泛型:

Pin<&mut Self>

<&mut Self> 被我的正则当成了一对 HTML 标签,把中间的文字整个吃掉了。误报。

NOTE

校验器必须比被校验的东西更简单、更可信。

一个需要你去 debug 的校验器,比没有校验器更糟——它会让你怀疑正确的东西。

顺带一提,迁移顺手修好了两个老站的 bug:一处 \left\{...\right\} 公式(marked 把 \{ 吃了,老站上一直是坏的),以及公式从客户端 MathJax 换成了构建期 KaTeX——首屏不再抖一下了

4 旧 URL:一个都不许死#

老站的 URL 长这样:

https://zlotus.github.io/2024/01/06/transformer-from-scratch-1/

Astro 默认会给你 /posts/xxx/。差一个字都是断链。

解法是让 front-matter 里的 slug 直接装下完整路径,再建一个四段的路由文件把它拆回来:

src/pages/[year]/[month]/[day]/[...slug].astro
const [year, month, day, ...rest] = entry.slug.split("/");
if (!year || !month || !day || rest.length === 0) {
// 宁可构建失败,也不要静默生成一个错的 URL
throw new Error(`${entry.id} 的 slug 不是 YYYY/MM/DD/name 格式`);
}

这里有个陷阱:URL 是改 URL 的地方越少越好。我把全站所有生成文章链接的地方(列表、上一篇/下一篇、RSS、TOC 高亮判断)全都掐到同一个函数上:

export function getPostUrlBySlug(slug: string): string {
return url(`/${slug}/`);
}

漏了两个:RSS 自己拼了 /posts/${slug}/TOCstartsWith("/posts/") 判断当前是不是文章页——后者会让目录永远不显示

NOTE

一个值(这里是 URL 格式)应该只有一个说了算的地方。散落在五个文件里各拼各的,就一定会漏。

至于列表页(/page/2//tags/xxx//categories/xxx/)这些,我不打算在 Astro 里复刻 Hexo 的分页结构,直接上 301:

/page/:num/ /:num/ 301
/tags/:tag/ /archive/?tag=:tag 301
/categories/:cat/ /archive/?category=:cat 301
/archives/ /archive/ 301

最后拿老站的 URL 全集跑一遍新站——注意必须发真实 HTTP 请求,重定向是没法靠”文件存不存在”验证的:

207 个 URL,207 个可达(其中 123 个走 301),0 个 404

5 部署:GitHub Actions → Workers#

老站是 hexo deploy 一把 force-push。新站换成 CI:push 到 main,GitHub 的机器上构建,wrangler deploy 推到 Cloudflare。

on:
push:
branches: [main]
paths-ignore:
# 改这些不影响构建产物,别浪费一次部署
# ⚠️ 不能写 '**.md'——src/content/posts/*.md 是文章,改了必须重新部署!
- "CLAUDE.md"
- "legacy/**"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 # 不写死版本,让它读 package.json
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: node scripts/check-math.mjs src/content/posts # 坏公式别上线
- run: pnpm build
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy

顺便,GitHub Pages 那个”Page not found”也自愈了:Pages 是 force-push 之后异步重建,那一两分钟里访问就是 404;Workers 是原子切换,新版本上线之前老版本一直在服务。

说个丢人的事#

配 secret 的时候我这么干的:

Terminal window
gh secret set CLOUDFLARE_API_TOKEN # 然后在提示符里粘贴 token

结果我把 token 贴到了名字那一栏。回头 gh secret list 一看:

CFUT_AJQNNMQCIBA9PFAFY01QYD5ZAW7SMUYDVJCJYN1I0365B2F8 ← 这是我的 token……
D6B51B94502C5FAA231B1544A3690BA8 ← 这是我的 account id……
CAUTION

GitHub secret 的「值」是加密的,「名字」不是。

名字会明晃晃出现在网页、API、日志里。等于我把 API token 公开发布了。

当场去 Cloudflare 吊销重发。想稳妥就别用交互式粘贴:

Terminal window
gh secret set CLOUDFLARE_API_TOKEN < token.txt && rm token.txt

6 白嫖能白嫖到什么程度#

这是整件事的重点——全部落在免费额度内

项目免费额度我用掉多少
静态资源请求不计入配额纯静态博客 ≈ 0
Worker 调用10 万次/天目前 0(没有动态接口)
Worker CPU每次 10ms没跑运行时逻辑
Workers KV每天只有 1000 次写没用
D15GB / 500 万行读每天还没用上

几条自己给自己划的红线:

  • 别拿 KV 做阅读计数器——一天 1000 次写,一天就打满。要计数用 D1。
  • 别做运行时 OG 图渲染——10ms CPU,satori 之类必超。OG 图构建期生成。
  • 别碰 Containers——没有免费额度,要 $5/月。

搜索用 Pagefind,这东西的思路很对我胃口:它不是搜索服务,是个后处理器astro build 生成完 HTML,它去爬构建产物、切词、建索引,产出一堆静态分片;浏览器搜索时按需下载命中的分片。

全程零后端。 Cloudflare 那边只是当图床发文件——而静态资源请求不计配额。等于白嫖到底。

7 以后怎么写文章#

这一节是写给未来的我看的。三个月后我肯定忘光。

新建#

Terminal window
cd ~/zlog
pnpm new-post my-new-post # 文件名用英文!

别手写 front-matter,一定要用这个脚本。因为:

CAUTION

slug: 那一行不能少,缺了直接构建失败。

旧 URL 是 /年/月/日/文件名/,路由里加了硬校验:slug 不是 YYYY/MM/DD/xxx 格式就当场 throw

这是故意的——宁可构建炸掉,也不要静默生成一个格式不对的 URL 悄悄上线。

Fuwari 自带的 new-post 脚本不知道这回事(它是给普通 Astro 站写的),生成的模板没有 slug:,直接用会一头撞上这个报错。所以我把脚本改了,现在它会自动填好 slug: 2026/07/14/my-new-post

顺便,文件名用英文。中文文件名会变成中文 slug,URL 里会被转义成一长串 %E8%AF%95...

封面图#

两条路,看图有多大:

放哪front-matter 写结果
public/img/x.webpimage: /img/x.webp原样发布,Astro 不碰它
src/content/posts/文章名/cover.pngimage: ./文章名/cover.pngAstro 构建期压成 webp

照片、截图一律走第二条,让构建期去压。走第一条的话,你放多大就发多大——我一开始把一张 2880×1800、334KB 的 PNG 扔进 public/,它就真的原封不动发出去了。

public/src/ 的区别就在这儿:public/ 是”原样复制”,src/ 才进构建流水线。

(图片优化在 Cloudflare 上是付费功能,但构建期压缩不花一分钱——反正 GitHub Actions 的机器是白送的。)

预览#

Terminal window
pnpm dev # localhost:4321,热更新,日常写字用这个

改一个字立刻看到。但有三样东西 pnpm dev 测不了,得走完整构建:

Terminal window
pnpm build # 必须先 build——Pagefind 索引是构建期生成的
npx wrangler dev # localhost:8787
  • 搜索:dev 下是假的,会直接告诉你 “This Is a Fake Search Result”
  • 301 重定向_redirects 是 Cloudflare 读的,Astro 不管
  • 404 页
NOTE

pnpm preview 是坏的,别用。

装了 @astrojs/cloudflare adapter 之后,产物是个 Worker,astro preview 起不来——它不报错,就是静静地什么都不做,端口也不监听。

要预览就 wrangler dev

发布#

Terminal window
git add -A
git commit -m "新文章:十年老blog搬家"
git push # ← 就这一下,没了

git push 就是发布。 推上去之后:

  1. GitHub 看到 main 分支有新提交,翻出 .github/workflows/deploy.yml
  2. 起一台临时 Ubuntu,装依赖、跑公式校验、pnpm build
  3. wrangler deploy 把产物推到 Cloudflare
  4. 几分钟后线上就是新的了

想看它跑到哪了:

Terminal window
gh run watch # 盯着这次构建
gh run list --limit 3 # 最近三次的结果

失败了也不用慌——Workers 是原子切换,新版本没成功之前,线上一直是老版本。构建挂了顶多是”没更新”,不会变成”站崩了”。

NOTE

这是和 Hexo 时代最大的区别。

以前是 hexo deploy 一把 force-pushzlotus.github.iomaster,GitHub Pages 再异步重建——那一两分钟里访问就是 Page not found。这也是这次搬家的起因:我以为我把十年的博客推没了。

现在推的是源码,构建在 CI 里做,产物原子切换。推坏了也只是没上线,不会把线上弄没。

不用再 hexo deploy 了。也不用再祈祷那两分钟的重建窗口。

后记#

搬完了。83 篇全在,旧 URL 一个没死,公式代码图片都好,还顺手修了老站两个 bug。

回头看,真正花时间的不是”用 Astro 重建一个博客”——那部分快得离谱。花时间的是:

  • 搞清楚老站到底是什么样(Phase 0 那张表)
  • 证明新站和老站一模一样(四道校验)
  • 保证十年的链接不断(207 个 URL 逐个发请求)

以及一个反复出现的教训:

NOTE

代码写完不算完,验证过才算完。

这次所有的坑——六个反引号、TOC 不显示、RSS 拼错路径、</div></p>、单数字日期——没有一个是我”读代码”读出来的,全是校验器跑出来的。

而唯一一个校验器自己出的错(Rust 泛型误报),恰恰是因为校验器写复杂了

现在挂在 Cloudflare 上,橙云一开,国内访问也没问题。

下一步大概是评论和阅读计数——不过那得等我先用几天,看看到底需不需要。YAGNI。

十年老blog搬家 - 从Hexo到Cloudflare Workers
https://zlog.zxdata.uk/2026/07/14/zlog-to-cloudflare/
作者
Z
发布于
2026-07-14
许可协议
CC BY-NC-SA 4.0