(开始的开始我们都是孩子~ 最后的最后渴望变成天使~)
开始的开始
初中时就想写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,使用Cloudflare的CDN回源
- 后面也迁移过,同时也经历过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回源的
- 突然就开始搞
QQBot和K8s了,应该是为了准备办下一年的比赛,玩了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写的, 说实话写的比较粗糙但是够用
#安装库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可选参数
| 参数 | 说明 | 示例 |
|---|---|---|
| —url | WordPress 站点 URL | https://blog.example.com |
| —user | 用户名 | admin |
| —pass | 密码 | secret |
| —author | 作者名 | John Doe |
| —output | 输出目录 | fuwari-export |
| —no-images | 不下载图片 | - |
导入到 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''
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()