loading...
ternaryop8479の小窝

Halo调教日记-脆弱的H2数据库,以及如何从损坏的H2数据库中恢复数据

首先,进食后人——千万!千万!千万!不要!用H2数据库部署长期服务!
轻量方便一时爽,数据丢失火葬场啊(悲)

事情是这样的——

一个风和日丽的下午,勤劳的to师傅像往常一样打开了自己的博客,准备给自己的查杀云写调用文档,却忽然发现自己的博客站报出了500 Internal Server Error无法访问,于是经验老道(并非)的to师傅便按照往常一样处理服务崩溃一样,登录上服务器就是一通sudo reboot ,然而几分钟过后,等来的却不是恢复访问的博客站,而是浏览器的无法访问报错。to师傅打开了docker容器里Halo的日志,却发现——日志报出了H2数据库Reading from file failed at 2071938: EOFException 的错误。于是to师傅的天塌了

又寸,就是重启了一下服务器,然后数据库就崩了。和AI分析了俩小时,我觉得应该是重启服务器的时候Halo还在往H2数据库写入数据库文件,导致重启之后数据库索引混乱,于是所有数据就都死掉了。当然也有可能是我的蜜汁SD卡抽风把某一片索引的数据改掉了,不然也解释不了500 Internal Sever Error是怎么来的。

接下来我又蹬了一下AI,并且尝试着拯救了一下我的数据,结果发现H2数据库官方的数据恢复工具在这种情况下是无济于事的,就算加了--skipSchema参数尝试跳过损坏的表也是不行的,因为这个损坏是数据库索引坏掉了,而不是缺页或者某块存储的数据坏死的问题。

那怎么恢复这些珍贵的文章资源数据呢?好在Halo的文章和页面数据都是以原始字符的形式存储在H2数据库中的,没有任何的压缩,这就使得我们可以通过一个简单的Python脚本,通过字符串匹配以及Json解析的方式,提取出原始的页面和文章数据:

#!/usr/bin/env python3
"""
从损坏的 H2 数据库文件(.mv.db)中恢复 Halo 博客数据。

用法:
    python3 recover_from_h2.py <input_db_file> <output_md_file>

示例:
    python3 recover_from_h2.py halo-next.mv.db recovered_posts.md
"""

import sys
import json
import os
import html2text


def extract_json_objects(raw_bytes: bytes):
    """
    从二进制数据中暴力提取所有完整的 JSON 对象。
    使用 json.JSONDecoder.raw_decode 逐个尝试解析,
    遇到无效字符则跳过,继续向后搜索。
    """
    # 将 bytes 转为 str,忽略无法解码的字节
    text = raw_bytes.decode('utf-8', errors='ignore')
    decoder = json.JSONDecoder()
    objects = []
    idx = 0
    length = len(text)

    while idx < length:
        # 跳过非 JSON 起始字符 '{' 或 '['
        while idx < length and text[idx] not in '{[':
            idx += 1
        if idx >= length:
            break
        try:
            obj, end = decoder.raw_decode(text, idx)
            objects.append(obj)
            idx = end
        except json.JSONDecodeError:
            # 当前起始位置不是有效 JSON,跳过 1 个字符继续尝试
            idx += 1
    return objects


def extract_final_html(spec):
    """
    从快照对象的 spec 中提取最终 HTML 正文。
    处理 contentPatch 或 rawPatch 字段,如果是 diff 列表则取 target.lines 合并。
    """
    patch = spec.get('contentPatch') or spec.get('rawPatch')
    if not patch:
        return ''
    if isinstance(patch, list):
        # diff 格式:取所有 target 的 lines 合并
        lines = []
        for item in patch:
            target = item.get('target', {})
            item_lines = target.get('lines', [])
            if item_lines:
                lines.extend(item_lines)
        return ''.join(lines)
    if isinstance(patch, str):
        return patch
    return ''


