Contents

把 Wordpress 旧站连同图片一起迁移到 Hugo

从本地建站、GitHub Pages 自动部署,到两段历史博客的批量迁移

这次迁移,本质上不是“换一个博客程序”这么简单,而是一次把十多年内容重新整理、重新落盘的过程。

我的旧博客实际上分成两段:

  1. 2013 年开始的第一段旧站:这部分因为历史原因,最后只剩下静态化后的 HTML 文件,当前放在 /old-blog/ 下。
  2. 2017 年开始的 Wordpress:这部分有完整的 Wordpress 结构,但我最终也没有直接走数据库导出,而是先把它静态化,再从静态页面中提取正文和资源。

/posts/2026/1/1.png

之所以会出现“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 和代理加速

做法很简单:

  1. 在 GitHub 仓库的 Pages 设置里,把自定义域名填成 www.liujason.com
  2. 在 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,但我最后没有直接从数据库里倒文章,而是走了这样一条更稳的路径:

  1. Simply Static 把整个 Wordpress 导出成静态 HTML。
  2. R 脚本 从这些 HTML 里提取文章标题、日期、分类、标签、正文、评论,以及正文中引用的图片和附件。
  3. 再把提取出的结果组织成 Hugo 可以直接读取的 index.mdassets/ 目录。

我选择这条路线,主要是因为:

  • 静态 HTML 是最终渲染结果,比 Wordpress 数据库里的半成品更接近最终页面。
  • 主题里加过哪些自定义 HTML、历史内容里混入过什么编辑器残留,HTML 里都能直观看到。
  • 图片、附件、超链接这些资源关系,也更容易一并扫描并重写。

换句话说,这次迁移的核心不是“导表”,而是:

把历史页面当成已经冻结的发布结果,再从发布结果里反向恢复 Hugo 内容。


迁移 2017 年至今的 Wordpress

第一步:用 Simply Static 导出整站 HTML

Simply Static 会把 Wordpress 当前能访问到的内容导成静态站。我导出后,得到的是一批 HTML 文件以及页面中引用到的静态资源。

这一步的目的不是拿来直接上线,而是作为后续脚本提取的输入数据。


第二步:用 R 脚本提取正文和引用资源

我写了一个 R 脚本,做几件事:

  • 自动遍历导出的 .html/.htm 文件。
  • 根据页面结构提取:
    • 标题
    • 日期
    • 分类
    • 标签
    • 正文 HTML
    • 评论文本
  • 找出正文中的:
    • img src
    • img data-original
    • a 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,路径一定会失效。因此脚本做了两件事:

  1. 定位原始文件:根据 HTML 文件路径、站点导出目录和当前工作目录,尽量把资源文件找出来。
  2. 复制并改写链接:把资源复制到当前文章的 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,而是分成了两段:

  1. 先从 old-blog 的静态 HTML 中提取结构化信息,写成 JSON。
  2. 再从 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-8GB18030 等编码之间兜底尝试,最后尽量统一转成 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 保持兼容。

发布前我重点检查了什么

批量迁移最怕的不是脚本报错,而是“脚本没报错,但内容悄悄坏了”。

所以在正式发布前,我重点抽查了几类文章:

  1. 带很多图片的文章:确认图片是否全部复制、链接是否正确。
  2. 带附件下载的文章:确认 a href 重写后还能打开。
  3. 带代码块的文章:确认旧站里的 prettyprint 或特殊 class 是否被正确处理。
  4. 很早期的 old-blog 文章:确认编码和模板差异没有把正文抽错。
  5. 历史外链很多的文章:确认只改写本地资源,不去误伤外部链接。

另外,我还特别留意两类问题:

  • 正文里是否还残留旧站的分享按钮、版权说明、捐赠模块;
  • 旧页面里的懒加载图片,是否只写在 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 的历史站点,这条路线其实是非常实用的:不要求旧系统还能正常活着,只要旧页面还能拿到,就还有机会把内容完整救回来。