☰
微信聊天记录导出与本地永久备份:SQLCipher解密、Python实现与年度报告生成
2026/10/2 5:31:25 网站建设 项目流程

简介:提取微信聊天记录并转存为HTML、Word、CSV,再生成年度聊天报告的全套工具与模板,适用于有Python基础的个人用户、数据分析爱好者及微信开发者。资源基于WeChatMsg解析思路,覆盖备份数据解析、格式转换、关键字频率与活跃时段统计、可视化报告输出等环节,帮助读者将零散聊天内容变成可长期保存和复盘的结构化文档。

压缩包共238个文件,大小约25MB。其中94个Python脚本负责核心解析、转换与统计逻辑;17个HTML模板用于报告页面展示,61个PNG与36个SVG提供图表和视觉素材;另含JSON、Markdown、YAML等配置说明文件,目录结构清晰,便于按模块调用或二次开发。

目前已有646人学习下载。通过该资源可完整跑通“备份→解析→导出→分析→报告”流程,产出支持在线浏览的HTML报告、词云图表以及便于编辑的Word、CSV文件;开发者还能参考proto、qrc等文件理解微信备份格式,扩展个性化分析和自动化应用。

1. 微信聊天记录导出:本地数据再生,永久保存与年度报告

换过几次手机的人都有这种体会:微信里存了好几年、舍不得删的聊天记录,换机后要么被"仅迁移最近聊天"截断,要么清理缓存时误删,等到想翻某段对话时只能翻截图。这套资源的思路是把 PC 端微信的本地数据库读出来,解密后按三种格式导出——HTML 方便浏览器翻看和留档,Word 适合打印和送审,CSV 适合喂给 Excel 做二次统计;最后再基于消息时间、联系人、关键词生成一份年度聊天报告。适合想给家庭聊天记录做永久备份的人,也适合需要用真实聊天数据做轻量分析的从业者,前提是你有对应账号在 PC 端登录过,且能拿到本地库文件。

2. 解密微信本地数据库:密钥、库结构与依赖准备

2.1 密钥获取:从内存中找回 SQLCipher 的口令

PC 端微信的消息库是 SQLCipher 加密的 SQLite,不是普通 sqlite3 能直接打开的。SQLCipher 使用 AES-256 加密页面,连接时必须提供口令(passphrase)或 raw key。微信客户端启动后,会把解密后的数据库和口令都留在进程内存里,所以社区里最常见的做法是:先定位微信进程,枚举可读写的内存区域,再从中搜出 key。

我习惯分三步走:dump 或实时读取内存 → 用特征匹配候选 key → 用候选 key 尝试打开数据库验证。以下是用 pymem 枚举内存区域的代码:

from pymem import Pymem def list_rw_regions(process_name: str = "WeChat.exe"): pm = Pymem(process_name) regions = [] addr = 0 max_addr = pm.process_handle.max_addr while addr < max_addr: try: mbi = pm.virtual_query(addr) # 0x1000 表示已提交,0x04/0x40 是 PAGE_READWRITE / PAGE_EXECUTE_READWRITE if mbi.State == 0x1000 and mbi.Protect in (0x04, 0x40): regions.append({ "base": mbi.BaseAddress, "size": mbi.RegionSize }) addr = mbi.BaseAddress + mbi.RegionSize except Exception: break return regions

这段代码的作用是扫描进程的所有虚拟内存页,过滤出可读写的提交区域。为什么只挑 PAGE_READWRITE?因为微信存放 SQLCipher 口令的堆内存通常属于这类属性,只读的代码段没必要扫。拿到区域列表后,需要对每个区域做分块读取,然后在块里搜索 32 字节的 key 候选,这一步比较费时间,建议每块 4MB 左右。

找到候选 key 后,最有效的验证方式是直接用 sqlcipher3 尝试连接数据库并跑 integrity_check:

import sqlcipher3 def verify_key(db_path: str, key: bytes) -> bool: try: conn = sqlcipher3.connect(db_path) conn.execute(f"PRAGMA key = \"x'{key.hex()}'\"") conn.execute("PRAGMA cipher_memory_security = OFF") row = conn.execute("PRAGMA integrity_check").fetchone() conn.close() return row is not None and row[0] == "ok" except Exception: return False