def main():
    if len(sys.argv) != 3:
        print("用法: python3 recover_from_h2.py <输入数据库文件> <输出Markdown文件>")
        sys.exit(1)

    input_file = sys.argv[1]
    output_file = sys.argv[2]

    if not os.path.isfile(input_file):
        print(f"错误: 文件 '{input_file}' 不存在")
        sys.exit(1)

    # 读取整个文件(二进制模式)
    with open(input_file, 'rb') as f:
        raw_data = f.read()

    print(f"正在分析 {input_file} ...")
    objs = extract_json_objects(raw_data)
    print(f"提取到 {len(objs)} 个 JSON 对象")

    # 分类存储
    posts = {}
    pages = {}
    snapshots = {}

    for obj in objs:
        kind = obj.get('kind')
        metadata = obj.get('metadata', {})
        name = metadata.get('name')
        if not name:
            continue
        if kind == 'Post':
            posts[name] = obj
        elif kind == 'SinglePage':
            pages[name] = obj
        elif kind == 'Snapshot':
            snapshots[name] = obj

    print(f"找到文章 {len(posts)} 篇,自定义页面 {len(pages)} 个,快照 {len(snapshots)} 个")

    if not posts and not pages:
        print("警告: 未找到任何文章或页面数据")
        sys.exit(0)

    # 配置 html2text
    h = html2text.HTML2Text()
    h.body_width = 0
    h.ignore_links = False
    h.ignore_images = False
    h.ignore_emphasis = False
    h.ignore_tables = False
    h.mark_code = True
    h.ul_item_mark = '-'
    h.emphasis_mark = '*'
    h.strong_mark = '**'
    h.single_line_break = False
    h.bypass_tables = False

    # 生成 Markdown 报告
    with open(output_file, 'w', encoding='utf-8') as out:
        out.write("# 博客恢复清单\n\n")
        out.write("从损坏的 H2 数据库中提取的文章和自定义页面。\n\n")

        if posts:
            out.write("## 文章列表\n\n")
            for name, post in posts.items():
                spec = post.get('spec', {})
                title = spec.get('title', '无标题')
                slug = spec.get('slug', '')
                categories = spec.get('categories', [])
                tags = spec.get('tags', [])
                publish_time = spec.get('publishTime', '')
                published = spec.get('publish', False)

                snapshot_name = spec.get('releaseSnapshot') or spec.get('headSnapshot')
                html_content = ''
                if snapshot_name and snapshot_name in snapshots:
                    snap_spec = snapshots[snapshot_name].get('spec', {})
                    html_content = extract_final_html(snap_spec)

                md_content = h.handle(html_content) if html_content else '*正文缺失*'

                out.write(f"### 文章: {title}\n\n")
                out.write(f"- **Slug**: `{slug}`\n")
                out.write(f"- **状态**: {'已发布' if published else '草稿'}\n")
                if publish_time:
                    out.write(f"- **发布时间**: {publish_time}\n")
                if categories:
                    out.write(f"- **分类**: {', '.join(categories)}\n")
                if tags:
                    out.write(f"- **标签**: {', '.join(tags)}\n")
                out.write("\n#### 正文\n\n")
                out.write(md_content.strip() + "\n\n")
                out.write("---\n\n")

        if pages:
            out.write("## 自定义页面列表\n\n")
            for name, page in pages.items():
                spec = page.get('spec', {})
                title = spec.get('title', '无标题')
                slug = spec.get('slug', '')
                publish_time = spec.get('publishTime', '')
                published = spec.get('publish', False)

                snapshot_name = spec.get('releaseSnapshot') or spec.get('headSnapshot')
                html_content = ''
                if snapshot_name and snapshot_name in snapshots:
                    snap_spec = snapshots[snapshot_name].get('spec', {})
                    html_content = extract_final_html(snap_spec)

                md_content = h.handle(html_content) if html_content else '*正文缺失*'

                out.write(f"### 页面: {title}\n\n")
                out.write(f"- **Slug**: `{slug}`\n")
                out.write(f"- **状态**: {'已发布' if published else '草稿'}\n")
                if publish_time:
                    out.write(f"- **发布时间**: {publish_time}\n")
                out.write("\n#### 正文\n\n")
                out.write(md_content.strip() + "\n\n")
                out.write("---\n\n")

        out.write("## 说明\n\n")
        out.write("本文档由数据恢复脚本自动生成。\n")
        out.write("请检查正文内容,纯文本解析可能无法准确提取出正文的格式信息,需要人工校验。\n")

    print(f"恢复清单已生成: {output_file}")


