4424 字
22 分钟
从 WordPress 迁移到 Astro (我的成长与变化)

(开始的开始我们都是孩子~ 最后的最后渴望变成天使~)

开始的开始#

初中时就想写Code玩服务器,上大学就开始搞这些东西了。

开始的开始之WordPress#

之前刚接触服务器就跟着网上的教程搭建博客。
网上很多关于wordpress建站建站的教程,确实很适合新手小白
当时也尝试过github的静态页面,当时不懂Git,也不懂NodeJS这种Javascript Runtime,甚至连数组都不会操作,只会 cout<<"hello world"<<endl;
所以就选择了WordPress,在一个1C1G的VPS开始了我的从零开始的WorldPress生活

我与WordPrss的爱恨情仇与成长路径#

  • 其实最开始我连域名都没有直接使用 IP:PORT 裸奔
  • 当时使用的还是宝塔,但其实宝塔没有多长时间就用了4-5个月大概,后边就换成1panel

对了,大家离开自己的电脑一定要锁屏,我当时就被人用自己的Shell上了一个PHP一句话木马,也算是被人近源渗透了QAQ

  • 后面购买了域名发现域名解析到国内服务器的 80 443 端口需要备案
  • 不想备案就更换了国外的VPS,使用CloudflareCDN回源
  • 后面也迁移过,同时也经历过WebServer服务提供商跑路,但是此时一直都是CF HTTP回源
  • 其实这时候还不知道为什么要使用HTTPS、什么是SSL、什么是Cert
  • 其实也使用过一些PHP网页托管的项目,发现免费的就是最贵的,而且那些托管的面板也很难用,硬盘空间给的也很少静态资源大一些的网页需要做静态资源外链,在这之前也自建过图床(后面懒得维护了就直接Delete了)
  • 其实还部署过雷池WAF蜜罐后来觉得,就一个简单的博客没必要就下了
  • 其实换了1panel也算是强制性入门Docker
  • 我记得应该是这个时间段应该是买了一台浪潮NF5280M4+玩客云,也开始了FRP+Tailscale+WireGuard的组合,但是后来发现Frp的效率太低了就把Frp去掉了,主力Tailscale备用WireGuard,后也发现经常Tailscale打不通会走官方通提供的Derp服务器,也自建过Derp服务器,后来发现不如直接FOFA梭哈
  • 简单用了一段时间pve
  • 后来就是用Vue3+SpringBoot3+PostgreSwQL写了一个博客的CMS写了快两个月,写完发现是一坨也没有启用,还是继续用WordPress
  • 我应该是这段时间换成HTTPS回源的
  • 突然就开始搞QQBotK8s了,应该是为了准备办下一年的比赛,玩了Ollama Napcat GZCTF,其实当时看不懂K8s的部署文档和教程然后部署的K3s
  • 我应该是这个时间把pve换成了ESXI7

Half a year later

  • 然后开了几台虚拟机玩了玩MC的Paper服务器
  • 然后在帮朋友(@qianjunasukami)搞K8S集群我部署了Dify、n8n、HelmDashBoard、ArgoCD应该就这些,我记不清了,K8S真的好难,也不太敢上手,虽然K8S的容灾和自我修复能力很强
  • 应该是这段时间,我自己复现训练了一个基于维基18年之前的中文数据的GPT模型,AI东西更是难上加难
  • 还是这段时间,我创建了三个虚拟机搭建了自己的K8S集群(一个Agent+两个Workers),也部署了单节点,对应文章Debain12 部署Kubernetes 1.32.5 单节点 教程
  • 又双叒叕的这段时间,尝试了Cloudint,对应文章Cloud-init ESXi 尝试
  • 在就是这段时间从 WordPress 迁移到 Astro 和将ESXI7更换成了PVE9.1 并且部署了Ceph集群 YakumoRan 在这里感谢陪我走过这些旅途的@qianjunasukami、@YakumoRan、@Yi 和其他无名客,原此行终抵群星。

为什么从 WordPress 迁移到 Astro?#

