微信聊天记录导出实战:从SQLCipher解密到Python解析全流程
简介微信聊天记录导出工具代码包面向需要二次开发或研究聊天记录管理方案的开发者可用于生成网页形式的可视化聊天记录页面并支持后续扩展为多类常见文档格式。代码包共有3个文件以网页文件为主配以在线运行配置和版本控制文件整体仅4KB结构极简便于快速阅读与改造。对应工具方案适配Win10/Win11系统操作门槛较低适合个人开发者、极客用户或需要系统整理微信聊天记录的技术人员。压缩包已有678人学习下载说明其复用价值受到一定认可。这份代码的亮点在于提供清晰的前端展示框架与可运行示例使用者可在此基础上调整界面展示逻辑、对接数据输入或结合聊天记录从手机迁移至电脑的思路快速搭建属于自己的聊天记录管理工具。1. 微信聊天记录导出工具真正卡人的是拿数据源不是写代码网上关于微信聊天记录导出工具的代码Python、Node、Go 各种版本都有散落得到处都是。但真正让多数人第一晚就失去兴趣的从来不是解析逻辑而是同一个场景从手机或者电脑里把数据库文件复制出来用 SQLite 打开直接报file is not a database。这是因为微信的聊天记录落在加密库里版本一换存储结构、表名、密钥策略全跟着变照着网上的旧教程跑必然翻车。这篇笔记不打算再贴第十八个”点一下就导出”的教程而是把一条能落地的链路拆开讲清楚数据存在哪、怎么绕过加密门槛、怎么写解析代码、最终交付成什么格式。适合准备自己动手做长期备份留存、或者做本地聊天数据检索的开发者也适合第一次碰 SQLite 的读者照着跑通一次。2. 微信聊天记录存在哪三端存储路径与 message 表结构2.1 Android / iOS / PC三个平台的数据源差异微信聊天记录的存储方式在三个平台上不太一样但有一个共同点都不是一个可以直接拖出来的文件夹。Android 端的数据落在应用私有目录里常规路径是/data/data/com.tencent.mm/MicroMsg/wxid哈希/EnMicroMsg.db这个wxid哈希是一长串 32 位十六进制字符每个微信账号对应一个目录。iOS 端因为沙箱机制常规做法是先用 iTunes 或 Finder 做一次本地备份再从备份文件里把微信 App 沙盒目录捞出来。PC 端相对简单聊天数据在微信安装目录下的WeChat Files/wxid/Msg/里但新版本同样做了加密处理。端关键数据文件近期版本是否加密常见获取方式Android/data/data/com.tencent.mm/MicroMsg/wxid哈希/EnMicroMsg.db是SQLite SQLCipherroot 环境或整机备份后提取iOS本地备份中的 App 沙盒 Documents 目录是SQLite SQLCipheriTunes / Finder 本地备份PC WindowsWeChat Files/wxid/Msg/下多个 db 文件是部分老版本明文直接读安装目录这里有个容易被忽略的坑Android 12 之后系统对应用数据的备份限制越来越严adb backup这条老路在很多新机型上已经拿不到完整数据了。我一般会建议先试手机厂商自带的整机备份再把备份文件解包从里面提取微信数据目录。iOS 端则要注意iTunes 备份如果勾选了“加密备份”拿出来的数据库文件仍然是密文处理时得先过一道解密。PC 端最省事但也别直接复制正在运行中的微信目录先退出微信再拷贝否则文件可能处于不一致状态。2.2 message 表字段把聊天记录当普通数据库表看拿到数据文件之后第一步不是急着解密而是先确认表结构。微信 Android 端的主消息表叫message不同版本字段略有增减但核心字段基本稳定localId是本地自增主键talker表示会话对象个人 wxid 或群聊 idmsgSvrId是服务端消息编号type是消息类型content是消息内容createTime是发送时间status是发送状态。字段含义典型值localId本地主键自增1, 2, 3...talker会话对象标识wxid_xxx 或 群聊idmsgSvrId服务端消息编号长整型type消息类型1文本 / 3图片 / 34语音 / 43视频 / 49链接文件 / 10000系统content消息正文或 XML文本内容或 XML 串createTime毫秒时间戳1700000000000status发送状态0/2/3 等type字段是整个解析工作的核心映射表。文本消息的content直接就是消息文字图片、语音、视频、文件这类消息content里存的是一段 XML真正内容藏在 XML 的属性里。系统消息type10000一般是“你已添加了对方”这类提示。PC 端表名可能不同有些版本叫MSGCONTACT或者拆成多张表所以写代码前先跑一遍sqlite_master探明表名最稳妥。2.3 加密门槛SQLCipher 与密钥的绕行思路微信选择的是 SQLite 的加密扩展 SQLCipher。也就是说数据文件本身是 SQLite 格式但每一页都做了加密处理直接用标准sqlite3模块打开会报file is not a database。网上关于密钥获取的方案很多旧版本常见做法是拿 IMEI 或 IMSI 与 wxid 做组合哈希新版本则从内存里抽取密钥这部分水很深版本差异极大。我不建议在这条路上硬啃更实际的做法是借助网上已经封装好的开源取 key 工具先把库折腾成明文再开始写自己的解析代码。网上有一批成熟方案搜索关键词用“微信数据库密钥”“SQLCipher 微信解密”就能找到对应版本的工具。拿到明文库或已有密钥之后先用一段快速插桩脚本确认库能打开。import sqlite3 db_path EnMicroMsg.db try: conn sqlite3.connect(db_path) cur conn.cursor() # 列出所有表确认表名不是 message 时及时调整 cur.execute(SELECT name FROM sqlite_master WHERE typetable LIMIT 20) for row in cur.fetchall(): print(row[0]) conn.close() except sqlite3.DatabaseError as e: print(打不开常见两种原因文件损坏或仍处于加密状态) print(报错信息:, e)这段代码的作用是先探明两件事库能不能打开以及表名列表长什么样。如果报错优先怀疑加密状态不要立刻觉得是文件坏了。如果库能打开但里面没有message表说明你拿到的可能是 PC 端或其他版本的数据文件这时候需要根据表名反推结构。参数方面没有特别要调的唯一要注意的是连接对象用完后要 close避免后面增量导出时文件被占用。3. 用 Python 从消息库导出结构化 JSON可直接抄的解析代码3.1 动手前先做三件事备份、校准版本、确认时间单位解析工作开始前先把原始文件做一份完整备份。这个备份最好放在导出脚本之外别在同一个目录里反复测试血泪经验是脚本写错一个字段把 output 目录当成源数据目录原始库被覆写之后整个过程得从头再来。备份之后跑一下PRAGMA user_version这是 SQLite 的一个内置元数据字段微信在升级表结构时会修改它。不同user_version对应的字段差异直接决定了你后面写的 SQL 能不能命中。import sqlite3 conn sqlite3.connect(EnMicroMsg.db) cur conn.cursor() cur.execute(PRAGMA user_version) print(schema version , cur.fetchone()[0]) # 顺手看一条消息的时间字段确认单位是秒还是毫秒 cur.execute(SELECT createTime FROM message ORDER BY createTime DESC LIMIT 1) sample cur.fetchone() print(最新一条 createTime , sample[0]) conn.close()这段脚本里user_version是整数通常微信版本跨度大的时候值差异明显。createTime的采样很关键如果数字是 10 位说明单位是秒如果是 13 位说明单位是毫秒。我在实际处理中见过同一条 SQL 在不同版本微信导出的结果差 1000 倍的情况这个校验能避免后面的时间全部错乱。3.2 核心解析函数把 message 表映射成会话字典拿到完整的消息表之后核心工作就是把每一行转换成一个可读的消息对象再按talker分组。下面是完整可跑的解析函数逻辑上做了四件事查全表、按类型还原正文、按会话分组、落盘 JSON。import sqlite3, json, time TYPE_LABEL { 1: 文本, 3: 图片, 34: 语音, 43: 视频, 49: 链接/文件, 10000: 系统 } def extract_title_from_xml(content): 从链接/文件消息的 XML 里尽量取一个标题取不到就返回占位文案 if not content: return 链接/文件消息 start content.find(title) if start -1: return 链接/文件消息 return content[start len(title):content.find(/title)] def parse_message_row(row): local_id, talker, msg_svr_id, msg_type, content, create_time, status row readable_time time.strftime( %Y-%m-%d %H:%M:%S, time.localtime(create_time / 1000) ) if msg_type 1: body content elif msg_type 3: body 图片消息 elif msg_type 34: body 语音消息 elif msg_type 43: body 视频消息 elif msg_type 49: body extract_title_from_xml(content) elif msg_type 10000: body content else: body f未知类型 {msg_type} return { localId: local_id, talker: talker, msgSvrId: msg_svr_id, type: msg_type, typeLabel: TYPE_LABEL.get(msg_type, str(msg_type)), body: body, time: readable_time, rawTimeMs: create_time, status: status, } def export_messages_to_json(db_path, output_json): conn sqlite3.connect(db_path) conn.row_factory sqlite3.Row cur conn.cursor() cur.execute( SELECT localId, talker, msgSvrId, type, content, createTime, status FROM message ORDER BY createTime ASC ) sessions {} for row in cur.fetchall(): msg parse_message_row(( row[localId], row[talker], row[msgSvrId], row[type], row[content], row[createTime], row[status] )) talker msg[talker] # 空 talker 的场景本地草稿或临时会话归到 unknown if not talker: talker unknown sessions.setdefault(talker, []).append(msg) conn.close() with open(output_json, w, encodingutf-8) as f: json.dump(sessions, f, ensure_asciiFalse, indent2) return sessions # 调用示例 if __name__ __main__: export_messages_to_json(EnMicroMsg.db, export.json)这段代码需要注意几个细节。第一createTime / 1000的前提是采样确认过单位是毫秒如果前面发现是秒这里的除法要去掉。第二extract_title_from_xml用的是粗暴的字符串查找微信的 XML 结构在不同消息类型里差异很大标题不一定在title里更稳妥的做法是用 Python 标准库xml.etree.ElementTree解析但那种写法在遇到非法 XML 字符时会抛异常所以我这里保留了字符串方案作为兜底。第三row_factory sqlite3.Row让查询结果支持按字段名索引避免写一堆位置索引把自己绕晕。运行后export.json里会是这样一个结构顶层是字典key 是talkervalue 是消息对象数组。每个消息对象保留了原始type和rawTimeMs这两项的保留意义后面会讲。3.3 增量导出用 MAX(createTime) 做游标避免每次全量扫聊天记录会持续增长完整备份一次之后后续只需要导出新增部分。增量导出的做法是记录上次导出时的最大createTime下一次查询加一个WHERE createTime ?条件。实现上用一个状态文件保存游标。import os, json STATE_FILE export_state.json def load_last_cursor(): if not os.path.exists(STATE_FILE): return 0 with open(STATE_FILE, r, encodingutf-8) as f: return json.load(f).get(last_create_time_ms, 0) def export_incremental(db_path, output_json): last_cursor load_last_cursor() conn sqlite3.connect(db_path) conn.row_factory sqlite3.Row cur conn.cursor() cur.execute( SELECT localId, talker, msgSvrId, type, content, createTime, status FROM message WHERE createTime ? ORDER BY createTime ASC , (last_cursor,)) new_messages [] max_time last_cursor for row in cur.fetchall(): msg parse_message_row(( row[localId], row[talker], row[msgSvrId], row[type], row[content], row[createTime], row[status] )) new_messages.append(msg) if row[createTime] max_time: max_time row[createTime] conn.close() if new_messages: # 将增量追加到原 JSON 文件而不是覆盖 with open(output_json, r, encodingutf-8) as f: sessions json.load(f) for msg in new_messages: talker msg[talker] or unknown sessions.setdefault(talker, []).append(msg) with open(output_json, w, encodingutf-8) as f: json.dump(sessions, f, ensure_asciiFalse, indent2) # 更新游标注意单位是毫秒不要存成秒 with open(STATE_FILE, w, encodingutf-8) as f: json.dump({last_create_time_ms: max_time}, f) return len(new_messages)增量导出有一个隐藏问题如果手机本地消息被清理过服务端消息还在但本地createTime最大的那批被删了游标不会后退新消息的createTime可能小于当前游标导致永远导不出来。这种情况我把游标语义从“严格游标”改成“回退窗口”每次保留最近 3 天内的消息都重新查询一遍再按localId去重。这个处理不做的话增量导出会在某些微信版本上莫名其妙地丢消息。另外状态文件和工作目录要绑定别用相对路径定时任务跑挂了都不知道读的是哪个目录的状态。4. 导出结果怎么交付TXT、HTML 与附件目录三种形态4.1 最简 TXT不依赖任何框架的时间线JSON 是中间格式不适合直接阅读。最常见的交付格式是纯文本按会话生成一个.txt文件每行一条消息前有时间和类型标记。这个格式的好处是任何设备都能打开检索起来也方便。TYPE_LABEL_TEXT { 1: 文本, 3: 图片, 34: 语音, 43: 视频, 49: 链接/文件, 10000: 系统 } def write_session_to_txt(session_name, messages, out_dir): import os os.makedirs(out_dir, exist_okTrue) safe_name session_name.replace(/, _).replace(:, _) out_path os.path.join(out_dir, f{safe_name}.txt) with open(out_path, w, encodingutf-8) as f: for msg in messages: label TYPE_LABEL_TEXT.get(msg[type], ftype{msg[type]}) # body 为空时给个占位避免整行空白 body msg[body] or 空消息 f.write(f[{msg[time]}] [{label}] {body}\n)这个函数的核心参数是out_dir建议按会话分了目录放别把所有会话塞进一个大文件。微信一个活跃群聊一年能产生几万条消息单文件过大之后文本编辑器打开都会卡。文件名里替换掉/和:是为了兼容 Windows 文件系统群聊 id 里可能出现这些字符。输出格式里保留[类型]标记而不是只写正文原因是微信文本消息和系统消息混在一起没有类型标记的话回看时容易把系统提醒当成别人的发言。4.2 HTML 导出按会话渲染成可点击页面纯文本能读但图片消息只能看到占位文案体验有限。HTML 导出在 TXT 的基础上把每条消息渲染成一个块时间、类型、正文分开展示后续还能继续加样式做的过程中能直接控制输出行为。生成 HTML 的关键是转义消息正文里的字符如果不处理会被浏览器当成标签解析轻则样式错乱重则整个页面结构被破坏。import html def build_session_html(session_name, messages): rows [] for msg in messages: body html.escape(msg[body] or , quoteTrue) label TYPE_LABEL_TEXT.get(msg[type], ftype{msg[type]}) rows.append( fdiv classmsg fspan classtime{msg[time]}/span fspan classlabel{label}/span fp classbody{body}/p f/div ) content \n.join(rows) return f!DOCTYPE html html langzh head meta charsetutf-8 title{html.escape(session_name)}/title style .msg {{ padding: 8px; border-bottom: 1px solid #eee; }} .time {{ color: #999; margin-right: 8px; }} .label {{ color: #06c; margin-right: 8px; }} .body {{ margin: 4px 0 0; white-space: pre-wrap; word-break: break-all; }} /style /head body h1{html.escape(session_name)}/h1 {content} /body /html # 调用把每个会话写成一个独立文件 def export_all_sessions_html(sessions, out_dir): import os os.makedirs(out_dir, exist_okTrue) for session_name, messages in sessions.items(): page build_session_html(session_name, messages) safe_name session_name.replace(/, _).replace(:, _) with open( os.path.join(out_dir, f{safe_name}.html), w, encodingutf-8 ) as f: f.write(page)这里html.escape是必选项。微信消息正文里经常出现和特别是有人发代码片段或 JSON 的时候不做转义直接拼接字符串生成出来的 HTML 在浏览器里会缺块。white-space: pre-wrap样式保留了消息里的换行符否则长消息挤成一行没法看。会话名做了同样的安全处理避免群聊名称里带特殊符号导致文件名非法。4.3 附件导出把图片、文件与消息关联起来聊天记录里最占空间的永远是图片和文件。导出工具只导文本是不够的还要把附件按消息关联关系复制出来。微信的附件存储逻辑在不同版本上差异比较大但常见规律是图片消息的content字段是一个 XML里面包含文件名线索真实文件散落在数据目录的多个子目录里。这里给一个可行的复制方案。import os, re, shutil def extract_file_hint(content): 从图片消息的 XML 中提取文件名线索 if not content: return None m re.search(r[0-9a-fA-F]{32,64}, content) if m: return m.group(0).lower() return None def copy_attachments(data_dir, sessions, out_attach_dir): 按消息线索在数据目录里找同名文件复制到输出目录 os.makedirs(out_attach_dir, exist_okTrue) copied set() for messages in sessions.values(): for msg in messages: if msg[type] ! 3: continue hint extract_file_hint(msg[body]) if not hint or hint in copied: continue # 在数据目录递归查找匹配前缀相同的文件 for root, _, files in os.walk(data_dir): for f in files: if f.lower().startswith(hint): src os.path.join(root, f) dst os.path.join(out_attach_dir, f) shutil.copy2(src, dst) copied.add(hint) break if hint in copied: break return len(copied)这个函数的data_dir是微信数据目录的根路径out_attach_dir是导出的附件输出目录。startswith匹配是因为微信的缩略图和原图命名相近用完整文件名匹配可能漏掉。这个方案比较粗糙不同版本的文件散布规则不一样真要做完整得先手动跑一遍看content里的线索和实际文件名的对应关系。我的血泪经验是第 4.3 节千万别一上来就写通用逻辑先拿一条图片消息把它content里的 XML 完整打印出来对着数据目录里的文件看规律再写匹配代码。5. 导出链路常见问题排查5 个高频坑的记录与对应解法5.1 报错 database disk image is malformed现象用标准sqlite3打开数据库返回database disk image is malformed但文件大小看起来正常。原因大概率是复制时机不对。微信运行过程中数据库文件处于写状态直接拷贝得到的文件可能包含未提交的事务页页校验和失败。另一个常见原因是备份软件中断导致文件不完整。解决先退出微信再复制不要从运行中的进程目录里直接拷。如果已经拿到损坏文件试着用sqlite3的.recover命令恢复命令是sqlite3 damaged.db .recover recovered.sql然后再导入。但注意这条命令只对明文库有效加密库要先解密再恢复。5.2 导出后时间字段全是 1970-01-01现象所有消息的time字段都显示成 1970-01-01 08:00:00 之类的值。原因createTime的单位判断错了。微信 Android 新版本用毫秒我见过 PC 端某些版本用秒还有个别版本在迁移后把毫秒值除以 1000 存了。如果脚本假设毫秒但实际是秒create_time / 1000之后会得到一个接近 1970 的小整数。解决先执行SELECT createTime FROM message ORDER BY createTime DESC LIMIT 1看返回值的位数。数字是 10 位就按秒处理13 位就按毫秒处理。这个判断做好之后时间处理的代码全都会走对。5.3 有密钥执行 PRAGMA key 后仍报 file is not a database现象从网上找到密钥用pysqlcipher3或sqlcipher3连接库执行PRAGMA key...后依然打不开。原因SQLCipher 的加密参数不止密钥一个。cipher_page_size、kdf_iter、hmac_algorithm都影响解密结果微信不同版本用过的参数组合不一样只传密钥是解不开的。解决确认你用的 SQLCipher 版本和微信版本匹配。遇到这个问题时别在参数组合上死磕我的建议是换个思路去找对应版本微信的开源解密脚本让现成代码把库转成明文之后再用标准sqlite3做解析。这是最快的路不要试图用穷举参数的方式去硬解。5.4 导出的消息条数比微信端显示少现象全量导出后统计message表行数和手机微信上显示的聊天记录总数对不上少的通常是最早的或者最近的那批。原因消息不一定全在message表里。微信有拆分存储的版本部分消息在message_xxx类似命名的附属表里或者按年份拆成了独立库。另外增量导出的游标如果用了createTime而消息近期被清理过也会少数据。解决先列出所有名字里含message的表逐个统计行数。主表和附属表都要解析汇总再按localId去重合并。增量导出的场景把游标策略改成“保留时间窗口 按 localId 去重”而不是单纯用最大时间戳过滤。5.5 消息里全是图片占位符附件也没复制出来现象导出的文本里图片消息显示为图片消息但附件目录是空的。原因图片消息的content是 XML附件文件名线索藏在 XML 属性里单纯把img标签当作占位符跳过了。我的extract_file_hint函数里用正则找十六进制字符串真实场景中可能匹配到的是无关数字比如消息里的手机号。解决先打印一条真实图片消息的完整 XML人工确认文件名线索的属性名再按属性提取。不要试图写一个万能解析正则微信的 XML 结构在版本更新时会调整绑定属性名的方式更容易维护。6. 进阶一步先用导出 JSON 做 24 小时分布自检再宣布你的工具可以交付工具写完先别急着宣布完工。我每次做完导出都会立刻做一次完整性自检统计所有消息在 24 小时内的分布画出柱状图。这个方法能同时暴露两类问题——时间解析有没有错位以及会话数据是不是完整。如果时间字段被错误除以 1000柱状图会异常集中在 1970 年如果某个会话只导出了零星几条分布曲线会出现明显断层。这个自检不需要额外依赖库用Counter统计消息的小时分布再输出成一份带条形块的 HTML 即可。from collections import Counter import json, time with open(export.json, encodingutf-8) as f: sessions json.load(f) hour_counter Counter() for messages in sessions.values(): for msg in messages: # 直接用原始毫秒时间戳转本地时区后再取小时 hour time.localtime(msg[rawTimeMs] / 1000).tm_hour hour_counter[hour] 1 max_count max(hour_counter.values()) bars [] for h in range(24): count hour_counter.get(h, 0) width int(count / max_count * 100) if max_count else 0 bars.append( fdiv classbar stylewidth:{width}% fspan classlabel{h:02d}:00/span fspan classcount{count}/span/div ) html_out f!DOCTYPE htmlhtmlheadmeta charsetutf-8 title消息时间分布自检/title style .bar {{ display: flex; background:#eef; margin: 4px 0; height: 24px; }} .label {{ width: 60px; color:#333; line-height: 24px; padding-left: 8px; }} .count {{ margin-left: 8px; line-height: 24px; }} /style/headbody{.join(bars)}/body/html with open(check_hourly.html, w, encodingutf-8) as f: f.write(html_out)这个自检脚本跑完你再看一眼分布图心里就有数了。正常人的聊天分布一定符合作息规律凌晨四点几乎为零上午十点和晚上九点附近是高峰。如果分布图里某个整点特别突出去看那个小时的消息多半是时间解析的时区问题如果全年分布里某一段全空查一下是不是那个时间段的消息被聊天记录清理功能清掉了。我做导出工具这些年最大的教训就是不要在一个脚本里同时处理所有版本的兼容问题。微信版本迭代频繁试图写一套兼容十年前到现在的解析逻辑只会让代码变成一坨没人能维护的分支地狱。正确做法是把“探版本”“解库”“解析”“导出”拆成独立步骤每一步都输出可检查的中间文件这样版本升级时只需要替换其中一环。每一轮做完先跑时间分布自检再手工抽查两个会话的消息条数确认无误才算结束。希望这套思路能帮你少走弯路也希望你早日用上自己写的、不受版本绑架的导出工具。本文还有配套的精品资源点击获取