设计笔记
随笔 约 9 分钟阅读

从 WordPress 到 Typecho-Workers 的数据映射与导入

书接上回,解决完初始化的 10ms CPU 超时问题后,程序总算能顺利运行了。接下来最重要的任务,就是把旧博客里的历史数据完整搬过来。

我的旧数据来自 WordPress 导出的 WXR(XML)备份文件。正经的文章和页面数据其实不算多,但十多年里积累下来的上万条读者评论,每一条都是弥足珍贵的互动记录;再加上数年来随手记录的日常笔记,构成了整个博客最核心的数字资产。


数据的构成与难点

常规内容的迁移相对直接:

  • 文章与页面:标题、正文、发布时间、分类与标签的映射关系很明确,直接转换成 typecho_contentstypecho_metas 即可;
  • 评论数据:主要在于维护父子层级的 parent 引用,按时间先后顺序插入并关联对应的文章 cid

比较麻烦的是笔记(Notes)

早年在 WordPress 中,我为了记录零散的想法与日常随笔,专门通过自定义文章类型(Custom Post Type)扩展了一套微贴系统。这部分数据在导出文件里非常特殊:

  1. 字段分散:正文属于 post_type = 'note',但点赞数、话题、关联的多张图片附件 ID 全部分散在 wp_postmeta 和自定义分类法(Taxonomy)里;
  2. 格式多变:有的笔记带 #话题,有的带多张九宫格图片,有的只有纯文字;
  3. 后续管理方式:迁移到新系统后,笔记该如何存储,后台该如何呈现与编辑,都需要提前规划好,不能简单把它们当成普通文章塞进数据库。

为了不破坏 Typecho 原生的表结构,我决定在保留核心表兼容性的前提下,通过 typecho_contents.type = 'note' 来承载笔记,同时用 typecho_fields 存放点赞数和多附件关联。


鞭策 AI 实现迁移工具

面对这种结构复杂、历史包袱重的数据转换,自己从头手写不仅费时费力,还极容易漏掉各种边角边界。于是我把 WordPress 的 XML 结构片段、Typecho 的 Drizzle Schema 表结构以及我的映射诉求一股脑扔给 AI,开始“鞭策” AI 来实现这套数据迁移工具。

在多轮需求拆解、边界纠错和报错调教下,AI 顺着数据结构一层层理清了关联逻辑,最终实现了一整套自动化迁移脚本 scripts/wordpress.ts

整个迁移流程设计如下:

[XML 导出文件] → [结构化数据解析] → [媒体扫描与 R2 规划] → [SQLite 数据集构建] → [D1 批量入库]

1. 笔记数据的精准提取与映射

在解析 XML 时,脚本针对 postType === 'note' 做单独处理:

  • 从自定义元数据中提取点赞数,写入 typecho_fields 表的 note_likes 字段;
  • 解析关联的图片附件 ID,映射为新库中的附件 cid,并以 JSON 数组形式存入 note_attachments
  • 将 WordPress 的自定义话题分类映射为 Typecho 的 note_topic 元数据。
// scripts/wordpress.ts 核心字段提取逻辑
if (item.postType === 'note') {
  // 1. 点赞数迁移:从 postmeta 提取并写入 note_likes
  const praise = values.get('praise')?.at(-1);
  if (praise !== undefined) {
    fields.push(fieldRow(cid, 'note_likes', praise, true));
  }

  // 2. 多图片附件关联:合并历史 attachment 字段为 JSON 数组
  const attachmentIds = [...(values.get('images') || []), ...(values.get('attachment') || [])]
    .flatMap(value => value.match(/\d+/g) || [])
    .map(value => contentIdByOldId.get(Number(value)))
    .filter((value): value is number => value !== undefined);

  if (attachmentIds.length) {
    fields.push(fieldRow(cid, 'note_attachments', JSON.stringify([...new Set(attachmentIds)])));
  }
}

2. 评论层级关系重构

为了防止评论顺序错乱,脚本按发布时间对评论排序,并在插入 typecho_comments 时动态维护新旧 coid 映射表,确保子评论能够准确找到父级 parent 节点。

同时,脚本只对审核通过(approved)的评论进行计数统计,并更新回对应文章的 commentsNum 字段。

3. 媒体链接批量替换与 R2 同步

旧文章里包含大量类似 https://old-domain.com/wp-content/uploads/2021/04/banner.jpg 的硬编码链接。

迁移脚本使用正则扫描正文与摘要中的所有图片地址,将它们统一重写为当前系统的规范路径 /usr/uploads/...,同时导出资源下载清单,后续直接批量同步到 Cloudflare R2 存储桶中:

function replaceMediaUrls(content: string, replacements: Map<string, string>): string {
  if (!content || replacements.size === 0) return content;
  return content.replace(
    /https?:\/\/[^\s"'<>]+?\/wp-content\/uploads\/[^\s"'<>),]+/gi, 
    source => replacements.get(source) || source
  );
}

4. 处理 SQLite 特殊字符与 NUL 字节

在处理早期历史文章时,部分老数据中夹带了不可见的 \0(NUL 字节),直接拼接 SQL 会导致 SQLite 语法报错。

脚本在生成 SQL 字面量时进行了 Hex 转换:

export function sqlLiteral(value: SqlValue): string {
  if (value === null || value === undefined) return 'NULL';
  if (typeof value === 'number') return String(value);
  
  // 包含 NUL 字节时使用 Hex Text 字面量转义
  if (value.includes('\u0000')) {
    return `CAST(X'${Buffer.from(value).toString('hex')}' AS TEXT)`;
  }
  return `'${value.replace(/'/g, "''")}'`;
}

导入执行与验证

准备就绪后,先执行脚本生成 SQL 文件:

bun run db:migrate:wordpress --file=/path/to/wordpress.xml --site-url=https://example.com --rewrite-media

脚本运行完毕后会输出详细的导入统计:

==================================================
WordPress Migration Report
==================================================
Posts imported:        280
Pages imported:         12
Notes imported:       1045
Comments imported:   11280
Metas (Cats/Tags):     186
Media assets mapped:  1240
==================================================
Generated SQL: ./drizzle/wordpress-import.sql

随后使用 Wrangler 将生成的 SQL 写入生产库:

wrangler d1 execute typecho-db --remote --file=./drizzle/wordpress-import.sql

导入过程只用了不到半分钟。打开博客前台,文章列表、独立页面、上万条评论盖楼和上千条笔记都准确就位,十多年的内容完整搬到了 Cloudflare D1 上。


刚搬完家,新的问题来了

数据导入顺利完成,前台也能正常访问。

然而,博客跑了一晚上,第二天打开控制台一看,单日数据库读取量飙升到了数百万行,直接击穿了免费配额。