首先,进食后人——千万!千万!千万!不要!用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.db 和halo-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,自动重建数据库即可),然后手工恢复页面和文章数据即可。
最后的最后,还是要叮嘱各位,数据安全无小事,一定!一定!一定!要经常备份重要数据、使用可靠的方法存储数据,不然很有可能一个不小心就会让你的心血付诸一旦。
评论