这里的关键点是PRAGMA key的写法:用十六进制字符串包一层x'...'表示 raw key。验证不通过就继续换下一个候选,不用黑盒猜,SQLCipher 会直接告诉你页是否被正确解密。实际项目中我还会加一个约束:候选 key 是 32 字节且整段字节不全为零,能过滤掉大量噪声。

2.2 认识三张核心表:message、contact、chat

拿到密钥后别急着导数据,先摸清库结构。不同版本微信的表结构略有差异,但核心字段基本稳定,最关键的是消息表MSG、联系人表Contact、会话表ChatRoom。这里以消息表为例说明字段含义:

字段说明导出时用途
MsgSvrID服务端消息 ID,唯一去重、断点续导
StrTalker会话标识,单个联系人是对方 wxid,群聊是群 id归类会话
StrContent消息内容正文导出
Type消息类型决定走文本、图片还是文件分支
CreateTime秒级时间戳排序和年度报告
Status送达状态过滤撤回消息

关于 Type 字段,导出前必须建一张映射表,否则导出结果里全是数字:

Type含义导出策略
1文本直接写入内容
3图片从 FileStorage 复制原图,HTML/Word 引用本地路径
34语音原文件是 silk 格式,需要转码,默认只留文件名
47表情多数是 GIF,按 Content 里的 md5 去资源目录找
49文件/链接/引用需要按子类型再拆,链接取标题,文件取文件名

建议连库后先跑一句 GROUP BY 统计 Type 分布,确认你手上的版本有没有新增类型。常见做法是用SELECT Type, COUNT(*) FROM MSG GROUP BY Type,看到陌生类型再去单条抽样,不要硬编码只处理上面五种。

2.3 环境准备:依赖清单和连接代码

这个资源涉及 Python 生态,依赖尽量收敛,避免在客户机上装一堆东西。我一般固定用这四个:sqlcipher3(连加密库)、python-docx(Word)、jinja2(HTML 模板)、jieba(年度报告分词)。装依赖时注意 sqlcipher3 在 Windows 上要先装 Visual C++ 构建工具,否则会当场翻车。

连接库的标准姿势:

import sqlcipher3 def open_db(db_path: str, raw_key: bytes): conn = sqlcipher3.connect(db_path) conn.execute(f"PRAGMA key = \"x'{raw_key.hex()}'\"") # 兼容旧库时可能需要显式设置加密参数 # conn.execute("PRAGMA cipher_page_size = 1024") # conn.execute("PRAGMA kdf_iter = 4000") return conn

cipher_page_size和kdf_iter是历史版本的兼容项,只要打开时报 "file is not a database" 且确认 key 无误,优先怀疑这两个参数和你的库不匹配。新库默认 4096 页大小,旧库有的版本是 1024,参数不对时 select 会直接报错。我把这个也看成是资源里最容易踩的坑之一,后面避坑章会再展开。

3. 三种导出格式的实现:HTML、Word、CSV

3.1 HTML 导出:模板渲染,保留表情与图片

HTML 是最适合长期保存的格式,不依赖 Office,浏览器双击就能看,图片引用本地相对路径即可。计划是把同一个会话按天切分成多个 html 文件,主目录下放聊天记录主页方便导航,这样单个文件不会因为动图太多变成几十 MB。

核心模板用 Jinja2,片段如下:

<div class="msg-row {% if msg.is_self %}self{% else %}peer{% endif %}"> <div class="msg-meta"> <span class="sender">{{ msg.sender }}</span> <span class="time">{{ msg.time }}</span> </div> <div class="msg-body"> {% if msg.type == "text" %} <p>{{ msg.content | replace("\n", "<br>") | safe }}</p> {% elif msg.type == "image" %} <img src="{{ msg.local_path }}" loading="lazy" /> {% elif msg.type == "emoji" %} <img src="{{ msg.local_path }}" class="emoji" /> {% endif %} </div> </div>