之所以为什么从WordPress迁移到 Astro 懒得迁移静态资源和数据库,我是个经常换服务器的人,虽然都备份在Cloudflare的R2上,但是每次迁移还是怪麻烦的
WordPress的新编辑器,在Https下而且是CDN回源的情况下有问题,需要手动需改config.php 还是 settings.php ,并且我现在习惯写markdown了
主要是这样就能绿自己的github墙了, 哈哈哈 其实也尝试过朋友使用的Hugo但是go的template语法我真的不会 选择fuwari我只是觉得比较符合我的风格

迁移脚本#

用AI写的, 说实话写的比较粗糙但是够用

Terminal window
#安装库
pip install python-wordpress-xmlrpc python-frontmatter html2text requests
#使用方法
#命令行参数(推荐)
python wp2fuwari.py \
  --url https://your-blog.com \
  --user your-username \
  --pass your-password \
  --author your-name \
  --output fuwari-export

可选参数#

参数说明示例
—urlWordPress 站点 URLhttps://blog.example.com
—user用户名admin
—pass密码secret
—author作者名John Doe
—output输出目录fuwari-export
—no-images不下载图片-
Terminal window
导入到 Fuwari
# 复制文章
cp -r fuwari-export/posts/* fuwari/src/content/posts/
# 复制图片
cp -r fuwari-export/images/* fuwari/public/images/
# 构建项目
cd fuwari
pnpm build

常见问题#

Q: XML-RPC 连接失败?

确保 WordPress 后台设置 > 撰写中已启用 XML-RPC。

Q: 图片下载失败?

检查 failed_images.txt,脚本会自动处理 base64 编码的图片。

Q: 格式转换不完美?

建议先迁移一小部分文章测试,根据结果调整清理规则。

#!/usr/bin/env python3
# wp2fuwari.py - WordPress 迁移到 Fuwari 完整脚本
# ==================== Python 3.10+ 兼容性补丁 ====================
import collections
import collections.abc
# 修复废弃的 collections 导入
for attr in ('Iterable', 'Iterator', 'Mapping', 'MutableMapping',
             'Sequence', 'MutableSequence', 'Callable'):
    if not hasattr(collections, attr):
        setattr(collections, attr, getattr(collections.abc, attr))
# ==================== 正常导入 ====================
from wordpress_xmlrpc import Client
from wordpress_xmlrpc.methods.posts import GetPosts
import frontmatter
import os
import re
import sys
import argparse
from html2text import HTML2Text
from datetime import datetime
import urllib.parse
import requests
import html
# ==================== 配置 ====================
class Config:
    WP_URL = ''  # 例如: https://blog.example.com
    WP_USER = ''
    WP_PASS = ''
    OUTPUT_DIR = 'fuwari-export'
    POSTS_DIR = f'{OUTPUT_DIR}/posts'
    IMAGES_DIR = f'{OUTPUT_DIR}/images'
    # 下载设置
    DOWNLOAD_IMAGES = True
    TIMEOUT = 30
    RETRY = 3
    # 作者名 - 修改这里的默认值
    AUTHOR = 'your-name'  # ← 修改为你想要的默认作者名
# ==================== 工具函数 ====================
def clean_slug(title):
    """生成干净的 URL slug"""
    slug = re.sub(r'[^\w\s-]', '', title).strip().lower()
    slug = re.sub(r'[-\s]+', '-', slug)
    return slug[:50]
def clean_wp_tags(text):
    """清理 WordPress Gutenberg 块标记"""
    # 移除块注释标签
    text = re.sub(r'<--\s*wp:\w+(\s+[^>]*)?\s*-->', '', text)
    text = re.sub(r'<--\s*/wp:\w+\s*-->', '', text)
    # 移除 HTML 注释
    text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL)
    # 移除 WordPress Gutenberg 块类名
    text = re.sub(r'\s*class="wp-block[^"]*"', '', text)
    text = re.sub(r'\s*class="size-[^"]*"', '', text)
    return text
