把 Wordpress 旧站连同图片一起迁移到 Hugo
从本地建站、GitHub Pages 自动部署,到两段历史博客的批量迁移
这次迁移,本质上不是“换一个博客程序”这么简单,而是一次把十多年内容重新整理、重新落盘的过程。
我的旧博客实际上分成两段:
- 2013 年开始的第一段旧站:这部分因为历史原因,最后只剩下静态化后的 HTML 文件,当前放在
/old-blog/下。 - 2017 年开始的 Wordpress:这部分有完整的 Wordpress 结构,但我最终也没有直接走数据库导出,而是先把它静态化,再从静态页面中提取正文和资源。

之所以会出现“2013 年那部分只剩静态文件”的情况,是因为当年为了安全和访问速度做了全站静态化,静态镜像存到了七牛云;后来阿里云 RDS 没有续费,数据库数据丢失,于是只剩静态镜像还能访问,后台也就彻底不可用了。
这篇文章记录一下,我是如何把这两部分内容一并迁移到新的 Hugo + LoveIt 博客里的。
最终目标
这次迁移,我希望同时满足几件事:
- 新站改用 Hugo,主题使用 LoveIt。
- 仓库托管在 GitHub,使用 GitHub Pages + GitHub Actions 自动部署。
- 域名继续使用
www.liujason.com。 - 在 Cloudflare 上把
www做成指向 GitHub Pages 的 CNAME,并开启代理加速。 - 2017 年后的 Wordpress 文章能够批量迁移。
- 文章正文里引用的图片、附件也能一起迁移。
- 尽量保留原来的访问路径,例如:
/article/123.html/old-blog/456.html
“尽量保留原 URL” 这件事非常重要。只要旧链接还有效,搜索引擎索引、外部引用、历史书签和自己过去写过的文章互链就都还能继续工作。
先在本地新建 Hugo 项目,并接入 LoveIt
我先在本地初始化一个 Hugo 项目,再把 LoveIt 主题接进去。
hugo new site liujason.com
cd liujason.com
git init
git submodule add https://github.com/dillonzq/LoveIt.git themes/LoveIt接着新建一个最小可用的 hugo.yaml:
baseURL: https://www.liujason.com/
languageCode: zh-cn
title: LiuJason's Blog
theme: LoveIt
defaultContentLanguage: zh-cn
hasCJKLanguage: true
enableRobotsTXT: true
markup:
goldmark:
renderer:
unsafe: true
params:
defaultTheme: auto
dateFormat: 2006-01-02这里有一个配置非常关键:
markup:
goldmark:
renderer:
unsafe: true因为我后面导出的内容,很多不是“纯 Markdown”,而是从旧页面里直接抽出来的 HTML 正文片段。如果不打开这个选项,Hugo 默认会把原始 HTML 当成不安全内容忽略掉,最终文章正文会残缺。
本地预览:
hugo server -D使用 GitHub Pages + GitHub Actions 自动部署
我不想再手工上传 public/ 目录,所以部署方式直接选 GitHub Pages 的 Actions 工作流。
仓库推到 GitHub 之后,先到:
Settings -> Pages -> Build and deployment -> Source -> GitHub Actions
然后在仓库里新建:
.github/workflows/hugo.yaml内容可以写成这样:
name: Build and deploy Hugo site
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
env:
HUGO_VERSION: 0.158.0
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
fetch-depth: 0
- name: Setup Pages
id: pages
uses: actions/configure-pages@v5
- name: Install Hugo
run: |
curl -sLJO "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz"
mkdir -p "${HOME}/.local/hugo"
tar -C "${HOME}/.local/hugo" -xf "hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz"
echo "${HOME}/.local/hugo" >> "${GITHUB_PATH}"
- name: Build
run: |
hugo build \
--gc \
--minify \
--baseURL "${{ steps.pages.outputs.base_url }}/"
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./public
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4之后每次 git push,GitHub Actions 就会自动构建并发布站点。
绑定域名 www.liujason.com
站点先跑起来,下一步就是把域名接过来。
我这里使用的是:
- GitHub Pages 负责托管
- Cloudflare 负责 DNS 和代理加速
做法很简单:
- 在 GitHub 仓库的 Pages 设置里,把自定义域名填成
www.liujason.com。 - 在 Cloudflare 的 DNS 里新增一条:
Type: CNAME
Name: www
Target: <你的 GitHub Pages 默认域名>
Proxy: ON也就是把 www.liujason.com 指到 GitHub Pages 默认域名(例如 <username>.github.io),然后打开 Cloudflare 代理。
我这里选择 www 子域名,而不是直接把根域名顶上去,主要是因为这条链路更直接,排查问题也更方便。
如果你也在使用 GitHub Actions 工作流部署,需要注意一点:自定义工作流场景下,GitHub Pages 不依赖仓库里的 CNAME 文件,真正生效的是 Pages 设置里的自定义域名配置。
迁移思路:不直接碰 Wordpress 数据库,而是先静态化
从 2017 年开始的博客虽然是 Wordpress,但我最后没有直接从数据库里倒文章,而是走了这样一条更稳的路径:
- 用 Simply Static 把整个 Wordpress 导出成静态 HTML。
- 用 R 脚本 从这些 HTML 里提取文章标题、日期、分类、标签、正文、评论,以及正文中引用的图片和附件。
- 再把提取出的结果组织成 Hugo 可以直接读取的
index.md和assets/目录。
我选择这条路线,主要是因为:
- 静态 HTML 是最终渲染结果,比 Wordpress 数据库里的半成品更接近最终页面。
- 主题里加过哪些自定义 HTML、历史内容里混入过什么编辑器残留,HTML 里都能直观看到。
- 图片、附件、超链接这些资源关系,也更容易一并扫描并重写。
换句话说,这次迁移的核心不是“导表”,而是:
把历史页面当成已经冻结的发布结果,再从发布结果里反向恢复 Hugo 内容。
迁移 2017 年至今的 Wordpress
第一步:用 Simply Static 导出整站 HTML
Simply Static 会把 Wordpress 当前能访问到的内容导成静态站。我导出后,得到的是一批 HTML 文件以及页面中引用到的静态资源。
这一步的目的不是拿来直接上线,而是作为后续脚本提取的输入数据。
第二步:用 R 脚本提取正文和引用资源
我写了一个 R 脚本,做几件事:
- 自动遍历导出的
.html/.htm文件。 - 根据页面结构提取:
- 标题
- 日期
- 分类
- 标签
- 正文 HTML
- 评论文本
- 找出正文中的:
img srcimg data-originala href
- 把其中指向本地静态资源的图片、附件复制出来。
- 重写正文中的资源链接,使其指向 Hugo 页面包内的资源路径。
我这里最核心的一点,是把每篇文章都生成为一个 leaf bundle:
content/article/<id>/index.md
content/article/<id>/assets/...这比把所有图片都堆到全站统一目录里更适合迁移老站,因为:
- 每篇文章的资源都能跟正文放在一起。
- 后续维护、定位问题、单篇删除或移动都更方便。
- 不同文章里重名图片不会互相冲突。
脚本最后生成的 front matter 大概是这样的:
---
title: 示例文章
date: 2020-01-01T00:00:00+08:00
url: /article/123.html
categories:
- Coding
- Web项目
tags:
- Hugo
- Wordpress
source_file: 123.html
comments_extracted:
- 第一条评论
- 第二条评论
draft: false
---这里最关键的是这一行:
url: /article/123.html它的意义是:即使文章真实文件已经变成了 content/article/123/index.md,最终渲染出的访问地址依然可以保持为旧站使用过的 /article/123.html。
这样旧链接就不用大规模改写,也不用做一堆 301 重定向。
第三步:资源重写
仅仅把正文抽出来还不够,真正麻烦的是图片和附件。
很多老文章里,资源引用可能长这样:
<img src="wp-content/uploads/2019/01/example.jpg">
<a href="wp-content/uploads/2019/01/demo.zip">下载附件</a>如果直接把正文塞进 Hugo,路径一定会失效。因此脚本做了两件事:
- 定位原始文件:根据 HTML 文件路径、站点导出目录和当前工作目录,尽量把资源文件找出来。
- 复制并改写链接:把资源复制到当前文章的
assets/目录,并把正文中的链接重写为新的路径。
重写后的路径类似这样:
/article/123/assets/file_xxx.jpg
/article/123/assets/file_xxx.zip这样迁移后的文章不只是“文字还在”,而是正文里引用过的图片、附件也能继续打开。
第四步:把输出结果放进 Hugo 内容目录
我的脚本输出的是每篇文章一个目录,目录内包含:
123/
├── index.md
└── assets/
├── file_2026....jpg
└── file_2026....zip把这批目录整体放进:
content/article/之后 Hugo 就可以直接识别。
到这里,2017 年之后的 Wordpress 部分就算迁移完成了。
迁移 2013-2017 的 old-blog
这部分更麻烦,因为它已经不是一个还能登录后台的博客系统,而是一批历史静态 HTML。
所以我没有直接一步转 Hugo,而是分成了两段:
- 先从 old-blog 的静态 HTML 中提取结构化信息,写成 JSON。
- 再从 JSON 批量生成 Hugo 的
index.md。
这样做的好处是,数据恢复和 Hugo 生成两个阶段是分离的:
- 第一阶段只管“尽可能把旧内容救出来”;
- 第二阶段只管“把救出来的数据组织成 Hugo 能吃的格式”。
第一步:从 old-blog HTML 提取到 JSON
这个脚本主要做的是:
- 提取标题;
- 提取分类标签;
- 提取月、日;
- 提取正文 HTML;
- 提取评论;
- 把正文中引用到的本地图片/附件复制到
export/assets/; - 记录资源映射和提取摘要。
最后每篇文章生成一个 JSON,例如:
{
"source_file": "1048.html",
"title": "示例标题",
"category_tags": ["R", "Web"],
"month": "11",
"day": "06",
"body": "<p>正文 HTML ...</p>",
"comments": ["评论 1", "评论 2"]
}这样做的意义在于:即使将来我要换另一套静态站生成器,或者重新设计迁移逻辑,也可以直接从这批 JSON 出发,而不用每次都回到最原始的 HTML 再解析一遍。
第二步:从 JSON 转成 Hugo 内容
第二个脚本则负责把 JSON 变成 Hugo 页面包:
content/old-blog/<id>/index.md
content/old-blog/<id>/assets/...生成的 front matter 重点如下:
---
title: 示例标题
date: 2013-11-06T00:00:00+08:00
url: /old-blog/1048.html
source_json: 1048.json
original_id: 1048
draft: false
---同样,关键还是 url:
url: /old-blog/1048.html也就是说,虽然我已经换成 Hugo 了,但 2013-2017 这批历史文章仍然继续挂在原来的 /old-blog/ 路径下。
对老内容来说,稳定的 URL 比“目录长得漂不漂亮”更重要。
为什么 old-blog 要先转 JSON,再转 Markdown
这一步我觉得很值,原因主要有三个。
1. 旧站结构不稳定
越老的站,越容易出现:
- 编码不统一;
- 模板结构改过好几轮;
- 局部 HTML 不规范;
- 一部分内容是正文,一部分内容其实是广告、版权说明、分享按钮、脚本残留。
先抽成 JSON,相当于先做一次“内容归档”,后面再慢慢调 Hugo 生成逻辑,成本会低很多。
2. 便于排错
如果最后 Hugo 页面出问题,我可以很快判断:
- 是 HTML 提取阶段就错了;
- 还是 JSON 转 Markdown 阶段错了;
- 还是 Hugo 模板渲染阶段错了。
3. 便于重复生成
只要 JSON 保留着,我以后随时可以:
- 重新调整 front matter;
- 重新整理日期或分类;
- 改 URL 规则;
- 重新生成整批文章。
这对历史站点迁移特别重要。
关于“Markdown”这件事,我的实际做法并不教条
虽然最终文件扩展名是 .md,但我并没有强迫自己把所有旧文章都转成纯 Markdown。
这是一个非常实际的取舍:
- 能稳定恢复内容 比 百分之百转成漂亮 Markdown 更重要;
- 旧文章里原本就有大量 HTML、代码块、图片、附件、特殊嵌套结构;
- 如果为了“看起来纯净”而硬转 Markdown,反而更容易把排版搞坏。
所以我的策略是:
- front matter 用 Hugo 标准格式;
- 正文优先保证内容完整;
- 必要时直接保留 HTML。
对迁移项目来说,这通常是最稳的方案。
这次迁移里几个特别有用的细节
1. 编码兜底
老 HTML 最容易踩坑的就是编码。
所以脚本里我先检测 <meta charset>,如果拿不到,再在 UTF-8、GB18030 等编码之间兜底尝试,最后尽量统一转成 UTF-8。
这能解决很多“中文变乱码”的老问题。
2. 资源路径清洗
无论是 #anchor、查询参数、反斜杠还是 URL 编码,脚本都先做统一清洗,再去定位本地文件。
否则明明文件还在,但就是匹配不到。
3. 把评论也先提取出来
评论我这次并没有强行做成 Hugo 页面中的可见模块,而是先提取后放进 front matter 或中间数据里。
先保住数据,渲染方式以后再决定。
4. 页面包比全局静态目录更适合历史迁移
如果所有图片都堆进 static/,前期看似省事,后期整理会很痛苦。
按文章拆分成页面包,哪篇文章出了问题,直接进对应目录就能定位。
我的目录组织方式
迁移完成后的 Hugo 内容目录,大致如下:
content/
├── article/
│ ├── 1/
│ │ ├── index.md
│ │ └── assets/
│ ├── 2/
│ │ ├── index.md
│ │ └── assets/
│ └── ...
└── old-blog/
├── 1/
│ ├── index.md
│ └── assets/
├── 2/
│ ├── index.md
│ └── assets/
└── ...这样有两个直接好处:
- 新旧两段历史内容逻辑上是分开的;
- 访问路径又都能通过
url保持兼容。
发布前我重点检查了什么
批量迁移最怕的不是脚本报错,而是“脚本没报错,但内容悄悄坏了”。
所以在正式发布前,我重点抽查了几类文章:
- 带很多图片的文章:确认图片是否全部复制、链接是否正确。
- 带附件下载的文章:确认
a href重写后还能打开。 - 带代码块的文章:确认旧站里的
prettyprint或特殊 class 是否被正确处理。 - 很早期的 old-blog 文章:确认编码和模板差异没有把正文抽错。
- 历史外链很多的文章:确认只改写本地资源,不去误伤外部链接。
另外,我还特别留意两类问题:
- 正文里是否还残留旧站的分享按钮、版权说明、捐赠模块;
- 旧页面里的懒加载图片,是否只写在
data-original而没写src。
这些都是批量迁移里很容易漏掉的小坑。
这次迁移之后,我得到的其实不只是一个新博客
表面上看,我只是把博客从 Wordpress 换到了 Hugo。
但更重要的是,我顺手完成了几件以前一直没机会做的事:
- 把两段历史博客重新归档到同一个内容系统里;
- 尽量保留了原来的访问路径;
- 把文章正文和引用图片真正绑定到一起;
- 把“内容恢复”和“站点渲染”拆成了两个独立问题;
- 从此以后,博客发布变成一次普通的 Git 提交。
这意味着后面无论是继续写文章、调整主题、增加搜索、做多语言,还是再迁移到别的平台,成本都会低很多。
小结
这次迁移,我最后采用的路线可以概括成一句话:
先把旧站尽可能完整地静态化保留下来,再把静态结果反向整理成 Hugo 可维护的内容结构。
对于 2017 年之后的 Wordpress,我用的是:
Simply Static -> R 提取正文与资源 -> 生成 Hugo 页面包
对于 2013-2017 的 old-blog,我用的是:
静态 HTML -> JSON 中间层 -> Hugo 页面包
而 Hugo 这一侧,则是:
LoveIt 主题 + GitHub Pages + GitHub Actions + Cloudflare CNAME 代理
整套方案的重点不在于“某个插件”或“某个脚本”,而在于下面这几个原则:
- 内容优先于系统;
- 兼容旧 URL 优先于重做链接;
- 资源要跟文章一起迁移;
- 中间数据最好可重复生成;
- 能稳定上线,胜过追求形式上的完美。
如果你手里也有一个已经跑了很多年的老 Wordpress,甚至还有一段只剩静态 HTML 的历史站点,这条路线其实是非常实用的:不要求旧系统还能正常活着,只要旧页面还能拿到,就还有机会把内容完整救回来。