渲染脚本里要做两件事:一是把 MSG.StrContent 里的换行符转成<br>,二是把图片消息的local_path从微信的 FileStorage 原目录复制到导出目录的images子目录。复制时我用的是shutil.copy2保留原始修改时间,这样导出目录里能看到图片的原始时间信息。参数loading="lazy"对几百条图片消息的页面很管用,等浏览器滚动到对应位置才加载图片。

HTML 导出还有个隐藏收益:它天然支持增量同步。只要把每个会话的最近导出时间记下来,下次只渲染该时间之后的消息,然后刷新一下主目录的导航页就行,不需要重新生成整个目录。很多聊天记录工具导出一次就废了,因为没法继续追加,这套按天切分的方式给续导留了口子。

3.2 Word 导出:docx 结构化排版

Word 导出的目标场景是"可打印的对话纪要",排版不需要花哨,但要稳定。python-docx 操作很简单,核心逻辑是把每条消息拆成一个段落,消息时间和发送人作为小字号灰字,正文作为正常字号,图片单独成段。

from docx import Document from docx.shared import Pt, RGBColor doc = Document() style = doc.styles["Normal"] style.font.name = "微软雅黑" style.font.size = Pt(10.5) for msg in messages: p = doc.add_paragraph() meta = p.add_run(f"{msg['time']} {msg['sender']}") meta.font.size = Pt(8) meta.font.color.rgb = RGBColor(0x80, 0x80, 0x80) body = p.add_run("\n" + msg["content"]) if msg["type"] == "image": run = p.add_run("\n") run.add_picture(msg["local_path"], width=Pt(320))

参数说明:width=Pt(320)是把图片宽度限制在文档内,避免原图太大把 docx 撑到 100MB 以上。Word 里图片即使缩到 320pt,文件体积依然保留原始像素数据,所以如果图片太多,我建议在导出前用 Pillow 压缩到宽度 1200px,体积能降一个数量级,打印效果也足够。

分段逻辑上有个细节:撤回的消息在 MSG 表里 Status 字段有标记,直接用 SQL 过滤会漏掉,python-docx 端做二次过滤更稳。这里我参照了"先 SQL 粗筛、再程序细筛"的处理方式,理由是撤回标记在不同版本里字段值不完全一致,程序里用一个集合定义已知值,既透明又容易改。

3.3 CSV 导出:UTF-8 BOM 与字段设计

CSV 是三种格式里最容易被轻视但最容易出问题的。如果只是给 Excel 用,编码必须是utf-8-sig,否则中文乱码是一定的。字段设计上,我会把"原始内容"和"解析后内容"分成两列:原始列留着 Type=49 的整段 XML,解析列放提取出的链接标题或文件名。这样既不丢失细节,又方便统计。

import csv import time def export_csv(messages, out_path: str, contact_map: dict): with open(out_path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["时间", "会话", "发送人", "消息类型", "内容", "本地文件"]) for msg in messages: writer.writerow([ time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(msg["ts"])), contact_map.get(msg["talker"], msg["talker"]), msg["sender"], msg["type_name"], msg["content"], msg["local_path"], ])

contact_map是从 Contact 表构建的"wxid → 备注名"映射,导出前先一次性加载成 dict。这么做的主要原因是 MSG 表里只有 wxid 没有备注名,如果不映射,导出的会话列全是 wxid,非技术用户根本看不懂是谁。另外注意newline="",不写的话在 Windows 上每行后面会多一个空行,这是 Python 写 CSV 最常见的玄学问题之一。

CSV 的统计价值在于可以用 pandas 直接做后续分析,或者导入数据库做长期归档。我一般建议把 CSV 当作"原始数据备份层",HTML 和 Word 当作"展示层",年度聊天报告则单独从 CSV 里读取再生成,三层各司其职。

4. 生成年度聊天报告:统计口径与中间格式

4.1 统计维度怎么定

年度聊天报告不是把消息总数列出来就完了,我的经验是先定五个维度:总量、联系人热度、时间分布、词频、情感词。总量包括年度消息条数、活跃天数、日均条数;联系人热度是按人聚合的消息数和字数排行;时间分布拆成月度走势和 24 小时时段热力;词频用 jieba 分词后做词频 TOP20;情感词简单分积极/消极两档。

