WordPress 迁移 Hugo 全记录:Plan、工具、代码与流程

2026 年 8 月,我把自己的 WordPress 博客完整迁移到了 Hugo。这篇文章记录整个迁移的计划、工具、核心代码和流程,给以后做同类迁移留个参考。

背景与数据盘点

旧站是 1Panel 面板部署的 Docker WordPress,备份包里有:

  • wordpress.sql(2.7MB,MySQL 全量导出,内容都在这里)
  • wp-content/uploads/(370 个媒体文件)

先摸清家底(用 Python 解析 SQL 统计):

类型数量
文章 post(已发布)211
文章 post(草稿)12
文章 post(回收站)9
文章 post(私密)2
页面 page(已发布 / 草稿)6 / 1
附件 attachment291
修订版本 revision87(忽略)
分类 / 标签9 / 69

内容形态:196 篇经典编辑器 HTML + 4 篇 Gutenberg 块,还有少量 Handsome 主题短代码([collapse][post cid=][hplayer])。

方案设计(关键决策)

  1. 状态映射:WP 的 post_status 还原为 5 种语义,写入 front matter 的 status 字段: publish→publisheddraft→drafttrash→trashprivate→hiddenpublish+密码→encrypted(本数据无)。 非 published 状态全部加 draft: true——Hugo 原生的”导入但不渲染”,hugo server -D 可预览。

  2. 目录结构:文章按发布时间存 content/posts/YYYY/MM/<slug>/(人好找好改),每个 bundle 内 index.md + images/;页面单独放 content/pages/<slug>/。这是 Hugo 官方推荐的 Page Bundle 模式。

  3. URL 与旧链接兼容:Hugo 目录结构自动生成规范链接 /posts/YYYY/MM/<slug>/,同时每篇 front matter 写两个 alias:

    aliases:
      - /posts/<slug>.html    # 旧 WordPress 链接
      - /posts/<slug>         # 短链接

    Hugo 自动为 alias 生成 301 重定向页,新旧站同域名 www.ioioi.cn,老地址全部不 404。

  4. 图片处理:文章引用的图片复制进文章 bundle 的 images/ 子目录,引用改为相对路径;没有被任何文章引用的孤儿图片,保留 YYYY/MM 年月结构放到 static/images/。全程不保留 wp-content/uploads 这种 WordPress 痕迹。

工具

工具版本用途
Python3.13解析 SQL、转换、生成
markdownify1.2.3HTML → Markdown(代码块/表格/图片都处理得很好)
Hugov0.162.1 extended构建验证

核心代码

1. SQL 解析器(兼容 mysqldump 转义)

def parse_values(block):
    """把一个 SQL 行拆成字段列表,处理 '' 和 \\ 转义"""
    vals, cur = [], []
    i, n = 0, len(block)
    def flush():
        nonlocal cur
        vals.append(''.join(cur)); cur = []
    while i < n:
        c = block[i]
        if c == "'":
            j = i + 1
            buf = []
            while j < n:
                if block[j] == "\\" and j + 1 < n:
                    buf.append('\\' + block[j+1]); j += 2
                elif block[j:j+2] == "''":
                    buf.append("'"); j += 2
                elif block[j] == "'":
                    j += 1; break
                else:
                    buf.append(block[j]); j += 1
            cur.append(''.join(buf)); i = j
        elif c == ',':
            flush(); i += 1
        else:
            cur.append(c); i += 1
    if cur:
        flush()
    return vals

