jsonlz4书签备份解压与回压:dejsonlz4与LZ4 block实践
简介:面向需要恢复或分析 Mozilla Firefox 书签备份的开发者与运维人员,这套开源 C 语言工具可处理扩展名为 .jsonlz4 的非标准压缩文件,将其解压还原为可读 JSON,便于迁移书签、排查损坏备份或研究 lz4 在浏览器数据存储中的应用。压缩包共 7 个文件、约 22KB,体量轻巧;其中 3 个 C 源文件承担核心解压逻辑与参考压缩实现,1 个头文件提供接口声明,2 个 Markdown 文档说明编译与使用方式,另附 LICENSE 明确开源授权。目录结构简洁,源码包含解压主程序、lz4 参考实现与压缩测试模块,适合作为命令行小工具直接编译运行,也可作为学习 lz4 解压流程与 Firefox 书签格式的示例。已有 470 人学习下载,适合浏览器数据恢复、格式解析及 C 语言实战参考。
1. 一份 .jsonlz4 备份躺在硬盘里,dejsonlz4 是把它变回人话的第一把钥匙
重装系统前把整个配置目录拷出来,事后想找一条三年前存下的书签,结果备份目录里躺着一排bookmarks-2025-03-01.jsonlz4,双击打不开,记事本里全是mozLz40开头的乱码。用 dejsonlz4 解压这类书签备份文件,是把这团二进制变回可读 JSON 最短的一条路——它只干一件事:剥掉 12 字节文件头,把后面的 LZ4 裸块还原成原始 JSON 文本,不联网、不读取浏览器配置、不改动原始文件。
适合谁:需要从旧备份里抢救书签的人、想做书签去重和归类的整理控、以及要批量迁移几百条书签到另一套工具的开发者。它不适合想「直接导入浏览器」的人——那走浏览器自带的导入入口更快。它适合的是「我要拿到那份清单本身」的场景:查重、清洗、转 CSV、跨工具迁移。开源、单文件、无依赖膨胀,是我愿意把它留在工具箱里的原因。
2. 从文件头拆起:8 字节魔数、4 字节长度和一个 LZ4 裸块
在动手跑工具之前,先花十分钟把格式本身看明白。这一步省不掉,因为后面所有翻车都出在这 12 个字节上:解压报错、长度对不上、回写后浏览器不认,本质上都是对头部字段的误读。格式看得懂,工具对你来说就只是一个更省事的封装;格式看不懂,工具一旦报错你就只能干瞪眼。
2.1 用十六进制把文件头看清楚
拿到一个备份文件,第一件事不是解压,是看头。xxd或者hexdump -C都行,只看前 32 字节就够:
# 只看前 32 字节,确认文件是不是我们以为的那种容器 xxd -l 32 bookmarks-2025-03-01.jsonlz4输出大概长这样:
00000000: 6d6f 7a4c 7a34 3000 1a2b 0000 1f00 0000 mozLz40..+......把这 12 个字节拆开,结构非常固定:
| 偏移 | 长度 | 含义 | 说明 |
|---|---|---|---|
| 0x00 | 8 字节 | 魔数6D 6F 7A 4C 7A 34 30 00 | ASCII 读作mozLz40,最后一个是 NUL 结尾 |
| 0x08 | 4 字节 | 解压后字节数 | uint32小端,上例是0x00002B1A= 11034 |
| 0x0C | 剩余全部 | LZ4 block 数据 | 没有帧头、没有校验和、没有内容长度字段 |
三个细节值得单独强调。第一,偏移 8 处那 4 个字节是解压后的长度,不是压缩后的大小——很多人第一次读会默认成「压缩包体积」,然后在后面算偏移时全错。第二,它是小端序,1a 2b 00 00要读成0x00002B1A,如果你按大端去解,会得到一个几百 MB 的天文数字,紧接着就是内存分配失败。第三,魔数最后那个00是内容的一部分,漏掉它做比较,前 7 字节匹配、第 8 字节不等,判断照样失败。
用file命令看这个文件,它多半只回你一句data——因为它既不是 zip 也不是 gzip,没有任何通用容器特征,系统认不出来很正常。
2.2 为什么lz4 -d一定会翻车
手上装了lz4命令行的人,第一反应基本都是lz4 -d bookmarks.jsonlz4 out.json。这条命令必然失败,报错通常是Unrecognized header或者Error 44 : Unrecognized header : file cannot be decoded。
原因不在文件损坏,在两种格式不是一回事。lz4命令行处理的是 LZ4frame格式,帧头是04 22 4D 18这个固定魔数,后面还跟着帧描述符、块大小标志、可选的内容校验和。而jsonlz4用的是 LZ4block格式——纯压缩数据流,没有魔数、没有边界标记、没有长度自描述,解压端必须事先知道「这段数据解出来有多长」,否则读到哪算完都说不清。
这也解释了为什么头部那 4 字节的长度字段是必需的:block 格式自己不带这个信息,容器得替它记住。在 Python 里对应的是两个不同的模块,用错了照样报错:
lz4.frame:对应 frame 格式,能处理.lz4文件lz4.block:对应 block 格式,处理jsonlz4必须用它,并且要显式传uncompressed_size
所以正确的路子是绕开命令行,直接调 block 解压,或者用专门为此写的小工具。牢记一条边界:任何面向 frame 格式的解压器,都读不了 jsonlz4。
2.3 用 Python 复刻一遍解压,验证你对格式的理解
理解了头部结构,二十行代码就能跑通。这一步的价值不在产出文件,而在验证你的理解是不是对的——如果这版代码解出来的 JSON 能被json.load正常吃下,说明你对 12 字节头的判断没错,后面再用现成工具就心里有底。
import struct import lz4.block MAGIC = b"mozLz40\x00" # 8 字节,结尾的 NUL 不能省 def read_jsonlz4(path): with open(path, "rb") as f: blob = f.read() # 少于 12 字节说明连头都不完整,多半是备份写到一半被中断 if len(blob) < 12: raise ValueError("文件不足 12 字节,备份可能没写完") magic = blob[:8] # "<I" = 小端无符号 32 位,这里必须是小端 size = struct.unpack("<I", blob[8:12])[0] if magic != MAGIC: raise ValueError(f"魔数不匹配:{magic!r}") # block 格式没有自描述长度,必须把 size 喂给解压器 raw = lz4.block.decompress(blob[12:], uncompressed_size=size) if len(raw) != size: raise ValueError(f"长度对不上:头里写 {size},实际解出 {len(raw)}") # 个别备份尾部会带 NUL 填充,直接交给 json.loads 会报 Extra data return raw.decode("utf-8").rstrip("\x00")参数逐个说清楚。uncompressed_size是硬性要求,不传的话lz4.block.decompress会尝试从数据本身猜,猜错就是解压异常或输出被截断。struct.unpack("<I", ...)里的<是小端,换成正解出来的长度会离谱到无法分配内存。最后那句rstrip("\x00")是防御性的——大部分备份没有尾随 NUL,但只要遇到一次,json.loads就会抛Extra data,把你引向完全错误的方向去排查编码问题。
跑一下验证:
text = read_jsonlz4("bookmarks-2025-03-01.jsonlz4") import json tree = json.loads(text) print(tree.get("guid"), len(tree.get("children", [])))能打印出根节点的guid和一级子节点数量,说明整条链路是通的。根节点下面通常固定挂着几个分支:菜单栏、工具栏、其他书签、移动端同步,每个分支自己又是一个typeCode为 2 的容器节点。
3. 编译 dejsonlz4 并跑通:从一条命令到一份能查的 JSON
理解了原理,接下来就是把工具用起来。现成工具相比自己写脚本的优势不在解压本身——解压就那几行——而在批量处理的稳定性和错误提示。备份目录里往往躺着几十个按日期排列的文件,手动一个个喂给脚本容易漏、容易覆盖,工具配合 shell 循环更适合这种活。
3.1 源码编译:一个 C 文件加一个系统库
这类小工具的源码结构通常极简,主体就是一个 C 文件,压缩解压全部委托给系统的 lz4 库。所以编译前要先装开发头文件,Debian 系是liblz4-dev,RedHat 系是lz4-devel,macOS 用brew install lz4。
# 1) 装 lz4 开发库(按你的系统选一条) sudo apt install -y liblz4-dev # Debian / Ubuntu # sudo dnf install -y lz4-devel # Fedora / RHEL # 2) 编译,-llz4 是链接系统库的关键 cc -O2 -o dejsonlz4 dejsonlz4.c -llz4 # 3) 最小可用:输入一个备份,输出一份 JSON ./dejsonlz4 bookmarks-2025-03-01.jsonlz4 bookmarks.json # 4) 不支持输出重定向时可省略第二个参数,结果直接打到标准输出 ./dejsonlz4 bookmarks-2025-03-01.jsonlz4 | head -c 200-O2只是常规优化,对这个体量的程序影响不大,但保留没坏处。-llz4漏掉会得到一堆undefined reference to LZ4_decompress_safe之类的链接错误,这时候不用怀疑源码,先确认库装了没有。如果编译报「找不到 lz4.h」,那是头文件路径问题,装完开发包通常自动解决。
3.2 批量解压整个备份目录
真正有用的场景是整目录处理。浏览器一般会把最近若干份自动备份放在配置目录下的bookmarkbackups子目录里,文件名带日期。一次性全解开,方便做跨时间点比对——比如「上周还有的那条书签,这周怎么没了」。
mkdir -p out for f in bookmarkbackups/*.jsonlz4; do base=$(basename "$f" .jsonlz4) if ! ./dejsonlz4 "$f" "out/$base.json"; then echo "解压失败,跳过:$f" >&2 fi done # 顺手看一眼哪些成功了、体积多大 ls -lh out/*.json | sort -k9这段循环里有两个细节值得保留。basename "$f" .jsonlz4去掉扩展名,避免输出成xxx.jsonlz4.json这种自己都认不出的名字。if !加错误重定向,是为了让单个坏文件不打断整批——备份文件里混进一个写坏的、或者被同步工具截断的,是很常见的事,整批中断反而更浪费时间。
跑完先别急着写解析逻辑,抽一个文件做一次完整性体检:
# JSON 能不能被严格解析,一步就能筛掉大部分坏文件 python3 -c "import json,sys;json.load(open(sys.argv[1],encoding='utf-8'));print('OK')" out/bookmarks-2025-03-01.json3.3 拆 JSON 结构:typeCode、children 和 uri 三件套
打开解压后的 JSON,第一眼会觉得字段很多,但真正决定结构走向的只有三个:typeCode决定节点是什么,children决定往下走不走,uri是叶子节点上的实际地址。其余字段基本是元数据。
| 字段 | 含义 | 使用要点 |
|---|---|---|
typeCode | 1 书签 / 2 文件夹 / 3 分隔线 | 判断类型只认这个,别靠type字符串 |
title | 显示名称 | 文件夹和书签都有,可能是空串 |
uri | 目标地址 | 仅typeCode=1有效,其余节点没有 |
children | 子节点数组 | 仅typeCode=2有效,可能缺省 |
guid | 节点唯一 ID | 回写时不要重新生成 |
dateAdded | 添加时间 | 单位是微秒,除以 1e6 才是秒 |
root | 根分支标识 | 只在最顶层几个节点上出现 |
typeCode和type这两个字段容易混。type是给人看的字符串标识,typeCode是给程序判断的整数,两者理论上一致,但历史备份里出现过type缺失而typeCode完好的情况。稳妥做法是只信typeCode。
递归展平只需要十几行:
def walk(node, path=()): tc = node.get("typeCode") title = node.get("title", "") if tc == 1: # 书签叶子 yield "/".join(path), title, node.get("uri", "") return if tc == 2 and not node.get("root"): path = path + (title,) # 顶层根节点不进路径,否则每条都带一长串前缀 for child in node.get("children") or []: yield from walk(child, path)walk返回三元组:路径、标题、地址。not node.get("root")这个判断是为了让导出的路径干净——根节点本身没有实际语义价值,把它拼进书签工具栏/开发/xxx里只会让后面去重更麻烦。
拿到展平结果,转 CSV 就是顺手的事。用utf-8-sig是为了让表格软件直接认出编码,省掉手动选字符集的步骤:
import csv, json tree = json.load(open("out/bookmarks-2025-03-01.json", encoding="utf-8")) rows = list(walk(tree)) with open("bookmarks.csv", "w", newline="", encoding="utf-8-sig") as f: w = csv.writer(f) w.writerow(["folder", "title", "uri"]) w.writerows(rows) print(f"导出 {len(rows)} 条")到这一步,你手上已经有一份能排序、能筛选、能丢进数据库做去重的清单了。很多人卡在这一步之后就没往下走,其实真正的价值在下一步:改完之后怎么压回去。
4. 改完书签怎么压回去:长度字段、store_size 和三次校验
清理完书签想写回浏览器,或者要把一份整理好的清单做成可导入的备份,就得反向压缩。压缩端比解压端更容易出错,因为解压错了会立刻报错,压缩错了往往要等浏览器打开才会发现——而那时候原始数据可能已经被覆盖了。压缩的正确性完全取决于你对头部字段和store_size参数的控制。
4.1 压缩端的两个参数决定成败
写回文件的动作本身很短,但有两处必须写对。
import json, struct import lz4.block MAGIC = b"mozLz40\x00" def write_jsonlz4(tree, path): # separators 去掉多余空格,能省一点体积,也更接近原始备份的紧凑风格 raw = json.dumps(tree, ensure_ascii=False, separators=(",", ":")).encode("utf-8") # 关键参数:store_size=False # True 会把长度写进压缩块自己的头部,读取端再按外部长度解就会错位 body = lz4.block.compress(raw, mode="high_compression", store_size=False) with open(path, "wb") as f: f.write(MAGIC) f.write(struct.pack("<I", len(raw))) # 这里必须是原始长度,不是 len(body) f.write(body) return len(raw), len(body)第一个参数store_size=False。默认值在某些版本里是True,那样压缩块开头会自带 4 字节长度,读取端如果按「头部 12 字节之后全是 block 数据」去解,就会把这 4 字节当压缩数据的一部分,结果要么报错要么输出错位。容器自己已经在偏移 8 处记了长度,块内再记一次纯属重复,且会破坏兼容性。
第二个易错点是struct.pack("<I", ...)里填什么。必须填len(raw),也就是压缩前的字节数。填len(body)是最常见的错误,压出来的文件魔数正确、体积正常,唯独浏览器读的时候会按错误长度分配缓冲区,结果就是静默失败——书签列表是空的,没有任何报错。
mode="high_compression"是可选项,追求体积可以加,但注意它改变的是压缩率不是格式,不影响可读性。
4.2 长度字段写错的三种表现
不同位置写错,症状完全不同,记住这三条能省掉大量瞎猜:
| 写错的地方 | 表现 | 定位方法 |
|---|---|---|
| 长度填成压缩后大小 | 读取端解压异常或直接崩溃,报 buffer 不足 | 对比头部声明的值和len(body) |
长度用了大端>I | 声明值巨大,分配失败或被拒绝加载 | 打印struct.unpack的原始结果 |
store_size=True但读取端不认 | 解出内容整体偏移若干字节,JSON 解析失败 | 检查压缩块前 4 字节是否像长度值 |
第三种最阴险。它不会立刻炸,只会让你解出来的内容「差一点点」——比如开头多几个不可打印字符。遇到这种症状,先怀疑store_size,别去查编码。
4.3 回写前的三份备份与一次回环校验
写回之前,把原始备份复制一份到别处,这是唯一一条不该省的步骤。我一般的做法是保留三份:原始.jsonlz4、解压出来的.json、以及写回后生成的新文件,文件名带上时间戳区分。
写完立刻做一次回环校验,不要等打开浏览器:
import hashlib, json, struct import lz4.block def roundtrip_digest(path): blob = open(path, "rb").read() assert blob[:8] == b"mozLz40\x00", "魔数不对" size = struct.unpack("<I", blob[8:12])[0] raw = lz4.block.decompress(blob[12:], uncompressed_size=size) return hashlib.sha256(raw).hexdigest() a = roundtrip_digest("bookmarks-2025-03-01.jsonlz4") # 原始文件 b = roundtrip_digest("bookmarks-new.jsonlz4") # 自己压出来的 # 内容未修改时应完全一致;如果改过内容,就比对解出的对象是否等价 print("hash 一致" if a == b else "hash 不同,需确认是否改过内容")如果只是「解压再压回去」没动内容,两个 sha256 应该完全相同——这能同时验证头部长度、压缩参数、序列化格式三件事。如果确实改过内容,就退一步,用json.loads比较两个对象是否相等,能筛掉字段顺序、空白差异这类无关变化。
提示:回写生成的新文件,先放到备份目录之外,用浏览器手动导入验证一次。确认书签能正常显示、层级没错位,再考虑替换配置目录里的文件。
5. 排查清单:解压书签备份最常见的五个坑
以下五个都是我在实际处理备份时反复撞上的,每条按「现象、原因、解决」写清楚。遇到问题先对着这张清单过一遍,大部分情况不用再去翻格式文档。
5.1 报 Unrecognized header,命令行直接拒绝
现象:lz4 -d xxx.jsonlz4 out.json立刻失败,提示Unrecognized header或Error 44。
原因:这是 block 格式,没有 frame 的04 22 4D 18帧头,命令行工具按 frame 去解析,第一步找魔数就失败。
解决:别用lz4命令行。要么用 Python 的lz4.block并显式传uncompressed_size,要么用专门处理这种容器的小工具。顺带记住反向也成立——lz4.block读不了普通.lz4文件。
5.2 魔数对得上,长度字段却离谱
现象:前 8 字节验证通过,紧接着struct.unpack出来的长度是几亿甚至几十亿,内存分配直接失败。
原因:字节序搞反了,用了大端">I"。也可能是文件被同步工具截断,剩下的字节刚好凑成了一个合法但荒谬的数值。
解决:确认用的是<I。再用xxd -l 12手工读一遍偏移 8 到 11 这四个字节,按低位在前的规则手算一次,跟脚本输出对一下。如果手工算出来是正常值而脚本不是,问题一定在格式符号上。
5.3 解压成功,但 json.loads 报 Extra data
现象:解压没报错,json.loads却抛Extra data: line 1 column NNNN,指向的位置通常在文件末尾。
原因:解压结果尾部带了 NUL 填充或多余空白。这类备份大多纯净,但混入个别带尾随 NUL 的文件时,症状很有误导性,容易让人往编码方向排查。
解决:解压后先rstrip("\x00")再解析。更好的习惯是解析前打印最后 16 个字节的十六进制,一眼就能看出是不是填充。
5.4 压回去浏览器不认,书签列表是空的
现象:文件生成成功,体积也正常,但导入或替换后书签是空的,没有任何报错提示。
原因:三种可能——长度字段填了压缩后大小;store_size=True让块内多了 4 字节;JSON 序列化时用了非 UTF-8 编码,内容读出来是乱码导致结构解析失败。其中第一种最常见。
解决:按上一章的回环校验跑一遍,对比原始文件和自造文件解压后的 sha256。不一致就先查struct.pack的入参,再查store_size,最后查encode("utf-8")有没有漏。
5.5 中文标题变问号或方块
现象:导出的 CSV 打开后中文全是乱码,或者 JSON 里中文变成了\uXXXX转义序列。
原因:两种不同的问题被混为一谈。CSV 乱码是编码问题,表格软件默认按本地字符集打开;JSON 里的\uXXXX不是错误,ensure_ascii=True(默认值)本来就会这么输出,只是人看着难受。
解决:CSV 输出用encoding="utf-8-sig",带上 BOM 让表格软件自动识别。JSON 想保留中文原文就传ensure_ascii=False,注意这只影响可读性,不影响解析结果——两种写法json.loads都能正确读回。
注意:如果解压出来的 JSON 里中文本身就是乱码(而不是转义),那说明源文件已经损坏,或者你中间的某一步用了错误的编码去读写。这种情况不要再往下做任何压缩操作,先保住原始备份。
6. 把它串成一条能反复跑的流水线:解压、校验、回压、比对哈希
零散敲命令做过一遍,下次再遇到备份还是得重来。我后来把整条链路收进一个脚本,输入一个.jsonlz4,输出一份 CSV 加一份校验报告,中间任何一步失败就整体退出,不产生半成品。
脚本骨架是这样:
#!/usr/bin/env bash set -euo pipefail SRC=${1:?用法: pipeline.sh 备份文件.jsonlz4} work=$(mktemp -d) trap 'rm -rf "$work"' EXIT python3 - "$SRC" "$work/plain.json" <<'PY' import struct, sys import lz4.block src, dst = sys.argv[1], sys.argv[2] blob = open(src, "rb").read() assert blob[:8] == b"mozLz40\x00", "魔数不匹配" size = struct.unpack("<I", blob[8:12])[0] raw = lz4.block.decompress(blob[12:], uncompressed_size=size) open(dst, "wb").write(raw) print(f"解压 {len(blob)} -> {len(raw)} 字节") PY # 严格解析一次,坏文件在这里就拦住,不往后走 python3 -c "import json,sys;json.load(open(sys.argv[1],encoding='utf-8'));print('JSON 合法')" "$work/plain.json" # 回压并按原始长度解一次,验证长度字段没写错 python3 - "$work/plain.json" "$work/roundtrip.jsonlz4" <<'PY' import hashlib, json, struct, sys import lz4.block raw = open(sys.argv[1], "rb").read() body = lz4.block.compress(raw, mode="high_compression", store_size=False) with open(sys.argv[2], "wb") as f: f.write(b"mozLz40\x00") f.write(struct.pack("<I", len(raw))) f.write(body) blob = open(sys.argv[2], "rb").read() size = struct.unpack("<I", blob[8:12])[0] back = lz4.block.decompress(blob[12:], uncompressed_size=size) print("回环 OK" if hashlib.sha256(back).digest() == hashlib.sha256(raw).digest() else "回环失败") PYset -euo pipefail是这套东西能当流水线用的前提:任何一步非零退出就整体中止,避免拿着半截数据继续往下跑。trap ... EXIT负责清理临时目录,跑一百次也不会在/tmp里留垃圾。中间那段python3 - <<'PY'把参数从命令行透传进去,比写一堆环境变量干净。
校验通过之后再接前面写好的walk和 CSV 导出,整条链路就完整了。实际用下来,这套脚本最有价值的不是省时间,而是把「长度字段不能填成压缩后大小」「store_size必须关掉」这两条硬约束固化成了会自己报警的检查。人会忘,脚本不会。
从那以后我每次动书签备份,都强制先跑一遍「解压 → JSON 解析 → 回压 → 再解压比对哈希」这四步,宁可多花十秒,也不愿再经历一次覆盖完才发现列表是空的。备份这种东西,后悔药是没有的,能提前吃药就别省。希望帮到你。
本文还有配套的精品资源,点击获取