从 WordPress 到 Astro迁移实战全记录
这篇文章记录了把 Muxui 从 WordPress(B2 Pro) 迁到 Astro + Firefly 的完整过程。不是「理论科普」,而是一次可复现的实操:文章怎么导、图片怎么改、导航怎么拆、友链怎么迁、踩了哪些坑。
如果你也在考虑离开 WordPress,或者正在把内容搬到 Astro / Fuwari / Firefly 一类静态主题,可以直接对照本文的步骤做。
为什么离开 WordPress
WordPress 很强,但对我这种以内容为主、不需要商城和复杂会员体系的个人站来说,日常成本越来越明显:
- 性能与安全:PHP + 数据库 + 插件栈,更新、备份、防篡改都要花精力
- 主题耦合深:B2 Pro 功能丰富,但短代码、付费隐藏、圈子等和主题绑死,换主题等于重做
- 静态化红利:Astro 构建后是纯静态(或边缘静态),CDN 分发简单,运维接近零
最终选择 Firefly——基于 Astro 的个人博客主题,Markdown / MDX 写作,配置全在 TypeScript 文件里,改起来清晰。
目标架构一览
| 项目 | 迁移前 | 迁移后 |
|---|---|---|
| 运行时 | WordPress + PHP + MySQL | Astro 静态构建 |
| 主题 | B2 Pro | Firefly(fork 自用) |
| 内容 | 后台编辑器 / Gutenberg | src/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/ # 迁过来的文章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: falsecomment: trueslug: "${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();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, };}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 脚本一次性完成:
- 用
fast-xml-parser解析 WXR - 建立
attachment_id → URL索引(封面_thumbnail_id) - 过滤
post_type=post且status=publish - Gutenberg HTML → Markdown(
turndown) - 改写图片域名
- 写出 Firefly frontmatter +
.md文件 - 更新
siteConfig.ts,删掉主题自带 Demo 文
核心映射关系:
| WordPress | Firefly |
|---|---|
| title | title |
| post_name(URL 解码) | 文件名 / slug |
| post_date | published |
| post_modified | updated |
| category | category(优先具体分类,弱化泛化的「文章」) |
| post_tag | tags |
| excerpt / 正文截取 | description |
_thumbnail_id → attachment | image(绝对 URL) |
| content | Markdown 正文 |
图片统一规则:
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/...这种拼接错乱——我第一次写正则时就踩过。
运行方式:
pnpm add -D turndown fast-xml-parsernode scripts/migrate-wordpress.mjspnpm check第三步:处理 B2 / Gutenberg 的「脏内容」
纯 Markdown 博客迁出去通常很干净;B2 + Gutenberg 会带上:
- 区块注释:
<!-- wp:paragraph --> - 短代码:隐藏内容、下载框、卡片等
- 偶发残缺 HTML、外链图片
策略是 可读优先,不追求像素级还原:
- Turndown 处理常见标题、列表、链接、代码块、表格
[hide]...[/hide]之类尽量展开正文- 无法可靠还原的短代码,降级成链接或直接丢掉,保证构建不炸
迁完后务必抽查:
- 图多的文章(封面 + 正文图是否都是
muxui.com/uploads) - 代码长文(Markdown 代码围栏是否完整)
- 带外链配图的文章(外站 URL 是否被误伤)
第四步:站点身份与页脚
siteConfig.ts 里至少改这些:
title/navbar.title→ Muxuisubtitle/description→ 「记录美好生活!」site_url→https://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 支持多categoryOR 筛选) - 分类页:
/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
入驻说明要点
- 必须完成国内 ICP 备案
- 欢迎个人博客、WordPress 类同类站互换
- 首页链接需双方同等待遇
- 提交后请联系博主;未通过会在留言板回应
- 内容须合法合规
- 定期检查死链;对方取消互链则删除且不另行通知
- 请先在贵站加好本站,再按模板申请
迁移清单(可直接照抄)
- WordPress 导出 WXR,本地备份数据库与
uploads - Fork / Clone Firefly,建自己的分支
- 图床已托管旧路径(或准备批量同步
uploads→ 图床) - 写迁移脚本:解析 → 转 MD → 改图床 → 写 frontmatter
- 删除 Demo 文,更新
siteConfig/profileConfig -
pnpm check/pnpm build通过 - 抽查图多文、代码文、外链图
- 导航、友链、页脚备案、无用页面开关
- DNS / CDN 切到静态托管(Vercel / Cloudflare Pages 等)
- 旧链接 301(若 URL 结构变化)
踩坑摘要
-
图片正则过宽
不要匹配所有站点的/wp-content/uploads/,只改自己的域名和站内相对路径。 -
单分类限制
Firefly 一篇文章一个category。WordPress 多分类时要定优先级(例如去掉泛化「文章」后取第一个具体分类),否则导航分组会对不齐。 -
中文 slug
post_name可能是百分号编码,写入文件前要decodeURIComponent;Windows 文件名还要避开非法字符。 -
短代码不可完美还原
付费隐藏、下载框等接受降级,比硬解析到构建失败更划算。 -
主题功能默认全开
Firefly Demo 带追番、相册、音乐等,个人站要主动关,不然导航和侧栏很「热闹」。
结果与后续
迁移完成后:
- 23 篇正文以 Markdown 落在 Git 里,可版本管理、可 PR 审稿
- 图片走自有图床,站点本体静态分发
- 导航按「文章 / 软件」内容域拆分,比 WordPress 默认分类页更符合阅读习惯
- 友链与入驻规则与旧站对齐
后续还可以做:
- 旧 permalink → 新
/posts/{slug}/的 301 表 - 关于页、评论系统(Twikoo / Waline 等)对接
- 把迁移脚本再封装成「指定 WXR 路径即可复跑」的小工具
结语
WordPress 适合「什么都想装一点」的阶段;当站点目标收敛成 写文章、存笔记、换友链 时,Astro 静态站往往更省心。
这次 Muxui 的路径可以概括成一句话:
WXR 导出 → 脚本转 Markdown → 图床改写 → Firefly 配置本地化。
脚本和配置都在自己的 Git 仓库里,下次换机器或重建环境,只要拉代码、装依赖、构建即可。
如果你也在迁站,欢迎在留言板交流你的导出格式和坑点——尤其是不同主题的短代码,大家踩的都不太一样。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!













