使用 Python 脚本备份 Halo 博客文章

前言

对于使用 Halo 2.x 版本的博主来说,数据备份一直是个核心问题。不同于 Halo 1.x 或 WordPress 等传统博客系统将文章明文存储在 posts 表中,Halo 2.x 采用了一种更现代但对普通用户不太友好的"对象模型"存储方式。

当你打开数据库试图进行人工备份时,你可能会发现找不到文章表,只看到一张巨大的 extensions 表。其实,你的所有文章都序列化后存储在这张表的 data 字段(BLOB 类型)中。

本文将提供一个 Python 自动化脚本,绕过 Halo 后台,直接从 MySQL 数据库的底层 extensions 表中提取数据,并将所有文章批量还原为通用的 Markdown 文件。这不仅是数据容灾的最佳手段,也是迁移到 Hexo、Hugo 或 Typecho 的完美方案。

适用场景

  • Halo 博客无法启动,需要紧急导出文章
  • 计划从 Halo 迁移至静态博客(Hexo/Hugo),需要批量 Markdown 文件
  • 希望将所有文章在本地硬盘保留一份"纯文本"格式的冷备份

准备工作

在运行脚本之前,请确保你具备以下环境:

  1. Python 3.x - 确保本机已安装 Python 环境
  2. MySQL 连接信息 - 你需要知道数据库的 IP 地址、端口、用户名、密码以及 Halo 的数据库名称
  3. 安装依赖库 - 我们需要 pymysql 来连接数据库

打开终端或命令行(CMD/PowerShell),执行以下命令安装依赖:

pip install pymysql

核心脚本

创建一个名为 halo_backup.py 的文件,将下方代码完整复制进去。

该脚本的核心逻辑如下:

  1. 连接 MySQL 数据库
  2. 筛选 extensions 表中 name 字段包含 /registry/content.halo.run/posts/ 的记录(即文章数据)
  3. 读取 data 字段的二进制流(BLOB),并将其解码为 JSON 格式
  4. 解析 JSON,提取标题、正文、发布时间、Slug 等元数据
  5. 生成带有 Front Matter(头部元数据)的 Markdown 文件并保存
import pymysql
import json
import os
import re

# ================= 配置区域 (请修改此处) =================
DB_CONFIG = {
    'host': '127.0.0.1',        # 数据库地址 (本地填 127.0.0.1,远程填服务器IP)
    'port': 3306,               # 数据库端口
    'user': 'root',             # 数据库用户名
    'password': 'your_password',# 数据库密码 (请替换为真实密码)
    'database': 'halo',         # Halo 的数据库名
    'charset': 'utf8mb4'
}

# 备份文件保存的文件夹名称
OUTPUT_DIR = './halo_posts_backup'
# =======================================================

def clean_filename(title):
    """
    清洗文件名:将无法作为文件名的字符替换为下划线
    """
    return re.sub(r'[\\/*?:"<>|]', '_', title)

