
简介面向需要恢复或分析 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 个字节拆开结构非常固定偏移长度含义说明0x008 字节魔数6D 6F 7A 4C 7A 34 30 00ASCII 读作mozLz40最后一个是 NUL 结尾0x084 字节解压后字节数uint32小端上例是0x00002B1A 110340x0C剩余全部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 bmozLz40\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_sizesize) 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-devRedHat 系是lz4-develmacOS 用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],encodingutf-8));print(OK) out/bookmarks-2025-03-01.json3.3 拆 JSON 结构typeCode、children 和 uri 三件套打开解压后的 JSON第一眼会觉得字段很多但真正决定结构走向的只有三个typeCode决定节点是什么children决定往下走不走uri是叶子节点上的实际地址。其余字段基本是元数据。字段含义使用要点typeCode1 书签 / 2 文件夹 / 3 分隔线判断类型只认这个别靠type字符串title显示名称文件夹和书签都有可能是空串uri目标地址仅typeCode1有效其余节点没有children子节点数组仅typeCode2有效可能缺省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, encodingutf-8)) rows list(walk(tree)) with open(bookmarks.csv, w, newline, encodingutf-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 bmozLz40\x00 def write_jsonlz4(tree, path): # separators 去掉多余空格能省一点体积也更接近原始备份的紧凑风格 raw json.dumps(tree, ensure_asciiFalse, separators(,, :)).encode(utf-8) # 关键参数store_sizeFalse # True 会把长度写进压缩块自己的头部读取端再按外部长度解就会错位 body lz4.block.compress(raw, modehigh_compression, store_sizeFalse) 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_sizeFalse。默认值在某些版本里是True那样压缩块开头会自带 4 字节长度读取端如果按「头部 12 字节之后全是 block 数据」去解就会把这 4 字节当压缩数据的一部分结果要么报错要么输出错位。容器自己已经在偏移 8 处记了长度块内再记一次纯属重复且会破坏兼容性。第二个易错点是struct.pack(I, ...)里填什么。必须填len(raw)也就是压缩前的字节数。填len(body)是最常见的错误压出来的文件魔数正确、体积正常唯独浏览器读的时候会按错误长度分配缓冲区结果就是静默失败——书签列表是空的没有任何报错。modehigh_compression是可选项追求体积可以加但注意它改变的是压缩率不是格式不影响可读性。4.2 长度字段写错的三种表现不同位置写错症状完全不同记住这三条能省掉大量瞎猜写错的地方表现定位方法长度填成压缩后大小读取端解压异常或直接崩溃报 buffer 不足对比头部声明的值和len(body)长度用了大端I声明值巨大分配失败或被拒绝加载打印struct.unpack的原始结果store_sizeTrue但读取端不认解出内容整体偏移若干字节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] bmozLz40\x00, 魔数不对 size struct.unpack(I, blob[8:12])[0] raw lz4.block.decompress(blob[12:], uncompressed_sizesize) 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_sizeTrue让块内多了 4 字节JSON 序列化时用了非 UTF-8 编码内容读出来是乱码导致结构解析失败。其中第一种最常见。解决按上一章的回环校验跑一遍对比原始文件和自造文件解压后的 sha256。不一致就先查struct.pack的入参再查store_size最后查encode(utf-8)有没有漏。5.5 中文标题变问号或方块现象导出的 CSV 打开后中文全是乱码或者 JSON 里中文变成了\uXXXX转义序列。原因两种不同的问题被混为一谈。CSV 乱码是编码问题表格软件默认按本地字符集打开JSON 里的\uXXXX不是错误ensure_asciiTrue默认值本来就会这么输出只是人看着难受。解决CSV 输出用encodingutf-8-sig带上 BOM 让表格软件自动识别。JSON 想保留中文原文就传ensure_asciiFalse注意这只影响可读性不影响解析结果——两种写法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] bmozLz40\x00, 魔数不匹配 size struct.unpack(I, blob[8:12])[0] raw lz4.block.decompress(blob[12:], uncompressed_sizesize) open(dst, wb).write(raw) print(f解压 {len(blob)} - {len(raw)} 字节) PY # 严格解析一次坏文件在这里就拦住不往后走 python3 -c import json,sys;json.load(open(sys.argv[1],encodingutf-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, modehigh_compression, store_sizeFalse) with open(sys.argv[2], wb) as f: f.write(bmozLz40\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_sizesize) 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 解析 → 回压 → 再解压比对哈希」这四步宁可多花十秒也不愿再经历一次覆盖完才发现列表是空的。备份这种东西后悔药是没有的能提前吃药就别省。希望帮到你。本文还有配套的精品资源点击获取