165 篇文章搬家记:从 WordPress 到 Hugo 静态站

这个博客从 2011 年跑到现在,一直是 WordPress 4.1.41 配 PHP 5.6.40。十多年没动过版本,原因很简单:能跑,改一次要冒一次风险。但今年翻访问日志,发现两件事已经不能装作看不见了——扫描器每天都在敲 /wp-login.php,而评论区除了几百条老留言,剩下的全是机器发的垃圾。

于是下决心做一次彻底搬家:内容全部转成 Markdown,站点改成 Hugo 生成的纯静态文件,评论单独用 Isso 自托管。这篇文章把过程里值得记的部分写下来,包括几个只有真跑一遍才会踩到的坑。

先摸清家底,别急着动手

动手前先只读盘了一遍数据库。结论比预想的好:

项 结果
数据库 12 张表,全部标准表,没有任何自建表
内容 165 篇已发布文章 + 8 个页面 + 12 个菜单项 + 1 篇草稿
时间跨度 2011-06-22 到 2014-07-21
评论 356 条已批准(含 36 条 pingback)+ 26 条 spam
附件 uploads 目录 1323 个文件、约 36 MB

当时最担心的是主题里那三个模块:「留言板」「读者墙」「最新留言」,看着像自建表。翻了主题源码才发现,Message.php 就是一句 SELECT ... FROM wp_comments LIMIT 30,readers.php 是按邮箱 GROUP BY 的查询视图。**全是 wp_comments 的查询,不存在独立数据。**这一条直接决定了方案形态:除了评论,站点里没有别的状态,不需要额外维护一套数据库。

数据出口:先导出,再脱敏

WordPress 自带导出只能给 WXR(WordPress eXtended RSS),这反而是好事——标准 XML,2.1 MB 里有 186 个 item、356 条评论、511 条分类关系,全是结构化的。

导出之后第一件事不是转换,是脱敏:邮箱、IP、User-Agent、user_id、作者网址全部不落盘。这一步不能偷懒,因为 WXR 一旦进了 Git 仓库或者被误放进静态输出目录,就是一次实打实的隐私泄露。

脱敏完再交给转换器,拆成 Hugo 认识的目录结构:

content/posts/<年>/<post_id>/index.md

用 post_id 而不是日期做目录名,是为了后面评论线程能对上(见下文)。每个 <pre> 块转成围栏代码块,图片路径保持原样,固定链接逐字保留——这一条最重要,十几年的外链和搜索引擎收录全靠它。

五个真跑才暴露的坑

一、nginx 会先解码 URI,Hugo 却生成字面百分号目录

老站的固定链接是中文,比如:

/2012/07/%e6%8e%a2%e8%ae%a8/

Hugo 会老老实实按这个字符串生成目录,磁盘上就是名字叫 %e6%8e%a2%e8%ae%a8 的文件夹。但 nginx 收到请求时会先把 URI 解码成 UTF-8 再去匹配文件,于是 try_files $uri 一个都找不到,全站 404。

解法是在构建之后加一步后处理:把输出目录里所有 %XX 名字的目录重命名成解码后的 UTF-8 名称,而 HTML 里的链接保持编码形式不变。另外老正文里 %E5 和 %e5 大小写混用,两种都要兼容。最终 173 个历史 URL 全部命中。

二、正文里的占位符被当成 HTML 标签吃掉了

有几篇讲配置的文章,正文写着 <application>、<project-id> 这种占位符。渲染器(Goldmark)会把它们当原始 HTML,而站点出于安全把 unsafe 关掉了,于是这几个词直接从页面上消失——不报错,就是没了。

转换器里对纯文本节点做了实体转义才解决。这个坑很阴,因为构建过程一声不响。

三、脱敏断言不能按字段名扫字符串

脱敏检查脚本写完跑了一下,报「泄露」。查半天发现:有一篇《WordPress 中实用的 SQL 语句》,正文里本来就明明白白写着 comment_author_email 这个字段名。按字符串扫,必然误判。

改成对 XML 做 XPath 结构化检查——只看 wp:comment_author_email 这类真实节点有没有值,正文文本一概不管。另外 comment_user_id=0 这种占位值也在脱敏阶段整字段删掉,免得被当成「有 user_id」。

四、日期不带时区,文章会被判成未来

新写的文章 date: 2026-11-12,构建完首页找不到它。原因是日期没带时区,而 Hugo 默认 buildFuture = false:一旦构建时的时区解释和写作时区对不上,它就被当成未来文章直接跳过,连警告都没有。

统一写成 2026-11-12T22:20:00+08:00 之后问题消失。这一条已经固化进建文章的脚本里,不给手写出错的机会。

五、构建断言写死数字,发第一篇文章就失败

最初为了确保迁移没丢东西,构建脚本里写死断言:必须 21 页分页、首页统计必须是 165 篇。上线时这是对的,但发第一篇文章就红。

改成按实际内容算:分页数用 ceil(文章数 / 8),首页统计动态求和。断言要保护的是「没丢东西」,不是「数字永远不变」。

评论怎么办:静态存档 + 自托管新评论

356 条历史评论不能丢,但也不想为了评论继续扛一个 PHP。

做法分两层:

  1. 历史评论静态化。构建时把老评论渲染成静态 HTML 嵌在文章页下方,只读、不可回复、不请求任何外部资源(连 Gravatar 都换成了本地色块头像)。零 SQLite、零第三方请求。
  2. 新评论用 Isso。一个 Python 写的轻量评论服务,单文件 SQLite,同源反代挂在 /isso/ 下。

这里有个必须提前想清楚的设计:评论线程用什么当键。

Isso 默认拿文章 URL 当线程键,逐字符精确匹配。而本站同一篇文章至少有三种写法:

/2012/07/%e6%8e%a2%e8%ae%a8/
/2012/07/%E6%8E%A2%E8%AE%A8/
/2012/07/%e6%8e%a2%e8%ae%a8

大小写不同、尾斜杠有无,在 Isso 眼里就是三个不同的线程——评论会「看不见」。解法是在模板里给评论区加一个 data-isso-id,值是 WordPress 原始的 post_id(纯数字)。embed.js 会优先用它当线程键,URL 怎么变都不影响。

上线后的样子

项 结果
构建 Hugo 418 页,0 warning
历史链接 173 / 173 命中,另加 349 条 301 规则
站点体积 615 文件 / 2.4 MB(不含附件)
依赖 只有 nginx;评论另需 Python3 + Isso
外部请求 0(无 CDN、无外部字体、无 Gravatar)

服务器上原来的 WordPress 目录一个字节没删,nginx 的 root 指到新目录就算切换,出问题指回去即可。

还没做完的

  • /?p=123 这种短链还是软 404,需要单独补规则;
  • 1323 个历史附件(约 36 MB)不在归档包里,换机时要单独把 uploads 目录拷一次;好在正文路径一个字都不用改;
  • 老主题 218 个文件的视觉细节还没全还原,目前只做了主要模板;
  • 主站仍然跑在 WordPress 上,这套东西先在 dev 子域跑着,切不切、什么时候切,另说。

搬家这件事最大的收获不是「换了个更时髦的技术栈」,而是终于清楚自己手里有哪些数据、它们在哪、怎么重建。以前这台机器对我而言是个黑盒,现在它是一个 tar.gz。


这个系列共三篇:迁移实录 · 写作与发布流程 · 换服务器手册

加入讨论

新评论 — 条

评论保存在本站自托管服务里,无需注册登录,填个昵称就能留言;不依赖任何第三方 Cookie 或账号。请尽量说点有用的 :)