def extract_description(content, excerpt, max_len=150):
    """提取描述 - 去除所有 HTML 标签,返回双引号包裹的文本"""
    # 首先清理 WordPress 块标记
    content = clean_wp_tags(content)
    excerpt = clean_wp_tags(excerpt) if excerpt else excerpt
    def clean_html(text):
        """去除所有 HTML 标签和实体"""
        # 移除 HTML 标签
        text = re.sub(r'<[^>]+>', '', text)
        # 解码 HTML 实体
        text = html.unescape(text)
        # 清理多余空白
        text = re.sub(r'\s+', ' ', text).strip()
        return text
    if excerpt and len(excerpt.strip()) > 10:
        text = clean_html(excerpt)
        desc = text[:max_len] + ('...' if len(text) > max_len else '')
        return desc
    text = clean_html(content)
    text = re.sub(r'[#*`!\[\]\(\)]', '', text)
    desc = text[:max_len] + ('...' if len(text) > max_len else '')
    return desc
def ensure_dirs():
    """创建输出目录"""
    os.makedirs(Config.POSTS_DIR, exist_ok=True)
    os.makedirs(Config.IMAGES_DIR, exist_ok=True)
def init_html2text():
    """配置 HTML2Text"""
    h = HTML2Text()
    h.body_width = 0
    h.ignore_links = False
    h.ignore_images = False
    h.wrap_links = False
    h.ignore_tables = False
    h.skip_internal_links = False
    h.inline_links = True
    h.protect_links = True
    h.wrap_list_items = False
    h.include_sup_sub = True
    return h
# ==================== 图片处理 ====================
class ImageDownloader:
    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
        })
        self.downloaded = set()
        self.failed = []
    def extract_images(self, md_content, post_date):
        """提取并替换图片路径"""
        cover_candidate = ''
        image_map = {}
        def replace_image(match):
            nonlocal cover_candidate
            alt_text = match.group(1)
            img_url = urllib.parse.unquote(match.group(2))
            # 解析文件名
            parsed = urllib.parse.urlparse(img_url)
            orig_filename = os.path.basename(parsed.path) or 'image.jpg'
            # 清理文件名
            clean_name = re.sub(r'[^\w.\-]', '_', orig_filename)
            if '.' not in clean_name:
                clean_name += '.jpg'
            # 添加日期前缀
            date_prefix = post_date.strftime('%Y%m%d')
            new_filename = f'{date_prefix}_{clean_name}'
            new_path = f'/images/{new_filename}'
            # 记录映射: 原始URL -> 本地文件名
            image_map[img_url] = new_filename
            # 设置封面候选
            exts = ['.jpg', '.jpeg', '.png', '.webp', '.gif']
            if not cover_candidate and any(ext in clean_name.lower() for ext in exts):
                cover_candidate = new_path
            return f'![{alt_text}]({new_path})'
        new_content = re.sub(r'!\[(.*?)\]\((.*?)\)', replace_image, md_content)
        return new_content, cover_candidate, image_map
    def download(self, image_map, post_date):
        """下载所有图片"""
        if not Config.DOWNLOAD_IMAGES or not image_map:
            return
        # 从日期生成可能的 WordPress 上传路径
        year = post_date.strftime('%Y')
        month = post_date.strftime('%m')
        date_str = post_date.strftime('%Y%m%d')
        for orig_url, filename in image_map.items():
            if filename in self.downloaded:
                continue
            # 构建可能的 URL 列表
            urls_to_try = []
            # 如果 orig_url 是绝对 URL,优先使用
            if orig_url.startswith('http'):
                urls_to_try.append(orig_url)
            # WordPress 标准上传路径
            base_url = Config.WP_URL.rstrip('/')
            # 移除日期前缀获取原始文件名
            orig_name = filename.replace(f'{date_str}_', '')
            urls_to_try.extend([
                f"{base_url}/wp-content/uploads/{filename}",
                f"{base_url}/wp-content/uploads/{year}/{month}/{orig_name}",
                f"{base_url}/wp-content/uploads/{year}/{orig_name}",
            ])
            # 尝试下载
            saved = False
            for url in urls_to_try:
                try:
                    resp = self.session.get(url, timeout=Config.TIMEOUT)
                    if resp.status_code == 200 and len(resp.content) > 100:
                        filepath = os.path.join(Config.IMAGES_DIR, filename)
                        with open(filepath, 'wb') as f:
                            f.write(resp.content)
                        self.downloaded.add(filename)
                        print(f"      ↓ {filename}")
                        saved = True
                        break
                except Exception:
                    continue
            if not saved:
                self.failed.append((filename, orig_url))
                print(f"      ✗ {filename} (下载失败)")
