5050 字
25 分钟
把 QQ 聊天记录完整导出归档:三轮并入、一次翻车和重新算账

把 QQ 聊天记录完整导出归档:三轮并入、一次翻车和重新算账#

我要的效果#

聊天记录用得越久越值钱,也越容易丢:在线漫游有窗口期,换手机就断档,Windows 端重装之后只剩一堆打不开的 .db。所以从头就把目标定死:

  1. 一处存全——所有会话落在本地一份 JSON 归档里,一个会话一个文件;
  2. 条数对得上——每并入一批数据,都能回答「新增多少、有没有丢、有没有重」;
  3. 能回滚——每次合并前留快照,出问题整批退回去;
  4. 能验证——口径写死、独立复算,不靠「我觉得应该没问题」。

最后的规模是 861 个会话文件、1,270.8 万条消息。它不是一次做出来的,是四轮采集叠出来的,中间还翻过一次车。

时间线总览#

阶段做了什么净增量结果
① 初始导出官方导出管线(QCE)出的归档—私聊 43 万条,月份残缺(另有群聊存量约 620 万)
② 内核翻页直连 QQ 内核 API 反向翻页+110,818私聊 54.2 万条,缺月补齐
③ Windows 备份库SQLCipher 离线解密后合并+1,353,314809.6 万条
④ 手机本地库(首战)解密、导出、合并声称 +926 万翻车,回滚
⑤ 全库去重按语义 key 清理重复−648,310账面好看,但埋了坑
⑥ 回滚整批退回手机并入前回到 809.6 万干净基线
⑦ 重算再并入规范化文本匹配+4,612,0671,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 万。但这次去重有两个问题:

  1. content 整体 JSON 当 key 是错的。同一个表情,PC 导出成 {"text":"[捂脸]","resources":[]},手机导出成 {"text":"[表情:[[捂脸]]]","elements":[...]},JSON 一比就不相等,于是「同一条消息」被判成两条。这一轮只清掉了「编码完全一致」的那部分重复。
  2. 顺手扫了不该扫的东西。会话目录下面还有 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,29912,708,299 ✅
并入前基线8,096,2328,096,232 ✅
逐文件增量合计4,612,0674,612,067 ✅
旧消息丢失00 ✅
系统消息混入00 ✅

重复(同人、同秒、同规范化文本):

项目结果
文件内重复 id0
语义重复多余副本34,353 条(0.27%)
其中并入前就已存在33,767 条
本次并入引入586 条(0.0127%)

结论很清楚:这次并入的 461 万条里只有 586 条撞了已有内容;剩下 3.3 万条重复是老账——第二轮 Windows 合并按 id 去重留下的,跟这次无关。

顺带发现一个坑:校验器一开始用 grep -c '"uid": "未知"' 数系统消息,结果多出 1,378 条。追下去才发现那些字样出现在转发消息 content 的内嵌 JSON 正文里,不是真的 sender 字段。必须解析成对象再数。

十一、附件与浏览页#

消息能看了,图片全显示不出来。原因有三层:

  1. 浏览页把图片渲染成 [图片:文件名] 纯文本;
  2. 部分消息正文里内嵌了 QQ 卡片自带的 <img src="img_1.png">,那个文件根本不存在,看起来就是破图;
  3. 真正的图片躺在会话的 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,561251与私聊分账,未走内核翻页
内核翻页补全+110,818—54.2 万条,缺月补齐
Windows 备份库+1,353,314285158 个全新建档;按 id 去重(隐患)
手机本地库(首战)+9,265,222557翻车,已整批回滚
全库语义去重−648,310521 文件content JSON 当 key,只清掉一半
回滚回到 8,096,232—干净基线
手机本地库(重算)+4,612,067358 文件被改写真增量 463 万,重复 460 万被挡掉
最终12,708,299861好友 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%

另外三条经验,比技术细节更值钱:

  1. 先回滚,再重算。 就地打补丁的结果是「一半对一半错」,账永远对不平;
  2. 每一步留快照。 三级备份链不是洁癖,是让你敢动手的前提;
  3. 让独立程序复算。 自己写的脚本自己签字,等于没校验。

最后这 861 个会话、1,270.8 万条,Windows 备份贡献 135 万、手机贡献 461 万,其余 674 万是更早的积累。这个数字能被两个互不通气的程序复算出来——这才叫归档,而不是一堆看起来像聊天的文件。

把 QQ 聊天记录完整导出归档:三轮并入、一次翻车和重新算账
https://hyperbola.cc/posts/tech/misc/qq-chat-history-archive/
作者
Hyperbola
发布于
2026-11-15
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取