从 WordPress 到 Astro迁移实战全记录

2736 字
14 分钟
从 WordPress 到 Astro迁移实战全记录

这篇文章记录了把 MuxuiWordPress(B2 Pro) 迁到 Astro + Firefly 的完整过程。不是「理论科普」,而是一次可复现的实操:文章怎么导、图片怎么改、导航怎么拆、友链怎么迁、踩了哪些坑。

如果你也在考虑离开 WordPress,或者正在把内容搬到 Astro / Fuwari / Firefly 一类静态主题,可以直接对照本文的步骤做。


为什么离开 WordPress#

WordPress 很强,但对我这种以内容为主、不需要商城和复杂会员体系的个人站来说,日常成本越来越明显:

  • 性能与安全:PHP + 数据库 + 插件栈,更新、备份、防篡改都要花精力
  • 主题耦合深:B2 Pro 功能丰富,但短代码、付费隐藏、圈子等和主题绑死,换主题等于重做
  • 静态化红利:Astro 构建后是纯静态(或边缘静态),CDN 分发简单,运维接近零

最终选择 Firefly——基于 Astro 的个人博客主题,Markdown / MDX 写作,配置全在 TypeScript 文件里,改起来清晰。


目标架构一览#

项目迁移前迁移后
运行时WordPress + PHP + MySQLAstro 静态构建
主题B2 ProFirefly(fork 自用)
内容后台编辑器 / Gutenbergsrc/content/posts/*.md
图片muxui.com/wp-content/uploads/...muxui.com/uploads/...
域名muxui.com仍为 muxui.com

仓库结构大致是:

Firefly/
├── muxui.WordPress.2026-07-23.xml # WXR 导出(本地用,不必提交)
├── scripts/
│ ├── migrate-wordpress.mjs # 一次性迁移脚本
│ └── lib/
│ ├── wxr-parse.mjs # 解析 WXR
│ └── html-to-md.mjs # HTML → Markdown + 图床改写
├── src/config/ # 站点 / 导航 / 友链等配置
└── src/content/posts/ # 迁过来的文章
migrate-wordpress.mjs
import fs from "node:fs";
import path from "node:path";
import { parseWxr } from "./lib/wxr-parse.mjs";
import { htmlToMarkdown, rewriteImageUrls } from "./lib/html-to-md.mjs";
const ROOT = process.cwd();
const XML = path.join(ROOT, "muxui.WordPress.2026-07-23.xml");
const POSTS_DIR = path.join(ROOT, "src/content/posts");
const SITE_CONFIG = path.join(ROOT, "src/config/siteConfig.ts");
function decodeSlug(raw) {
if (!raw) return "";
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
function safeSlug(title, id) {
const base = title
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return base || `post-${id}`;
}
function pickCategory(cats) {
const specific = cats.filter((c) => c !== "文章");
return specific[0] || cats[0] || "";
}
function yamlEscape(s) {
return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function toIsoDate(wpDate) {
return wpDate.slice(0, 10);
}
function excerptFrom(md, excerpt) {
if (excerpt?.trim()) return excerpt.trim().slice(0, 160);
const plain = md
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[[^\]]*\]\([^)]*\)/g, "$1")
.replace(/[#>*`\[\]()\-_!]/g, " ")
.replace(/https?:\/\/\S+/g, "")
.replace(/\s+/g, " ")
.trim();
return plain.slice(0, 120);
}
function clearPostsDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
return;
}
for (const name of fs.readdirSync(dir)) {
fs.rmSync(path.join(dir, name), { recursive: true, force: true });
}
}
function buildFrontmatter(post, image, body, slug) {
const tags = post.tags.map((t) => `"${yamlEscape(t)}"`).join(", ");
const desc = excerptFrom(body, post.excerpt);
return `---
title: "${yamlEscape(post.title)}"
published: ${toIsoDate(post.date)}
updated: ${toIsoDate(post.modified)}
description: "${yamlEscape(desc)}"
image: "${yamlEscape(image)}"
tags: [${tags}]
category: "${yamlEscape(pickCategory(post.cats))}"
draft: false
comment: true
slug: "${yamlEscape(slug)}"
---
`;
}
function patchSiteConfig(filePath) {
let src = fs.readFileSync(filePath, "utf8");
src = src.replace(/title: "Firefly"/, 'title: "Muxui"');
src = src.replace(/subtitle: "Demo site"/, 'subtitle: "记录美好生活!"');
src = src.replace(
/site_url: "https:\/\/firefly\.cuteleaf\.cn"/,
'site_url: "https://muxui.com"',
);
src = src.replace(
/description:\s*\n\s*"[\s\S]*?",/,
'description:\n\t\t"记录美好生活!",',
);
src = src.replace(
/keywords: \[[\s\S]*?\],/,
`keywords: [
\t\t"Muxui",
\t\t"博客",
\t\t"技术博客",
\t\t"muxui.com",
\t],`,
);
src = src.replace(/(navbar: \{[\s\S]*?title: )"Firefly"/, '$1"Muxui"');
fs.writeFileSync(filePath, src);
}
function main() {
if (!fs.existsSync(XML)) {
console.error("Missing WXR:", XML);
process.exit(1);
}
const { posts, attachmentsById } = parseWxr(XML);
console.log(`Parsed ${posts.length} published posts`);
clearPostsDir(POSTS_DIR);
const used = new Set();
for (const post of posts) {
const body = htmlToMarkdown(post.content);
let image = "";
if (post.thumbnailId && attachmentsById.has(post.thumbnailId)) {
image = rewriteImageUrls(attachmentsById.get(post.thumbnailId));
}
let slug = decodeSlug(post.slug) || safeSlug(post.title, post.id);
// Windows-safe filenames: avoid reserved chars
slug = slug.replace(/[<>:"/\\|?*]/g, "-").replace(/\.+$/g, "");
if (used.has(slug)) slug = `${slug}-${post.id}`;
used.add(slug);
const fm = buildFrontmatter(post, image, body, slug);
const out = path.join(POSTS_DIR, `${slug}.md`);
fs.writeFileSync(out, fm + body + "\n", "utf8");
console.log("Wrote", path.relative(ROOT, out));
}
patchSiteConfig(SITE_CONFIG);
console.log("Patched", path.relative(ROOT, SITE_CONFIG));
console.log("Done.");
}
main();
wxr-parse.mjs
import fs from "node:fs";
import { XMLParser } from "fast-xml-parser";
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
cdataPropName: "__cdata",
isArray: (name) =>
["item", "category", "wp:postmeta", "wp:comment"].includes(name),
});
function textOf(node) {
if (node == null) return "";
if (typeof node === "string" || typeof node === "number") return String(node);
if (typeof node === "object" && "__cdata" in node)
return String(node.__cdata ?? "");
return String(node);
}
function metaMap(postmeta) {
const map = {};
for (const m of postmeta || []) {
map[textOf(m["wp:meta_key"])] = textOf(m["wp:meta_value"]);
}
return map;
}
function categoriesOf(item) {
const cats = [];
const tags = [];
for (const c of item.category || []) {
const name = textOf(c);
const domain = c["@_domain"];
if (domain === "post_tag") tags.push(name);
else if (domain === "category") cats.push(name);
}
return { cats, tags };
}
export function parseWxr(xmlPath) {
const xml = fs.readFileSync(xmlPath, "utf8");
const doc = parser.parse(xml);
const channel = doc.rss.channel;
const attachmentsById = new Map();
const posts = [];
for (const item of channel.item || []) {
const type = textOf(item["wp:post_type"]);
const id = textOf(item["wp:post_id"]);
if (type === "attachment") {
const url = textOf(item["wp:attachment_url"]) || textOf(item.guid) || "";
attachmentsById.set(id, url);
continue;
}
if (type !== "post") continue;
if (textOf(item["wp:status"]) !== "publish") continue;
const { cats, tags } = categoriesOf(item);
const meta = metaMap(item["wp:postmeta"]);
posts.push({
id,
title: textOf(item.title),
slug: textOf(item["wp:post_name"]),
date: textOf(item["wp:post_date"]),
modified: textOf(item["wp:post_modified"]),
excerpt: textOf(item["excerpt:encoded"]),
content: textOf(item["content:encoded"]),
cats,
tags,
thumbnailId: meta._thumbnail_id || "",
});
}
return {
site: {
title: textOf(channel.title),
link: textOf(channel.link),
description: textOf(channel.description),
},
attachmentsById,
posts,
};
}
html-to-md.mjs
import TurndownService from "turndown";
const TARGET_PREFIX = "https://muxui.com/uploads/";
export function rewriteImageUrls(text) {
return text
.replace(/https?:\/\/img\.roven\.cc\/images\//g, TARGET_PREFIX)
.replace(/https?:\/\/muxui\.com\/wp-content\/uploads\//g, TARGET_PREFIX)
.replace(/(["'(=\s]|^)\/wp-content\/uploads\//g, `$1${TARGET_PREFIX}`);
}
function stripShortcodes(html) {
return html
.replace(/\[\/?b2[^\]]*\]/gi, "")
.replace(/\[hide[^\]]*\]([\s\S]*?)\[\/hide\]/gi, "$1")
.replace(/\[.*?\]/g, (m) => {
const href = m.match(/https?:\/\/[^\s\]]+/);
return href ? `<p><a href="${href[0]}">${href[0]}</a></p>` : "";
});
}
export function htmlToMarkdown(html) {
const cleaned = stripShortcodes(rewriteImageUrls(html));
const td = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
});
td.keep(["iframe", "video", "table"]);
return td.turndown(cleaned).trim();
}

第一步:从 WordPress 导出内容#

后台路径:工具 → 导出 → 全部内容,得到 WXR(WordPress eXtended RSS)XML。

我这份导出里大致有:

  • 已发布文章 23 篇(另有 1 篇草稿跳过)
  • 附件约 181 个(主要用于封面 ID → URL 映射)
  • 以及页面、友链、圈子、快讯等 B2 扩展类型

第一期只迁 已发布文章 + 站点基础信息;友链、页脚备案、导航定制是迁完内容后再单独处理的。

建议:导出后先把 XML 放到仓库根目录本地用,不要轻易提交到公开仓库(体积大,也可能含邮件等隐私字段)。


第二步:写一个可复跑的迁移脚本#

手工复制 23 篇不现实,所以用 Node 脚本一次性完成:

  1. fast-xml-parser 解析 WXR
  2. 建立 attachment_id → URL 索引(封面 _thumbnail_id
  3. 过滤 post_type=poststatus=publish
  4. Gutenberg HTML → Markdown(turndown
  5. 改写图片域名
  6. 写出 Firefly frontmatter + .md 文件
  7. 更新 siteConfig.ts,删掉主题自带 Demo 文

核心映射关系:

WordPressFirefly
titletitle
post_name(URL 解码)文件名 / slug
post_datepublished
post_modifiedupdated
categorycategory(优先具体分类,弱化泛化的「文章」)
post_tagtags
excerpt / 正文截取description
_thumbnail_id → attachmentimage(绝对 URL)
contentMarkdown 正文

图片统一规则:

https://muxui.com/wp-content/uploads/{path}
→ https://muxui.com/uploads/{path}

相对路径 /wp-content/uploads/... 同样改写;外站自带的 /wp-content/uploads/(例如引用别人站点的图)不要动,否则会出现:

https://www.example.comhttps://muxui.com/uploads/...

这种拼接错乱——我第一次写正则时就踩过。

运行方式:

Terminal window
pnpm add -D turndown fast-xml-parser
node scripts/migrate-wordpress.mjs
pnpm check

第三步:处理 B2 / Gutenberg 的「脏内容」#

纯 Markdown 博客迁出去通常很干净;B2 + Gutenberg 会带上:

  • 区块注释:<!-- wp:paragraph -->
  • 短代码:隐藏内容、下载框、卡片等
  • 偶发残缺 HTML、外链图片

策略是 可读优先,不追求像素级还原

  • Turndown 处理常见标题、列表、链接、代码块、表格
  • [hide]...[/hide] 之类尽量展开正文
  • 无法可靠还原的短代码,降级成链接或直接丢掉,保证构建不炸

迁完后务必抽查:

  • 图多的文章(封面 + 正文图是否都是 muxui.com/uploads
  • 代码长文(Markdown 代码围栏是否完整)
  • 带外链配图的文章(外站 URL 是否被误伤)

第四步:站点身份与页脚#

siteConfig.ts 里至少改这些:

  • title / navbar.title → Muxui
  • subtitle / description → 「记录美好生活!」
  • site_urlhttps://muxui.com

页脚版权与备案:

Copyright © 2026 Muxui ・ 蜀ICP备2024080231号-1

备案号放在 FooterConfig.html,并链到工信部查询页 https://beian.miit.gov.cn/。侧栏显示名同步改成 Muxui。


第五步:导航按「文章 / 软件」分组#

WordPress 时代分类较杂。迁到 Firefly 后,导航做成:

主要 · 文章 · 软件 · 工具 · 友链 · 留言 · 关于我

其中:

  • 文章 下拉:归档 / 分类 / 标签 —— 只覆盖
    文章、Java、Python、PHP、Vue、jQuery
  • 软件 下拉:归档 / 分类 / 标签 —— 只覆盖
    软件、Windows、Mac、IOS
  • 工具:外链 https://muxui.com/tools,新窗口打开

实现上用配置文件声明分组,例如:

export const CONTENT_GROUPS = {
article: {
id: "article",
name: "文章",
categories: ["文章", "Java", "Python", "PHP", "Vue", "jQuery"],
},
software: {
id: "software",
name: "软件",
categories: ["软件", "Windows", "Mac", "IOS"],
},
};
  • 归档:/archive/?category=A&category=B&...(Firefly 的 ArchivePanel 支持多 category OR 筛选)
  • 分类页:/categories/article//categories/software/
  • 标签页:/tags/article//tags/software/(标签点击时带上同组 category,避免串到另一边)

相册、追番、番组、打赏、音乐等与个人站无关的入口全部关掉,避免空页面。


第六步:友链迁移与入驻说明#

WordPress 里 post_type=links 很多,真正「友情链接」分类只有几条;AI / 网络工具那些不算友链,不要混进 Firefly 的 friendsConfig

最终保留例如:

  • 朱某的生活印记
  • 小栋博客
  • handsome

并在 src/content/spec/friends.mdx 写清:

本站信息

  • 名称:Muxui
  • 介绍:记录个人生活点滴与技术笔记……
  • 地址:https://muxui.com
  • Logo:https://muxui.com/favicon.ico

入驻说明要点

  1. 必须完成国内 ICP 备案
  2. 欢迎个人博客、WordPress 类同类站互换
  3. 首页链接需双方同等待遇
  4. 提交后请联系博主;未通过会在留言板回应
  5. 内容须合法合规
  6. 定期检查死链;对方取消互链则删除且不另行通知
  7. 请先在贵站加好本站,再按模板申请

迁移清单(可直接照抄)#

  • WordPress 导出 WXR,本地备份数据库与 uploads
  • Fork / Clone Firefly,建自己的分支
  • 图床已托管旧路径(或准备批量同步 uploads → 图床)
  • 写迁移脚本:解析 → 转 MD → 改图床 → 写 frontmatter
  • 删除 Demo 文,更新 siteConfig / profileConfig
  • pnpm check / pnpm build 通过
  • 抽查图多文、代码文、外链图
  • 导航、友链、页脚备案、无用页面开关
  • DNS / CDN 切到静态托管(Vercel / Cloudflare Pages 等)
  • 旧链接 301(若 URL 结构变化)

踩坑摘要#

  1. 图片正则过宽
    不要匹配所有站点的 /wp-content/uploads/,只改自己的域名和站内相对路径。

  2. 单分类限制
    Firefly 一篇文章一个 category。WordPress 多分类时要定优先级(例如去掉泛化「文章」后取第一个具体分类),否则导航分组会对不齐。

  3. 中文 slug
    post_name 可能是百分号编码,写入文件前要 decodeURIComponent;Windows 文件名还要避开非法字符。

  4. 短代码不可完美还原
    付费隐藏、下载框等接受降级,比硬解析到构建失败更划算。

  5. 主题功能默认全开
    Firefly Demo 带追番、相册、音乐等,个人站要主动关,不然导航和侧栏很「热闹」。


结果与后续#

迁移完成后:

  • 23 篇正文以 Markdown 落在 Git 里,可版本管理、可 PR 审稿
  • 图片走自有图床,站点本体静态分发
  • 导航按「文章 / 软件」内容域拆分,比 WordPress 默认分类页更符合阅读习惯
  • 友链与入驻规则与旧站对齐

后续还可以做:

  • 旧 permalink → 新 /posts/{slug}/ 的 301 表
  • 关于页、评论系统(Twikoo / Waline 等)对接
  • 把迁移脚本再封装成「指定 WXR 路径即可复跑」的小工具

结语#

WordPress 适合「什么都想装一点」的阶段;当站点目标收敛成 写文章、存笔记、换友链 时,Astro 静态站往往更省心。

这次 Muxui 的路径可以概括成一句话:

WXR 导出 → 脚本转 Markdown → 图床改写 → Firefly 配置本地化。

脚本和配置都在自己的 Git 仓库里,下次换机器或重建环境,只要拉代码、装依赖、构建即可。

如果你也在迁站,欢迎在留言板交流你的导出格式和坑点——尤其是不同主题的短代码,大家踩的都不太一样。

CuteLeaf
/
Firefly
Waiting for api.github.com...
00K
0K
0K
Waiting...

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从 WordPress 到 Astro迁移实战全记录
https://muxui.com/posts/wordpress-to-astro-migration/
作者
Muxui
发布于
2026-07-23
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
YOLO训练麻将(Mahjong)识别,导出ONNX
文章本文基于 Roboflow Universe 数据集(例如 $1 )与仓库内脚本,在 Windows 上完成训练,并导出 ONNX 供后端推理。下文配图均为本机实测截图,便于对照。快速体验 小程序 1\. 环境要求 \ Windows 10
2
自建 Headscale + DERP 全流程实战记录
文章记录一次完整、可上线、可长期运行的 Headscale + 自建 DERP 搭建过程。 本文不是“能跑就行”的教程,而是 生产可用、已多客户端验证 的配置方案。 简介 Tailscale(Headscale)就是组建一个大的局域网,可以将你
3
基于 Cloudflare Workers + Telegram Bot + D1 的双向匿名聊天系统完整实现
文章在本篇文章中,我将分享一个基于 Cloudflare Workers + Telegram Bot + D1 数据库 的完全无服务器(Serverless)聊天系统。该机器人支持用户匿名与管理员双向通信,具备首访验证、自动诈骗检测、屏蔽管理
4
Mac中磁盘没有正常推出的解决方案
文章由于mac电脑关机或者没有实现正常推出硬盘导致的磁盘没有正常推出。 解决办法 : 1)打开终端,输入如下命令查看一下硬盘的信息,确定一下自己的磁盘是否可以显示: diskutil list 在终端的最后一行显示了我硬盘的名字,因此 /dev
5
白嫖 .com .net .jp .shop .xyz 等顶级域名
文章1.$1 日本IP 点开活动地址之后搜索你想要的域名 注意全程日本IP 身份生成:$1 2.付款选择 $1 输入你的卡信息 卡需要支持3ds 3.看到 您的申请已被接受。 来这里 $1 登录你的账户,账号是右上角的数字 。在邮箱里也可以看到
随机文章随机推荐

评论区

Profile Image of the Author
Muxui
Hello, I'm Muxui.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签
站点统计
文章
24
分类
8
标签
19
总字数
53,213
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.14.5
文章许可
CC BY-NC-SA 4.0