确定维度后再统一口径,最容易翻车的点是"算不算群聊、算不算图片语音"。我的建议是默认只统计文本和表情,图片语音单独一个计数项,避免词频被图片数量稀释。过滤条件统一放 SQL 里,不要在多个脚本里各写一遍,否则报告数据自相矛盾。

import json import time from collections import Counter, defaultdict import jieba STOPWORDS = set("嗯 啊 了 的 是 就 在 和 都 也".split()) def build_report(rows, year: int = 2024): stats = { "total": 0, "active_days": 0, "by_month": defaultdict(int), "by_hour": defaultdict(int), "by_talker": Counter(), "words": Counter(), } day_set = set() for r in rows: ts = r["ts"] if time.localtime(ts).tm_year != year: continue stats["total"] += 1 day_set.add(time.strftime("%Y-%m-%d", time.localtime(ts))) stats["by_month"][time.localtime(ts).tm_mon] += 1 stats["by_hour"][time.localtime(ts).tm_hour] += 1 stats["by_talker"][r["talker"]] += 1 if r["type_name"] == "文本": for w in jieba.cut(r["content"]): if len(w) >= 2 and w not in STOPWORDS: stats["words"][w] += 1 stats["active_days"] = len(day_set) return stats

这段代码里的year参数是硬边界,报告只统计目标年份的数据。STOPWORDS是内置停用词,实际做的时候我会从外部文件加载一份更大的停用词表,否则"微信""消息"这类词会因为高频且无意义,把词频榜直接霸屏。len(w) >= 2是过滤单字,单字在分词里多是语气词,统计意义不大。

4.2 报告生成:JSON 中间层 + Jinja2 模板

统计脚本输出什么格式,直接决定报告页好不好写。我的做法是统计脚本只输出 JSON,HTML 报告用 Jinja2 渲染,二者之间不共享任何代码,改报告样式不需要重新跑统计。JSON 结构分三块:header 里的汇总数字、by_month 和 by_hour 两个数组、by_talker 和 words 两个排行列表。

报告页的模板结构比较固定:顶部是年度总览卡片,中间是月度柱状图和时段热力布局,底部是联系人 TOP10 和关键词 TOP20。这里不强求图表库,纯 CSS 柱状图对"永久保存"这个目标更友好——不依赖 CDN,离线也能看。用 div 高度模拟柱子的做法,代码量最少且导出后单文件可移动。

生成 JSON 并渲染 HTML 的主流程:

import json from jinja2 import Environment, FileSystemLoader def render_report(stats, talker_names: dict, out_html: str): payload = { "total": stats["total"], "active_days": stats["active_days"], "month": [stats["by_month"].get(m, 0) for m in range(1, 13)], "hour": [stats["by_hour"].get(h, 0) for h in range(24)], "talkers": [ {"name": talker_names.get(k, k), "count": v} for k, v in stats["by_talker"].most_common(10) ], "words": [{"name": k, "count": v} for k, v in stats["words"].most_common(20)], } env = Environment(loader=FileSystemLoader("templates")) tpl = env.get_template("report.html") html = tpl.render(data=payload) with open(out_html, "w", encoding="utf-8") as f: f.write(html)

参数说明:talker_names与 CSV 导出用的是同一个 contact_map,保证统计里的"微信名"和导出文档里的"会话名"一致。by_month和by_hour用 range 补齐了 12 个月和 24 小时的空位,否则一月份只有 30 天数据时,报告页柱子会缺一段,观感像数据丢了。

言语之外,年度报告最有"存档感"的是把当年聊天里第一次出现和最后一次出现的时间也放进去。代码上只需要在 build_report 里维护min_ts和max_ts两个变量,渲染时转成字符串放进 header,整体并不复杂,但对报告的情感价值提升非常明显,那段时间跨度就是"这一年我们聊了这些"的直观表达。

5. 避坑指南:五条血泪记录

5.1 现象:打开数据库报 "file is not a database"

