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 |
| 附件 attachment | 291 |
| 修订版本 revision | 87(忽略) |
| 分类 / 标签 | 9 / 69 |
内容形态:196 篇经典编辑器 HTML + 4 篇 Gutenberg 块,还有少量 Handsome 主题短代码([collapse]、[post cid=]、[hplayer])。
方案设计(关键决策)
状态映射:WP 的
post_status还原为 5 种语义,写入 front matter 的status字段:publish→published、draft→draft、trash→trash、private→hidden、publish+密码→encrypted(本数据无)。 非 published 状态全部加draft: true——Hugo 原生的”导入但不渲染”,hugo server -D可预览。目录结构:文章按发布时间存
content/posts/YYYY/MM/<slug>/(人好找好改),每个 bundle 内index.md+images/;页面单独放content/pages/<slug>/。这是 Hugo 官方推荐的 Page Bundle 模式。URL 与旧链接兼容:Hugo 目录结构自动生成规范链接
/posts/YYYY/MM/<slug>/,同时每篇 front matter 写两个 alias:aliases: - /posts/<slug>.html # 旧 WordPress 链接 - /posts/<slug> # 短链接Hugo 自动为 alias 生成 301 重定向页,新旧站同域名
www.ioioi.cn,老地址全部不 404。图片处理:文章引用的图片复制进文章 bundle 的
images/子目录,引用改为相对路径;没有被任何文章引用的孤儿图片,保留YYYY/MM年月结构放到static/images/。全程不保留wp-content/uploads这种 WordPress 痕迹。
工具
| 工具 | 版本 | 用途 |
|---|---|---|
| Python | 3.13 | 解析 SQL、转换、生成 |
| markdownify | 1.2.3 | HTML → Markdown(代码块/表格/图片都处理得很好) |
| Hugo | v0.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 + 数量核对 + 渲染抽查 + 迁移报告
踩过的坑
.html附件不能放 content 里:Hugo 把 bundle 内任何.html/.md文件当页面源,触发security.allowContent拦截。非图片附件(html/zip/mp4)统一放static/media/YYYY/MM/,用绝对路径/media/...引用。- PowerShell 跑
python -c传中文/引号脚本容易炸,改成写.py文件执行。 - SQL 内容里的
\r\n是转义序列不是字面量,sql_unescape()要按\\→\、\n→换行 的顺序处理,否则代码块里出现真\n文本。 - 控制台 GBK 乱码 ≠ 文件乱码,验证文件内容用
-Encoding UTF8读取。 - 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。