行切分的关键是引号感知:内容里可能包含 ),( 或转义引号,必须用状态机在引号外才切行:

def extract_insert(table):
    """解析该表的所有 INSERT 区块, mysqldump 可能拆成多条 INSERT 语句"""
    all_rows = []
    for m in re.finditer(r'INSERT INTO `' + table + r'` VALUES(.*?);\n', sql, re.S):
        block = m.group(1)
        rows, start, i, in_str = [], 0, 0, False
        while i < len(block):
            if block[i] == "\\":          # 转义字符,跳过
                i += 2; continue
            if block[i] == "'":
                if in_str and block[i+1:i+2] == "'":   # '' 转义
                    i += 2; continue
                in_str = not in_str
            elif not in_str and block[i:i+3] == '),(':
                rows.append(block[start:i+1]); start = i + 2; i += 3; continue
            i += 1
        ...
    return all_rows

2. HTML → Markdown + 图片归集

from markdownify import markdownify as md

# 清理 Gutenberg 注释
html = re.sub(r'<!--\s*/?wp:[^>]*?-->', '', html)

# 提取文章引用的所有上传文件
ABS_RE = re.compile(r'https?://(?:www\.)?ioioi\.cn/wp-content/uploads/([^\s"\'<>)]+)')
for m in ABS_RE.finditer(html):
    rel = unquote(m.group(1))
    # 找到磁盘上的原文件,复制到 bundle/images/,引用改为相对路径
    target = safe_name(os.path.basename(rel))
    shutil.copy2(src, os.path.join(bundle, 'images', target))
    ref_map[orig] = 'images/' + target

markdown = md(html, heading_style='ATX', bullets='-')
for orig, target in ref_map.items():
    markdown = markdown.replace(orig, target)

3. front matter 生成

fm.append('---')
fm.append(fm_value('title', it['title'] or f'未命名-{pid}'))
fm.append(fm_value('date', fmt_dt(date_dt)))          # YYYY-MM-DDTHH:MM:SS+08:00
fm.append(fm_value('status', it['status']))
fm.append(fm_value('draft', it['status'] != 'published'))
fm.append(fm_value('aliases', [alias_base + '.html', alias_base]))
fm.append('---')

流程(5 阶段)

阶段1 解析   wordpress.sql → 结构化数据(文章/页面/分类/标签/图片引用)
阶段2 转换   HTML→Markdown, 清 Gutenberg 注释, 解析 <img> 清单
阶段3 资源   文章引用图片 → bundle images/; 孤儿 → static/images/YYYY/MM/
阶段4 生成   content/posts/YYYY/MM/<slug>/index.md + content/pages/<slug>/index.md
阶段5 验证   hugo build + 数量核对 + 渲染抽查 + 迁移报告

踩过的坑

  1. .html 附件不能放 content 里:Hugo 把 bundle 内任何 .html/.md 文件当页面源,触发 security.allowContent 拦截。非图片附件(html/zip/mp4)统一放 static/media/YYYY/MM/,用绝对路径 /media/... 引用。
  2. PowerShell 跑 python -c 传中文/引号脚本容易炸,改成写 .py 文件执行。
  3. SQL 内容里的 \r\n 是转义序列不是字面量,sql_unescape() 要按 \\\\n→换行 的顺序处理,否则代码块里出现真 \n 文本。
  4. 控制台 GBK 乱码 ≠ 文件乱码,验证文件内容用 -Encoding UTF8 读取。
  5. mysqldump 会按表拆成多条 INSERT 语句:wp_posts 数据被拆成两个 INSERT INTO 区块,如果只 re.search 第一个区块,第二个区块里的 2025 年文章会全部丢失。解析器必须用 re.finditer 扫描所有 INSERT INTO ... VALUES 区块再合并。这是本次迁移最大的坑,第一版差点丢掉 11 篇文章(2025 年的 Win11 核显、SQL 连接规范、绿联 NAS、Windows IPv4 优先级等)。

最终结果

  • 234 篇文章 + 7 个页面全部导入(逐篇核对无遗漏),hugo build 零错误
  • 211 篇已发布正常渲染,草稿/回收站/私密 23 篇以 draft: true 保留
  • 511 个旧链接重定向,123 篇带图文章图片全部本地化,无缺失
  • 迁移报告自动生成(缺失图片、短代码清单、孤儿图片清单)

遗留:[collapse](8 篇)、[post cid=](9 处)、[hplayer](1 篇)等 Handsome 主题短代码目前以纯文本保留,后续可改成 Hugo shortcode(<details> 折叠、文章互链等)。

数据文件

迁移脚本和中间数据留在本地 C:\Users\i\AppData\Local\Temp\opencode\(migrate.py / generate.py / wp_items.json),报告在 migration-report.md