# ==================== 主导出逻辑 ====================
def export_posts():
    """导出 WordPress 文章"""
    # 验证配置
    if not all([Config.WP_URL, Config.WP_USER, Config.WP_PASS]):
        print("错误: 请配置 WP_URL, WP_USER, WP_PASS")
        print("示例: python wp2fuwari.py --url https://blog.com --user admin --pass secret")
        sys.exit(1)
    ensure_dirs()
    h = init_html2text()
    downloader = ImageDownloader()
    # 连接 WordPress
    print(f"连接 {Config.WP_URL} ...")
    try:
        wp = Client(f"{Config.WP_URL}/xmlrpc.php", Config.WP_USER, Config.WP_PASS)
    except Exception as e:
        print(f"连接失败: {e}")
        sys.exit(1)
    # 获取文章
    print("获取文章列表...")
    posts = wp.call(GetPosts({
        'post_type': 'post',
        'post_status': 'publish',
        'number': 1000
    }))
    print(f"找到 {len(posts)} 篇文章\n")
    # 处理每篇文章
    success = 0
    failed = 0
    for i, post in enumerate(posts, 1):
        try:
            title_display = post.title[:40] if len(post.title) > 40 else post.title
            print(f"[{i}/{len(posts)}] {title_display}...")
            # HTML 转 Markdown
            md_content = h.handle(post.content)
            # 清理 WordPress Gutenberg 块标记
            md_content = re.sub(r'<!--\s*wp:\w+(\s+[^>]*)?\s*-->', '', md_content)
            md_content = re.sub(r'<!--\s*/wp:\w+\s*-->', '', md_content)
            md_content = re.sub(r'class="[^"]*"', '', md_content)  # 移除空 class
            md_content = html.unescape(md_content)  # 解码 HTML 实体
            md_content = re.sub(r'\n{3,}', '\n\n', md_content)  # 清理多余空行
            # 处理图片
            md_content, cover, image_map = downloader.extract_images(md_content, post.date)
            # 构建 frontmatter (Fuwari 格式)
            # 使用 datetime 对象,frontmatter 会输出为无引号的日期格式
            published_date = post.date.replace(tzinfo=None)
            updated_date = (
                post.date_modified.replace(tzinfo=None)
                if hasattr(post, 'date_modified') and post.date_modified
                else post.date.replace(tzinfo=None)
            )
            metadata = {
                'title': post.title.strip(),
                'published': published_date,
                'updated': updated_date,
                'description': extract_description(post.content, post.excerpt),
                'author': Config.AUTHOR,
                'category': post.terms[0].name if post.terms else 'uncategorized',
                'tags': [t.name for t in post.terms if hasattr(t, 'taxonomy') and t.taxonomy == 'post_tag'] or [],
                'cover': cover,
                'draft': False,
            }
            # 创建 Markdown 文件
            md_file = frontmatter.Post(md_content, **metadata)
            slug = clean_slug(post.title)
            filename = f"{post.date.strftime('%Y-%m-%d')}-{slug}.md"
            filepath = os.path.join(Config.POSTS_DIR, filename)
            fm_string = frontmatter.dumps(md_file, allow_unicode=True)
            with open(filepath, 'w', encoding='utf-8') as f:
                f.write(fm_string)
            # 下载图片
            if image_map:
                print(f"    发现 {len(image_map)} 张图片")
                downloader.download(image_map, post.date)
            success += 1
        except Exception as e:
            print(f"    ✗ 失败: {e}")
            failed += 1
    # 统计
    sep_line = '=' * 50
    print(f"\n{sep_line}")
    print("导出完成!")
    print(f"成功: {success} | 失败: {failed}")
    print(f"图片: {len(downloader.downloaded)} 成功, {len(downloader.failed)} 失败")
    print(f"输出: {os.path.abspath(Config.OUTPUT_DIR)}")
    # 写入失败日志
    if downloader.failed:
        log_path = f'{Config.OUTPUT_DIR}/failed_images.txt'
        with open(log_path, 'w', encoding='utf-8') as f:
            for name, url in downloader.failed:
                f.write(f"{name}\t{url}\n")
        print(f"失败列表: {log_path}")
    def process_failed_images():
        """处理失败图片列表中的 base64 编码图片"""
        failed_log_path = f'{Config.OUTPUT_DIR}/failed_images.txt'
        if not os.path.exists(failed_log_path):
            return
        print(f"\n处理失败图片列表中的 base64 图片...")
        with open(failed_log_path, 'r', encoding='utf-8') as f:
            lines = f.readlines()
        processed = []
        failed_again = []
        for line in lines:
            line = line.strip()
            if not line or '\t' not in line:
                continue
            filename, url_or_data = line.split('\t', 1)
            # 检查是否是 base64 编码
            if 'base64,' in url_or_data:
                try:
                    # 提取 base64 数据
                    base64_data = url_or_data.split('base64,')[1]
                    import base64
                    image_data = base64.b64decode(base64_data)
                    # 保存图片
                    filepath = os.path.join(Config.IMAGES_DIR, filename)
                    with open(filepath, 'wb') as f:
                        f.write(image_data)
                    print(f"  ✓ {filename} (base64 decoded)")
                    processed.append(filename)
                except Exception as e:
                    print(f"  ✗ {filename} (base64 decode failed: {e})")
                    failed_again.append(line)
            else:
                # 保留非 base64 的失败记录
                failed_again.append(line)
        # 更新失败列表
        if failed_again:
            with open(failed_log_path, 'w', encoding='utf-8') as f:
                for line in failed_again:
                    f.write(line + '\n')
            print(f"剩余 {len(failed_again)} 个图片仍需手动处理")
        else:
            os.remove(failed_log_path)
            print("所有失败图片已处理完成")
        return processed
        # 在 export_posts() 函数末尾、sys.exit(0) 之前调用
        # 位置:在 "下一步提示" 之后
        # 处理 base64 编码的失败图片
        process_failed_images()
    # 下一步提示
    print(f"\n下一步:")
    print(f"  1. cp -r {Config.POSTS_DIR}/* fuwari/src/content/posts/")
    print(f"  2. cp -r {Config.IMAGES_DIR}/* fuwari/public/images/")
    print("  3. cd fuwari && pnpm build")