if __name__ == '__main__':
    main()

在执行这个Python脚本之前,需要先安装依赖:

pip install html2text

如果提示error: externally-managed-environment 的话,可以临时加--break-system-packages参数跑完脚本,然后再用pip uninstall html2text --break-system-packages卸载。当然你直接用pipx也是可以的。

这个脚本的用法非常简单,直接python3 [脚本名].py [你的H2数据库文件] [你要把恢复出的数据导出到的位置,是一个Markdown文本文件]即可,比如python3 recover.py ~/.halo2/db/halo-next.mv.db ./recovered_data.md

这里需要讲解的一点是,Halo的H2数据库数据一般存储在Halo安装目录下的db文件夹中,这个文件夹里会有一个或者两个文件,叫做halo-next.mv.dbhalo-next.trace.db ,第一个.mv.db文件,就是你的数据实际存储的文件,这个就是要传入到python脚本的参数里的文件;第二个.trace.db文件,顾名思义,就是你的数据库运行过程中的日志文件,没有什么大用,里面也没有你的博客数据,所以不用管它。

在python脚本执行完成之后,你指定的位置将会多出来一个Markdown文件,这里面就包含了脚本恢复出来的页面和博客数据,格式如下:

# 博客恢复清单

从损坏的 H2 数据库中提取的文章和自定义页面。

## 文章列表

### 文章: [Title]

- **Slug**: [博客索引]
- **状态**: 已发布/草稿
- **发布时间**: [博客里写的发布时间,注意不是最近一次的修改时间]
- **分类**: (如果有分类的话就会有这一栏,没有分类就没有这一栏)
- **标签**: (如果有标签就是tag号,没有的话就是文章地址)

#### 正文

(文章数据,如果说你的文章是已发布且无未发布草稿,或者未发布的草稿,那么正文就完全是你正文的Markdown数据,如果说博客已发布,且有未发布草稿的时候,文章数据格式在下一个示例)

---


### 文章: [Title2]

- **Slug**: [博客索引]
- **状态**: 已发布/草稿
- **发布时间**: [博客里写的发布时间,注意不是最近一次的修改时间]
- **分类**: (如果有分类的话就会有这一栏,没有分类就没有这一栏)
- **标签**: (如果有标签就是tag号,没有的话就是文章地址)

#### 正文

[{"source":{"position":0,"lines":["

(已发布的正文内容)

"]},"target":{"position":0,"lines":["

(未发布的草稿内容)

"]},"type":"CHANGE"}]

---

(...)

## 自定义页面列表

### 页面: [Title]

- **Slug**: (和文章部分相同)
- **状态**: (和文章部分相同)
- **发布时间**: (和文章部分相同)
- **分类**: (和文章部分相同)
- **标签**: (和文章部分相同)

#### 正文

(和文章部分相同,也是分页面已发布but存在草稿,以及未发布和已发布且无草稿两种情况)

---

## 说明

本文档由 recover_from_h2.py 自动生成。
请检查正文内容,如有缺失可尝试手动从原始数据库中提取。

然后根据这个Markdown文档,将Halo的默认数据库修改成其他数据库(最好这样做,否则下次可能还会接着丢数据,如果真的没必要换的话,备份一下db/文件夹,然后删掉db/文件夹之后重启Halo,自动重建数据库即可),然后手工恢复页面和文章数据即可。

最后的最后,还是要叮嘱各位,数据安全无小事,一定!一定!一定!要经常备份重要数据、使用可靠的方法存储数据,不然很有可能一个不小心就会让你的心血付诸一旦。

评论