原因:微信升级后消息库路径变了,旧的 MicroMsg.db 只剩空壳,或者数据库被微信迁移到了新目录。现在 3.9 版本之后消息库被拆成多个 msg_0.db、msg_1.db,路径通常在文档\xwechat_files\<wxid>\db_storage\message\下。解决:不要硬编码一个路径,用glob扫整个 wxid 目录下的*.db,再用 verify_key 逐个试。多花两分钟扫描,比报错后再找半天路径强。

5.2 现象:CSV 用 Excel 打开,中文全部乱码

原因:Python 默认用utf-8写入,Excel 并不认 utf-8 无 BOM 文件,它默认用本地 ANSI 编码解析,中文字段在 GBK 里全是问号。解决:打开文件时写encoding="utf-8-sig",它会自动在文件头加 BOM,Excel 就能正确识别。这是所有导出流程里最便宜的修复,却也是最常见的翻车点。

5.3 现象:Word 导出后文件 60MB,打开卡死

原因:图片消息的原图被原封不动插进 docx,python-docx 的 add_picture 即使设置显示宽度,文件内部依然保存原始像素数据。一次聊天里传十几张高清照片,文件体积就会失控。解决:在插入前用 Pillow 压缩到长边 1200px、JPEG 质量 80,压缩这块放在导出脚本里提前处理,word 端只收路径。从那以后我导出前都会先跑一遍图片压缩函数,再检查输出文件大小是不是合理的。

5.4 现象:导出消息顺序错乱,比实际时间晚 8 小时

原因:MSG 表的 CreateTime 是秒级时间戳,微信服务器存的是 UTC,本地显示时再转东八区。如果脚本跑在纯 Python 环境且系统时区不是中国时区,time.localtime(ts)会按系统时区转,直接差出 8 小时。解决:统一用time.localtime(ts, timezone)或在脚本开头os.environ["TZ"] = "Asia/Shanghai"。排序上按 CreateTime 再按 MsgSvrID 双字段排序,避免同一秒内多条消息顺序错乱。

5.5 现象:撤回的消息还在导出文档里

原因:MSG 表里撤回消息有两种状态:一种是 Status 被标记,一种是内容被替换成"你撤回了一条消息"。前者可以被 SQL 过滤,后者半像普通消息,程序很难判断。解决:我的习惯是导出时把 Status 不在正常集合的跳过,再把 Content 里包含"撤回了一条消息"关键词的文本过滤掉。宁可少几条,不要出现被撤回的敏感内容。

6. 进阶:增量同步与一键备份

导出一旦做了第一次,后续就是持续更新的需求。增量导出的思路是给每个会话保存一个 checkpoint 文件,记录上次导出的最大 MsgSvrID。下次导出时用WHERE MsgSvrID > ?取增量,再按 MsgSvrID 去重,就永远不用全量重跑。

import json import time CHECKPOINT = "checkpoint.json" def load_checkpoint(talker: str) -> int: try: with open(CHECKPOINT, "r", encoding="utf-8") as f: return json.load(f).get(talker, 0) except (FileNotFoundError, json.JSONDecodeError): return 0 def save_checkpoint(talker: str, max_id: int): cp = {} try: with open(CHECKPOINT, "r", encoding="utf-8") as f: cp = json.load(f) except (FileNotFoundError, json.JSONDecodeError): pass cp[talker] = max_id with open(CHECKPOINT, "w", encoding="utf-8") as f: json.dump(cp, f, indent=2, ensure_ascii=False)

配套的全量备份脚本,我通常会做成三步:先检查微信进程是否在运行并占用数据库,再复制一份 db 文件到临时目录,最后对副本做导出和报告生成。数据库处于被占状态时直接连库读容易读到未刷盘的数据,复制出来的文件是最稳妥的工作副本,Windows 下直接用shutil.copy2即可。

照这样整理完,整套流程就沉淀成三个入口:export_all.py负责三种格式导出,build_report.py负责年度报告,update.sh / update.bat负责增量同步。每次微信升级后跑一遍全量校验,确认密钥和路径没有变化,再切回增量模式。希望帮到你,让你那些被锁在微信里的对话,也能变成自己能永久掌控的本地资料。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询