def export_halo_posts():
    # 1. 创建备份目录
    if not os.path.exists(OUTPUT_DIR):
        os.makedirs(OUTPUT_DIR)
        print(f"[-] 已创建备份目录: {os.path.abspath(OUTPUT_DIR)}")

    print("[-] 正在连接数据库...")
    
    connection = None
    try:
        # 2. 建立数据库连接
        connection = pymysql.connect(**DB_CONFIG)
        
        with connection.cursor() as cursor:
            # 3. 执行查询
            # Halo 2.x 的文章数据存储在 extensions 表中
            # Key 规则为: /registry/content.halo.run/posts/{UUID}
            print("[-] 正在查询文章数据...")
            sql = "SELECT name, data FROM extensions WHERE name LIKE '/registry/content.halo.run/posts/%'"
            cursor.execute(sql)
            results = cursor.fetchall()
            
            total_count = len(results)
            print(f"[-] 数据库中发现 {total_count} 篇文章,准备开始解析...")
            
            success_count = 0
            
            for row in results:
                name_key = row[0]
                blob_data = row[1] # 获取二进制数据 (longblob)
                
                try:
                    if not blob_data:
                        continue
                        
                    # 4. 解码:将二进制数据转为 JSON 字符串
                    json_str = blob_data.decode('utf-8')
                    post_data = json.loads(json_str)
                    
                    # 5. 提取核心字段
                    # Halo 2.x 数据结构通常包含 spec 和 metadata
                    spec = post_data.get('spec', {})
                    metadata = post_data.get('metadata', {})
                    
                    title = spec.get('title', '未命名文章')
                    content = spec.get('content', '')  # 正文 (Markdown源码)
                    create_time = metadata.get('creationTimestamp', '')
                    slug = spec.get('slug', clean_filename(title))
                    
                    # 6. 拼接 Markdown 内容
                    # 使用 Front Matter 格式,通用性最强
                    md_content = f"""---
title: "{title}"
date: {create_time}
slug: {slug}
id: {name_key}
---

{content}
"""
                    # 7. 写入文件
                    file_name = f"{clean_filename(title)}.md"
                    file_path = os.path.join(OUTPUT_DIR, file_name)
                    
                    with open(file_path, 'w', encoding='utf-8') as f:
                        f.write(md_content)
                        
                    print(f"[√] 成功导出: {file_name}")
                    success_count += 1
                    
                except Exception as e:
                    print(f"[x] 解析失败: {name_key} - {str(e)}")

        print(f"\n" + "="*40)
        print(f"备份任务完成!")
        print(f"共扫描: {total_count} 篇")
        print(f"成功导出: {success_count} 篇")
        print(f"文件路径: {os.path.abspath(OUTPUT_DIR)}")
        print(f"="*40)

    except pymysql.MySQLError as e:
        print(f"\n[ERROR] 数据库连接失败: {e}")
        print("请检查脚本顶部的 DB_CONFIG 配置是否正确。")
    finally:
        if connection:
            connection.close()

if __name__ == '__main__':
    export_halo_posts()

使用教程

第一步:配置数据库信息

用文本编辑器(如 VS Code, Notepad++)打开 halo_backup.py,找到代码顶部的 DB_CONFIG 区域:

DB_CONFIG = {
    'host': '127.0.0.1',        
    'port': 3306,             
    'user': 'root',             
    'password': '这里填你的密码', 
    'database': 'halo',         
    'charset': 'utf8mb4'
}

注意事项:

  • 如果你是在服务器上运行脚本(连接本机数据库),host 填 127.0.0.1
  • 如果你是在本地电脑运行脚本连接远程服务器,host 填服务器 IP,并确保服务器防火墙开放了 3306 端口

第二步:运行脚本

在文件所在目录打开命令行,输入:

python halo_backup.py

第三步:查收备份

脚本运行只需几秒钟。运行结束后,你会看到如下提示:

[-] 已创建备份目录: .../halo_posts_backup
[-] 正在连接数据库...
[-] 数据库中发现 25 篇文章,准备开始解析...
[√] 成功导出: Docker部署教程.md
[√] 成功导出: Python学习笔记.md
...
备份任务完成!成功导出 25 篇。

打开脚本目录下的 halo_posts_backup 文件夹,你所有的文章都已经安静地躺在那里了。

原理分析与总结

为什么只用一张 extensions 表就能恢复数据?

Halo 2.0 借鉴了 Kubernetes 的 CRD(自定义资源定义)设计理念。在数据库层面,它不再为每种资源创建独立的表,而是将数据统一存储。

  • Key: /registry/content.halo.run/posts/文章ID
  • Value: 一个完整的 JSON 对象,包含了文章的所有信息

这种设计虽然增加了直接通过 SQL 查询数据的难度,但数据的完整性极高。只要掌握了本文介绍的 JSON 解析方法,数据主权就永远掌握在你手中。无论 Halo 官方未来如何发展,只要保留了数据库文件,你的文字资产就永远不会丢失。


温馨提示: 建议定期运行此脚本进行数据备份,确保你的博客内容安全无忧。