把 QQ 聊天记录完整导出归档:三轮并入、一次翻车和重新算账
我要的效果
聊天记录用得越久越值钱,也越容易丢:在线漫游有窗口期,换手机就断档,Windows 端重装之后只剩一堆打不开的 .db。所以从头就把目标定死:
- 一处存全——所有会话落在本地一份 JSON 归档里,一个会话一个文件;
- 条数对得上——每并入一批数据,都能回答「新增多少、有没有丢、有没有重」;
- 能回滚——每次合并前留快照,出问题整批退回去;
- 能验证——口径写死、独立复算,不靠「我觉得应该没问题」。
最后的规模是 861 个会话文件、1,270.8 万条消息。它不是一次做出来的,是四轮采集叠出来的,中间还翻过一次车。
时间线总览
| 阶段 | 做了什么 | 净增量 | 结果 |
|---|---|---|---|
| ① 初始导出 | 官方导出管线(QCE)出的归档 | — | 私聊 43 万条,月份残缺(另有群聊存量约 620 万) |
| ② 内核翻页 | 直连 QQ 内核 API 反向翻页 | +110,818 | 私聊 54.2 万条,缺月补齐 |
| ③ Windows 备份库 | SQLCipher 离线解密后合并 | +1,353,314 | 809.6 万条 |
| ④ 手机本地库(首战) | 解密、导出、合并 | 声称 +926 万 | 翻车,回滚 |
| ⑤ 全库去重 | 按语义 key 清理重复 | −648,310 | 账面好看,但埋了坑 |
| ⑥ 回滚 | 整批退回手机并入前 | 回到 809.6 万 | 干净基线 |
| ⑦ 重算再并入 | 规范化文本匹配 | +4,612,067 | 1,270.8 万条 |
| ⑧ 校验 | 两个独立程序复算 | — | 账目闭合 |
第六步那 809.6 万是分水岭:回滚之后的每一笔,都能被独立复算出来。
环境
Arch Linux + Hyprland(Omarchy)
Python 3.13
QQ NT:Windows 端备份库 + Android 端本地库
一台已 root 的 Android 手机(adb)归档结构固定成两层,任何脚本都能按固定位置扫:
archive/YYYY-MM-DD/
├── 好友/<uin>_<昵称>/friend_<昵称>_<uin>_<导出时间>.json
│ ├── 聊天记录.html # 单会话浏览页(内嵌图片)
│ └── QCE完整附件导出/ # 该会话的附件实体
└── 群聊/<群号>_<群名>/group_<群名>_<群号>_<导出时间>_<群号>.json每个 JSON 是同一种壳:
{
"chatInfo": { "type": "private", "selfUid": "u_xxx", "selfUin": "...", "peerUin": "..." },
"messages": [
{
"id": "7000000000000000001",
"seq": "77459",
"timestamp": 1790000000000,
"sender": { "uid": "u_xxx", "uin": "...", "name": "...", "groupCard": "..." },
"type": "text",
"content": { "text": "...", "html": "", "elements": [], "resources": [] },
"recalled": false,
"system": false
}
],
"statistics": {},
"metadata": {}
}三个字段先记住:
id:消息唯一键,字符串大整数。归档里字段名是id,不是msgId——第一版脚本在这上面栽过。seq:同一会话内单调递增的序号,删除不回收,所以「跨度 − 实有 = 缺失量上界」。timestamp:epoch 毫秒。别用time字段,新旧格式混着+08:00和Z。
一、库长什么样
QQ NT 的本地消息库是 SQLCipher 加密的 SQLite。有用的参数只有几个:
页大小 4096
KDF PBKDF2-HMAC-SHA512, 4000 轮
页布局 reserve 区在页尾,IV 在 reserve 区开头
Windows: reserve 80, IV @ 4016
Android: reserve 48, IV @ 4048
密钥 Windows:从进程内存里找;Android:可派生Android 侧的密钥能直接算出来,比在内存里翻稳得多:
key = md5(uid_hash + rand) # rand = 库头 "QQ_NT DB" 标记之后 8 字节解密按页做:每页 4096 字节里前 4016(Windows)/ 4048(Android)是密文,剩下是 HMAC-SHA1 的 MAC。全部解完先跑 PRAGMA quick_check,ok 再往下走。
二、三条取数通路,只有一条能用
刚开始以为「官方有导出接口,调就完了」,踩了才知道有三条路,差别巨大。
① 官方导出管线(QCE HTTP API)
/api/messages/export、/fetch、/roaming/* 这一套。不要用于全量导出。它内部是「日历逐日 + msgSeq 锚点」扫描:对每天只取一个锚点再向下扩展,扩展一旦遇到 non_monotonic_anchor_sequence 就中止。结果就是某些月份只剩真实量的 1/5,最惨的一天只有 1 条。
第一版归档(43 万条)就是这条管线出的,看起来规整,实际千疮百孔:月份缺失、某些天只有一两条、附件和消息对不上。
② QQ 内核桥(首选)
NapCat-QCE 把内核 API 暴露成本地 RPC(127.0.0.1:40654/rpc,无鉴权、仅本机)。关键是这个:
MsgApi.getMsgHistory(peer, msgId, count, reverse=True)客户端往上滚聊天记录用的就是它,所以历史深度不受漫游窗口限制。配套还有:
Core.selfInfo
MsgApi.getAioFirstViewLatestMsgs(peer, count) # 最新 N 条
MsgApi.getMsgsBySeqAndCount(peer, seq, cnt, ...) # 按 seq 取
peer = {"chatType":1, "peerUid":"u_xxx", "guildId":""} # 群聊 chatType=2两个实操细节:count=100 时大约 440 条/秒;内核是异步补拉的,首次调用可能返回空,要短睡眠重试 3–4 次,别一次空就判到底。
③ 直读 nt_msg.db
不可行——加密,而且列名是纯数字,表结构也不稳定。真正需要直读的场景只有手机端,见第五节。
三、第一轮:内核翻页补全(+110,818)
用 getMsgHistory 从最新一条开始反向翻页,写到 bridge_fetch/<uid>.jsonl 作为原始数据,再统一转成归档格式合并。
这一轮的重点不是拿数据,是证明数据全了:用 seq 算覆盖率。
seq 单调递增、删除不回收 → 会话内 seq 跨度 − 实有条数 = 缺失量上界
seq = 0 是占位符,必须先剔除,否则覆盖率会算出 >1结果(私聊):43 万 → 54.2 万条,+110,818,原先为 0 的月份(如 2021-08、2021-09)补回来了。用 seq 推算的累计缺口 21,494 条,分布在 345 天,占私聊总量 3.8%——这部分绝大多数是撤回/删除的消息,任何客户端都看不到。
加上此前导出的群聊存量约 620 万条,这时全库是 674.3 万——后面两次并入都是在这个基数上做加法。
四、第二轮:Windows 备份库(+1,353,314)
Windows 端的库不能直接拷:服务在跑的时候文件锁着,密钥只存在进程内存里。流程是「先复制库文件 + WAL,再从内存镜像定位 key,最后离线解密」。
合并策略很简单:id 不存在就插入。加进来 1,353,314 条,涉及 285 个会话,其中 158 个是归档里原本没有的会话(整段关系第一次出现)。
当时觉得挺稳——id 是主键语义,能有什么问题?后来证明这是整条链上最大的认知错误:id 只能防「同一条消息被写两次」,防不了「同一条消息有两个不同的 id」。
这一轮的口径缺陷一直潜伏到手机并入时才爆出来。
五、第三轮:手机本地库(13 GB,还带一个锁页坑)
手机侧数据比 Windows 全得多,因为本地库会保留漫游已经不再下发的老消息。
adb shell su -c 'cp /data/data/com.tencent.mobileqq/databases/nt_msg.db /sdcard/'
adb pull /sdcard/nt_msg.db # 13 GB解密时撞上一个不常见的状况:
sqlite 的 pending-byte 页(lock page)落在 262145 号
父页 259696 引用了它库超过 1 GiB 之后,SQLite 会把锁页从第 2 页挪到「262144 页之后」的规范位置,而这份库的锁页内容还停在原位。直接按页解会得到一堆错页。处理办法是把密文层整体迁到 262144 号,重算这一页的 HMAC-SHA1,再解:
ct = read_page(src, 262145) # 锁页密文
write_page(dst, 262144, ct) # 迁到规范位置
mac = hmac_sha1(key_mac, page_no + ct) # 重算 MAC第一次跑还挂了一次:流式解密时漏了 seek(1024),所有页整体错位,解出来的文本全是乱码。修好之后 quick_check=ok,13 GB 全部可读。
六、翻车:926 万条进来,一半是重复
手机库解出来,导出成每会话一份 JSON,然后直接合并——按 id 去重,新增 926 万条(群 896 万 / 私聊 30 万),516 个既有会话 + 41 个新会话。
数字好得不像话。但抽样一看就露馅了:同一段对话里,每条消息出现两次,一条是「本地副本」,一条是「漫游副本」,id 完全不同,seq 都是 0。
按 id 去重根本防不住这个。 更麻烦的是,这 926 万里有相当一部分压根不是新内容,而是归档里已经有的消息换了个编码。
先做了一次全库语义去重
第一反应是「就地清一遍」。用 key (timestamp, sender, content) 扫全部 1,289 个文件、2,074 万条,移除 648,310 条(私聊 58,459 / 群聊 589,851),改写 521 个文件,被改的文件先快照。
清完账面上好看多了:相对于手机并入前,净增 863 万。但这次去重有两个问题:
content整体 JSON 当 key 是错的。同一个表情,PC 导出成{"text":"[捂脸]","resources":[]},手机导出成{"text":"[表情:[[捂脸]]]","elements":[...]},JSON 一比就不相等,于是「同一条消息」被判成两条。这一轮只清掉了「编码完全一致」的那部分重复。- 顺手扫了不该扫的东西。会话目录下面还有
QCE完整附件导出/,里面 116 个文件是同一批消息的「带附件快照」。去重把它们也改了,13,175 条被移除。这些不属于归档本体,后来从快照恢复原样。
净增 863 万这个数字,是「重复清了一半 + 口径没对齐」的产物,不能拿去交差。
七、回滚:退回去,重新开始
决定不再就地打补丁,整批退回手机并入前:
516 个被改写文件 ← .bak-手机合并前(逐文件 md5 对齐,516/516 一致)
41 个新增会话文件 → 隔离区(移动,不删除)
结果:归档精确回到 8,096,232 条回滚这一步比想象中重要:它把「半对半错」的状态清零,后面每一笔账都能从干净基线重算。备份链也从这时起变成三级:
.bak-合并前-<日期> # Windows 备份库合并前
.bak-手机合并前-<日期> # 回滚点
.bak-手机增量补并-<日期> # 本次只增不改前的快照八、重算:什么才算「同一条消息」
这是整件事真正的难点。三次迭代,每次都是被数据打脸之后改的。
第一版:按 id —— 失败
手机库里同一条逻辑消息存两行:本地副本 + 漫游副本,id 不同、seq 都是 0。
第二版:按 content 整体 JSON —— 失败
导出器编码不同就判成两条。更隐蔽的是图片:PC 端会把占位符拼进文本尾巴(那你把文件发我吧[图片:8F3A...C1][D2SF...9K.png]),手机端是两条独立消息(文本一条、图片一条)。按 JSON 或按原始文本比,全都对不上。
第三版:规范化文本 + 时间容差 —— 可用
key 定成 (timestamp, 发送者, 规范化文本),规范化做四件事:
def canon(m):
# 1) 优先用 elements 重建文本:text 取正文,face 还原成 [表情名],reply 丢弃
parts = []
for e in m["content"].get("elements", []):
if e["type"] == "text":
parts.append(e["data"].get("text", ""))
elif e["type"] == "face":
parts.append("[" + e["data"].get("name", "").lstrip("/") + "]")
t = "".join(parts).strip() or m["content"].get("text", "")
# 2) 剥掉回复引文前缀:[回复 引用的内容]正文 / [回复消息]\n正文
t = re.sub(r"^\[回复[^\]]*\]", "", t)
t = re.sub(r"^\[(?:回复消息|回复)\]\s*\n?", "", t)
# 3) 表情写法归一:[表情:[[捂脸]]] → [捂脸]
t = re.sub(r"\[表情:\[+([^\[\]]+)\]+", r"[\1]", t)
# 4) 去掉所有空白
return re.sub(r"\s+", "", t)光有规范化还不够:时间戳会差 1 秒。 同一条消息,手机端 19:30:13、归档里 19:30:12,两条管线的毫秒→秒取整规则不同。不加容差匹配率直接掉一半,所以给 ±2 秒窗口。
匹配是一把梯子,从最严到最松:
| 顺序 | 判据 | 解决什么 |
|---|---|---|
| 1 | 规范化文本相同,时间差 ≤2s | 绝大多数 |
| 2 | 归档文本尾部被拼了 token → 剥掉尾部 [...] 后相同 | PC 把图片/表情拼进文本 |
| 3 | 媒体用 md5 / 文件名 / 文本内 [图片:xxx] token | 图片、文件、语音 |
| 4 | 卡片类(json / forward / ark)同秒同人互认 | [JSON消息] ↔ [聊天记录:N条] |
| 5 | 纯占位符同类型互认 | [图片] 撞 [图片] |
再加两条规则:
- 系统消息不收:
sender.uid为空/未知、或sender.name是「系统消息」的一律丢弃(手机侧约 68 万条,归档侧本来也没存); - 只增不改:匹配上的丢弃,匹配不上的追加,最后整文件按
timestamp稳定排序。
结果
517 个会话重跑一遍:申请入 835 万条(不含系统消息 68 万),命中已有 304 万,真增量 4,632,326 条,再剔掉残余内部重复 19,752 条和系统残留 507 条,实际并入 4,612,067 条。
对比一下:第一版声称 926 万,真实增量 461 万——约一半是重复。
九、并入怎么落盘
只增不改听着简单,工程上有两个坑:
# 坑①:nohup 在沙箱里活不过一个工具调用
# 后台跑合并,跑了 62 个会话就被回收 —— 改成断点续跑
done = {row[0] for row in csv.reader(open(log, 'a+'))} # 已处理的跳过
# 坑②:直接覆盖写,中断就是半个 JSON
tmp = path + '.tmpmerge'
json.dump(doc, open(tmp, 'w'), ensure_ascii=False)
os.replace(tmp, path) # 原子替换再加 6 进程并行,454 个文件 46 秒跑完。每个被改写的文件旁边留一份 .bak-手机增量补并-<日期>。
十、校验:两个独立程序对着算
自己算完自己签字没有意义。所以写了两个互不通气的校验器,各跑一遍全库。
账目(逐文件、逐行、逐 id):
| 项目 | 声称 | 实测 |
|---|---|---|
| 全库总量 | 12,708,299 | 12,708,299 ✅ |
| 并入前基线 | 8,096,232 | 8,096,232 ✅ |
| 逐文件增量合计 | 4,612,067 | 4,612,067 ✅ |
| 旧消息丢失 | 0 | 0 ✅ |
| 系统消息混入 | 0 | 0 ✅ |
重复(同人、同秒、同规范化文本):
| 项目 | 结果 |
|---|---|
文件内重复 id | 0 |
| 语义重复多余副本 | 34,353 条(0.27%) |
| 其中并入前就已存在 | 33,767 条 |
| 本次并入引入 | 586 条(0.0127%) |
结论很清楚:这次并入的 461 万条里只有 586 条撞了已有内容;剩下 3.3 万条重复是老账——第二轮 Windows 合并按 id 去重留下的,跟这次无关。
顺带发现一个坑:校验器一开始用 grep -c '"uid": "未知"' 数系统消息,结果多出 1,378 条。追下去才发现那些字样出现在转发消息 content 的内嵌 JSON 正文里,不是真的 sender 字段。必须解析成对象再数。
十一、附件与浏览页
消息能看了,图片全显示不出来。原因有三层:
- 浏览页把图片渲染成
[图片:文件名]纯文本; - 部分消息正文里内嵌了 QQ 卡片自带的
<img src="img_1.png">,那个文件根本不存在,看起来就是破图; - 真正的图片躺在会话的
QCE完整附件导出/resources/images/里,文件名是<md5小写>_<MD5大写>.<ext>,和消息里写的原始文件名对不上。
修法是给每个会话建附件索引(MD5大写 → 相对路径,再用 basename 兜底),渲染时按 [图片:文件名] token 拆开:命中就内嵌 <img>,未命中显示灰色占位「图片未下载:文件名」,正文内嵌的 <img> 换成橙色占位。DOM 全部用 createElement/textContent 构建,正文里的 HTML 不会被当标签执行。
结果:213 个会话页内嵌 33,174 张图片,100% 指向真实存在的文件。
十二、完整性:用 seq 判断「缺没缺」
归档不可能 100% 完整,但可以量化缺在哪:
会话内:缺失上界 = seq 跨度 − 实有条数
全库: 缺口 21,494 条 / 3.8%,分布在 345 天这部分绝大多数是撤回、删除的消息——seq 删除不回收,所以「缺的那条」在任何客户端都不存在。这条口径也解释了为什么之前「某些天只有 1 条」不是数据不存在,而是取数管线的缺陷。
十三、最终账目
| 来源 | 新增条数 | 涉及会话 | 备注 |
|---|---|---|---|
| 初始导出(QCE 管线) | 431,539 | — | 仅私聊;月份残缺,后废弃 |
| 群聊存量(2026-10 导出) | 6,200,561 | 251 | 与私聊分账,未走内核翻页 |
| 内核翻页补全 | +110,818 | — | 54.2 万条,缺月补齐 |
| Windows 备份库 | +1,353,314 | 285 | 158 个全新建档;按 id 去重(隐患) |
| 手机本地库(首战) | +9,265,222 | 557 | 翻车,已整批回滚 |
| 全库语义去重 | −648,310 | 521 文件 | content JSON 当 key,只清掉一半 |
| 回滚 | 回到 8,096,232 | — | 干净基线 |
| 手机本地库(重算) | +4,612,067 | 358 文件被改写 | 真增量 463 万,重复 460 万被挡掉 |
| 最终 | 12,708,299 | 861 | 好友 567,168 / 群聊 12,141,131 |
私聊和群聊的增量结构完全不同:
- 私聊 +22,134 条,282/327 个会话有增量。漫游窗口早就覆盖了大部分内容,补的只是尾巴;
- 群聊 +4,610,192 条,150 个群有增量。原因是归档的群历史普遍只到 2025 年初,手机有到 2026-01,中间是整月整月的空白——某些群 2025-04 之后归档每月 0 条、手机每月三万条。
踩坑汇总
全树扫描会把数据量算大 32%
QCE完整附件导出/ 里 387 个 JSON 是同一批消息的带附件快照,99.7% 的 id 与主文件重合。find -name '*.json' 一把扫下去统计出 1,674 万条,比真实值高 32%。
约定:只扫两层(好友/*/*.json、群聊/*/*.json)。
SQLCipher 库的列名是数字,不加引号会静默出错
解密出来的表列名是 "1002"、"40050" 这种纯数字。SQL 里不包双引号,SQLite 会把它当字面量,查询不报错,但返回错值。这个坑靠数字对不上才被发现。
content JSON 不能当去重 key
见第八节。跨导出器比对必须走文本级规范化,否则同一句话永远是两条。
「时间戳差 1 秒」不是噪声
毫秒→秒的取整规则不同,同一条消息两边差 1 秒。不加 ±2 秒容差,匹配率直接腰斩。
统计字段不要用字节串匹配
grep '"uid": "未知"' 会把转发消息正文里的同名文本也算进去(实测量级 1,378 条)。必须解析 JSON 再数。
系统消息不收
手机库约 68 万条 [系统消息],归档侧本来就没存。收进来只让条数好看,内容为零。
去重要注意「子目录不是归档本体」
QCE完整附件导出/ 属于附件快照,被去重脚本连坐改了 116 个文件、13,175 条。恢复时还因为快照路径多一层日期目录失败过一次。任何”全库扫描”都要先明确扫哪些层。
后台任务:nohup 靠不住
沙箱里 nohup ... & 在工具调用返回时被回收,62 个会话后静默中断。改成断点续跑 + 并行 + 原子替换,46 秒跑完 454 个文件。
验证清单
每次合并后按顺序过一遍:
1. 每会话条数 == 备份条数 + 合并日志记录
2. 备份里的 id 100% 还在(逐条比对)
3. 文件内 id 唯一(重复 = 0)
4. 时间序单调
5. 语义重复率(同人同秒同文本)< 1%前四项是硬约束,任何一项不过就回滚;第五项看趋势,突然升高说明去重 key 又漏了口子。
总结
难点从来不在解密——密钥派生、页布局、锁页位置,都是查文档加试错能解决的。真正的问题是**「什么算同一条消息」**:
| 判据 | 结果 |
|---|---|
按 id | 手机双副本直接击穿,虚增约一倍 |
按 content JSON | 导出器编码不同就判成两条,虚增 70%~90% |
| 按原始文本 | 表情 4 种写法、回复 2 种前缀、图片占位符拼进尾巴,仍对不上 |
| 规范化文本 + ±2s 容差 | 重复引入率 0.013% |
另外三条经验,比技术细节更值钱:
- 先回滚,再重算。 就地打补丁的结果是「一半对一半错」,账永远对不平;
- 每一步留快照。 三级备份链不是洁癖,是让你敢动手的前提;
- 让独立程序复算。 自己写的脚本自己签字,等于没校验。
最后这 861 个会话、1,270.8 万条,Windows 备份贡献 135 万、手机贡献 461 万,其余 674 万是更早的积累。这个数字能被两个互不通气的程序复算出来——这才叫归档,而不是一堆看起来像聊天的文件。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 0 天
部分内容可能已过时