# ==================== 命令行入口 ====================
def main():
    parser = argparse.ArgumentParser(description='WordPress 迁移到 Fuwari')
    parser.add_argument('--url', help='WordPress 站点 URL')
    parser.add_argument('--user', help='用户名')
    parser.add_argument('--pass', dest='password', help='密码')
    parser.add_argument('--output', default='fuwari-export', help='输出目录')
    parser.add_argument('--author', default=None, help='作者名 (如果不指定则使用 Config.AUTHOR)')
    parser.add_argument('--no-images', action='store_true', help='不下载图片')
    args = parser.parse_args()
    # 应用命令行参数
    if args.url:
        Config.WP_URL = args.url
    if args.user:
        Config.WP_USER = args.user
    if args.password:
        Config.WP_PASS = args.password
    Config.OUTPUT_DIR = args.output
    Config.POSTS_DIR = f'{args.output}/posts'
    Config.IMAGES_DIR = f'{args.output}/images'
    # 只在命令行指定了作者时才覆盖配置
    if args.author:
        Config.AUTHOR = args.author
    Config.DOWNLOAD_IMAGES = not args.no_images
    export_posts()
if __name__ == '__main__':
    main()

从 WordPress 迁移到 Astro (我的成长与变化)
https://blog.fiveqm.com/archives/wordpresstoastor
作者
inuyume
发布于
2026-02-11
许可协议
CC BY-NC-SA 4.0