Merge PR #107: GUI + 企业微信 + 朋友圈 + 单 exe 打包

支持:
- 企业微信 (WXWork) Windows 解密 (wxSQLite3 AES-128-CBC):
  - find_wxwork_keys.py: cipher 结构体扫描自动定位 16-byte raw key
  - wxwork_crypto.py: per-page MD5 派生 + AES-128-CBC 解密
  - decrypt_wxwork_db.py: 批量解密所有加密 db (17/17 实测通过)
  - export_wxwork_messages.py: 按个人/群导出 CSV/HTML/JSON
- 朋友圈 (SNS) 解密 & 导出:
  - decrypt_sns.py / export_sns.py
- GUI 工具箱 (app_gui.py, tkinter):
  - 整合解密/导出/音频转换/企业微信
- 单 exe 打包: WeChatDecrypt.spec + build.bat (PyInstaller)
- 音频工具: voice_to_mp3.py (SILK_V3 → MP3)
- 批量图片: batch_decrypt_images.py
- 共享 export 模块: export_messages.py

config 改动:
- 加 _app_base_dir() 支持打包后 exe 找 config (WECHAT_DECRYPT_APP_DIR)
- 加 wxwork_* / output_base_dir / wechat_files_dir / xwechat_attach_dir 等
- _choose_candidate 加 WECHAT_DECRYPT_NONINTERACTIVE / _GUI 非交互模式
- main.py 加 _call_with_argv helper 隔离子命令 argparse

文档:
- README 加 GUI / 企业微信 / 打包章节
- 新增 EXE_USAGE.md

测试: 185/185 通过 (新增 1 个 wxsqlite3 roundtrip test)

重磅意义: 推翻了 docs/wxwork-research.md 5/13 的"derive-use-zero
不可破"结论 — 企微 5.0.8.6009 实际用 wxSQLite3 AES-128-CBC, 16-byte
raw key 存在内存的 cipher 结构体里, 可被结构体扫描定位。本地实测
17/17 数据库全部解密成功, 读出真实明文消息验证时间戳一致 (含 5/13
登录通知)。

Closes #107
This commit is contained in:
ylytdeng
2026-05-17 16:42:29 +08:00
22 changed files with 5649 additions and 36 deletions

401
.gitignore vendored
View File

@@ -1,13 +1,314 @@
# Decrypted databases and keys - NEVER upload
all_keys.json
wechat_key.txt
config.json
decrypted/
decoded_images/
*.db
*.db-shm
*.db-wal
*.db.tmp_monitor
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
.idea/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# Chat export/transcription output files (contain private message data)
*_export*.json
@@ -18,11 +319,75 @@ hook_output.txt
hook_start_output.txt
hook_stderr.txt
run_hook.bat
# CodeRush personal settings
.cr/personal
# Python
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.py[cod]
*.egg-info/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/.bevel
/.vscode
# Node.js / npm
node_modules/
package-lock.json
.npm
.eslintcache
# VitePress
docs/.vitepress/cache
docs/.vitepress/dist
.temp
*.local
# Temporary build artifacts
x64/
# OS
.DS_Store
@@ -32,3 +397,13 @@ Thumbs.db
find_all_keys_macos
decoded_voices/
voice_transcriptions.json
data/
export/
build/
dist/
decrypted/
all_keys.json
wxwork_decrypted/
wxwork_export/
wxwork_keys.json
config.json

127
EXE_USAGE.md Normal file
View File

@@ -0,0 +1,127 @@
# WeChat Decrypt 工具箱 使用说明
## 快速开始
1. **启动微信**并登录账号;如果要解密企业微信,也请先启动企业微信
2. 双击 `WeChatDecrypt.exe` 打开工具箱
3. 根据需要点击按钮:
- **① 微信解密** → 从微信进程提取密钥并解密数据库到 `decrypted/` 目录
- **② 图片密钥** → 从微信进程提取新版图片 AES 密钥
- **③ 导出数据** → 将聊天记录导出为 CSV / HTML / JSON 到 `export/` 目录
- **④ 朋友圈图片** → 解密朋友圈缓存图片
- **⑤ 企业微信解密** → 从企业微信进程提取密钥并解密数据库到 `wxwork_decrypted/` 目录
- **⑥ 企业微信导出** → 选择某个人或群,导出 CSV / HTML / JSON 到 `wxwork_export/` 目录
## 前置要求
- Windows 10 / 11
- 微信 PC 版已登录(解密微信时需要微信进程运行)
- 企业微信 PC 版已登录(解密企业微信时需要企业微信进程运行)
- [FFmpeg](https://ffmpeg.org/download.html) 已安装并加入 PATH转换音频需要
### 检查 FFmpeg
打开命令提示符,输入:
```
ffmpeg -version
```
如果提示"不是内部或外部命令",需要先安装 FFmpeg。
## 输出目录说明
运行后在 exe 所在目录下生成以下文件夹:
```
WeChatDecrypt.exe
config.json ← 首次运行自动生成的配置文件
decrypted/ ← ① 解密后的数据库文件
wxwork_decrypted/ ← ⑤ 解密后的企业微信数据库文件
wxwork_export/ ← ⑥ 导出的企业微信聊天记录
群名_R_123/
.info
messages.csv
messages.html
messages.json
export/ ← ③ 导出的聊天记录
张三/
.info ← 联系人信息username/alias/remark/nick_name
message_0.db.csv ← CSV 格式Excel 可直接打开)
message_0.db.html← HTML 格式(浏览器打开,微信气泡样式)
message_0.db.json← JSON 格式(程序处理用)
李四/
...
data/ ← 导出时选择“同时转换语音为 MP3”后的输出
张三/
.info
20250101_120000_1.mp3
...
```
## 导出格式说明
### CSV
- 编码UTF-8 with BOMExcel 双击即可正确显示中文
- 字段时间、发送者、消息类型、内容、server_id
### HTML
- 浏览器打开,模拟微信聊天界面
- 左侧气泡为接收消息,右侧为发送消息
- 按日期自动分组
### JSON
- 完整结构化数据,包含所有元信息
- 适合程序二次处理或 AI 训练
## 配置文件
首次运行会自动检测微信数据目录并生成 `config.json`
```json
{
"db_dir": "D:\\xwechat_files\\wxid_xxx\\db_storage",
"keys_file": "all_keys.json",
"decrypted_dir": "decrypted",
"wechat_process": "Weixin.exe",
"wxwork_db_dir": "C:\\Users\\<用户>\\Documents\\WXWork\\<account_id>\\Data",
"wxwork_keys_file": "wxwork_keys.json",
"wxwork_decrypted_dir": "wxwork_decrypted",
"wxwork_export_dir": "wxwork_export"
}
```
如果自动检测失败,请手动修改 `db_dir` 为你的微信数据目录。
路径可在:微信设置 → 文件管理 中找到。
## 常见问题
**Q: 点击"解密数据库"提示未检测到微信进程**
A: 请确保微信 PC 版已启动并登录,然后重试。
**Q: 解密失败 / 密钥提取失败**
A: 检查 `config.json` 中的 `db_dir` 是否与当前登录的微信账号匹配。切换账号后需要删除 `all_keys.json` 重新提取。
**Q: 企业微信解密失败 / 找不到企业微信数据目录**
A: 确认企业微信 PC 版已启动并登录。若自动检测失败,请在 `config.json` 中设置 `wxwork_db_dir`,路径通常类似 `C:\Users\<用户>\Documents\WXWork\<account_id>\Data`。切换企业微信账号后删除 `wxwork_keys.json` 重新提取。
**Q: 企业微信导出为空 / 找不到会话**
A: 先执行"⑤ 企业微信解密",确认 `wxwork_decrypted/message.db``wxwork_decrypted/session.db` 存在,然后再执行"⑥ 企业微信导出"。
**Q: 转换音频没有输出**
A: 确认已安装 FFmpeg 并加入系统 PATH。确认已先执行"① 解密数据库"。
**Q: 导出消息为空**
A: 确认已先执行"① 解密数据库",且 `decrypted/message/` 下有 `.db` 文件。
**Q: 目录名是 wxid_xxx 而不是昵称**
A: 该联系人不在通讯录中contact.db 无记录),会使用原始 username。
## 自行打包
安装依赖后双击 `build.bat` 即可重新打包:
```
pip install pyinstaller pycryptodome zstandard pilk
build.bat
```
输出文件:`dist\WeChatDecrypt.exe`

118
README.md
View File

@@ -274,12 +274,23 @@ make help # 列出所有命令
| 文件 | 说明 |
|------|------|
| `main.py` | **一键启动入口** — 自动配置、提取密钥、启动 Web UI |
| `main.py` | **一键启动入口** — 自动配置、提取密钥、启动服务 |
| `app_gui.py` | **GUI 工具箱** — tkinter 界面,整合解密/导出/音频转换 |
| `export_messages.py` | 聊天记录导出CSV / HTML / JSON |
| `voice_to_mp3.py` | 语音消息 SILK 转 MP3 |
| `build.bat` | 一键打包为单 exePyInstaller |
| `config.py` | 配置加载器(自动检测微信数据目录) |
| `find_all_keys.py` | 平台分发入口Windows / Linux |
| `find_all_keys_windows.py` | Windows 版内存扫描提 key |
| `find_all_keys_linux.py` | Linux 版内存扫描提 key |
| `decrypt_db.py` | 全量解密所有数据库 |
| `export_all_chats.py` | 批量导出所有聊天为 JSON支持 `-t` 附带语音转录) |
| `export_chat.py` | 单会话导出(供 export_all_chats.py 内部调用) |
| `chat_export_helpers.py` | 导出格式化共享函数(两脚本共用,避免代码漂移) |
| `transcribe_chat.py` | 语音消息转录(共享 config.json 配置的 backend |
| `find_wxwork_keys.py` | 企业微信 Windows 版内存扫描提 key |
| `decrypt_wxwork_db.py` | 企业微信 wxSQLite3 AES-128 数据库解密 |
| `export_wxwork_messages.py` | 企业微信聊天记录导出(按个人/群筛选CSV / HTML / JSON |
| `mcp_server.py` | MCP Server让 Claude AI 查询微信数据 |
| `monitor_web.py` | 实时消息监听 (Web UI + SSE) |
| `monitor.py` | 实时消息监听 (命令行) |
@@ -304,6 +315,35 @@ make help # 列出所有命令
WCDB (微信的 SQLCipher 封装) 会在进程内存中缓存派生后的 raw key格式为 `x'<64hex_enc_key><32hex_salt>'`。三个平台均可通过扫描进程内存匹配此模式,再通过 HMAC 校验 page 1 确认密钥正确性。
### GUI 工具箱 & 单 exe 打包
提供 tkinter 图形界面 (`app_gui.py`),集成核心功能:
1. **解密数据库** — 调用 `main.py decrypt`
2. **导出消息** — 调用 `export_messages.py`,输出 CSV / HTML / JSON
3. **转换音频** — 调用 `voice_to_mp3.py`SILK_V3 → MP3
4. **企业微信解密** — 调用 `find_wxwork_keys.py` + `decrypt_wxwork_db.py`
5. **企业微信导出** — 调用 `export_wxwork_messages.py`,按个人/群导出 CSV / HTML / JSON
#### 直接运行
```bash
python app_gui.py
```
#### 打包为单 exe
```bash
pip install pyinstaller
build.bat
```
输出 `dist\WeChatDecrypt.exe`(约 18MB双击即可使用无需安装 Python。
> 转换音频需要系统安装 [FFmpeg](https://ffmpeg.org/download.html) 并加入 PATH。
详细说明见 [EXE_USAGE.md](EXE_USAGE.md)。
### WAL 处理
微信使用 SQLite WAL 模式WAL 文件是**预分配固定大小** (4MB)。检测变化时:
@@ -311,7 +351,81 @@ WCDB (微信的 SQLCipher 封装) 会在进程内存中缓存派生后的 raw ke
- 使用 mtime 检测写入
- 解密 WAL frame 时需校验 salt 值,跳过旧周期遗留的 frame
### 更新日志
### 图片 .dat 加密格式
微信本地图片 (.dat) 有三种加密格式:
| 格式 | 时期 | Magic | 加密方式 | 密钥来源 |
|------|------|-------|---------|---------|
| 旧 XOR | ~2025-07 | 无 | 单字节 XOR | 自动检测 (对比 magic bytes) |
| V1 | 过渡期 | `07 08 V1 08 07` | AES-ECB + XOR | 固定 key: `cfcd208495d565ef` |
| V2 | 2025-08+ | `07 08 V2 08 07` | AES-128-ECB + XOR | 从进程内存提取 |
V2 文件结构: `[6B signature] [4B aes_size LE] [4B xor_size LE] [1B padding]` + `[AES-ECB encrypted] [raw unencrypted] [XOR encrypted]`
### 企业微信数据库解密 (实验)
企业微信 Windows 5.x 的本地数据库不是普通微信 SQLCipher 4 格式,而是 wxSQLite3 AES-128-CBC
- 16 字节 raw key
- 每页按 page index + `sAlT` 派生 AES key
- 每页 IV 由 page index 派生
- 无 SQLCipher HMAC / reserve 区
提取并解密:
```bash
python find_wxwork_keys.py
python decrypt_wxwork_db.py
python export_wxwork_messages.py
```
如果自动提取失败但你已有 raw key也可以直接传入 32 位 hex key
```bash
python decrypt_wxwork_db.py --key 00112233445566778899aabbccddeeff
```
配置项:
```json
{
"wxwork_db_dir": "C:\\Users\\<用户>\\Documents\\WXWork\\<account_id>\\Data",
"wxwork_keys_file": "wxwork_keys.json",
"wxwork_decrypted_dir": "wxwork_decrypted",
"wxwork_export_dir": "wxwork_export"
}
```
### 数据库结构
解密后包含约 26 个数据库:
- `session/session.db` - 会话列表 (最新消息摘要)
- `message/message_*.db` - 聊天记录
- `contact/contact.db` - 联系人
- `media_*/media_*.db` - 媒体文件索引
- 其他: head_image, favorite, sns, emoticon 等
## macOS 数据库密钥扫描 (WeChat 4.x)
macOS 版微信 4.x 使用 SQLCipher 4 加密本地数据库,密钥格式为 `x'<64hex_key><32hex_salt>'`。C 版扫描器通过 Mach VM API 扫描微信进程内存提取密钥。
### 前置条件
- macOS (Apple Silicon / Intel)
- WeChat 4.x (macOS 版)
- Xcode Command Line Tools: `xcode-select --install`
- 微信需要 ad-hoc 签名(或安装了防撤回补丁):
`sudo codesign --force --deep --sign - /Applications/WeChat.app`
### 编译和使用
```bash
# 编译
cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation
# 运行(自动查找微信进程、扫描内存、匹配 DB salt
sudo ./find_all_keys_macos
<details>
<summary>点击展开</summary>

45
WeChatDecrypt.spec Normal file
View File

@@ -0,0 +1,45 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
datas = [('main.py', '.'), ('config.py', '.'), ('decrypt_db.py', '.'), ('export_messages.py', '.'), ('voice_to_mp3.py', '.'), ('find_all_keys.py', '.'), ('find_all_keys_windows.py', '.'), ('find_all_keys_linux.py', '.'), ('find_wxwork_keys.py', '.'), ('decrypt_wxwork_db.py', '.'), ('export_wxwork_messages.py', '.'), ('wxwork_crypto.py', '.'), ('key_scan_common.py', '.'), ('key_utils.py', '.'), ('decode_image.py', '.'), ('find_image_key.py', '.'), ('find_image_key_monitor.py', '.'), ('decrypt_sns.py', '.'), ('export_sns.py', '.'), ('monitor.py', '.'), ('monitor_web.py', '.'), ('mcp_server.py', '.'), ('config.example.json', '.')]
binaries = []
hiddenimports = []
tmp_ret = collect_all('pilk')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['app_gui.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='WeChatDecrypt',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

929
app_gui.py Normal file
View File

@@ -0,0 +1,929 @@
"""WeChat Decrypt GUI — 一键解密 / 导出消息 / 转换音频"""
import os
import sys
import subprocess
import threading
import sqlite3
import hashlib
import glob as globmod
import tkinter as tk
from tkinter import ttk, scrolledtext
# 确保工作目录为脚本所在目录(打包后也适用)
if getattr(sys, "frozen", False):
BASE_DIR = os.path.dirname(sys.executable)
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
os.chdir(BASE_DIR)
os.environ["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR
# ── 子任务入口(当以 --task 参数调用时直接执行对应脚本) ──────────────────────
# 显式导入:让 PyInstaller 收集子脚本需要的所有依赖
# (这些脚本通过 exec 动态加载PyInstaller 无法自动检测)
import importlib.util # noqa: F401 - used for dynamic loading
if False: # noqa: never executed, only for PyInstaller dependency detection
import sqlite3, hashlib, csv, json, re, glob, tempfile # noqa: F401
import xml.etree.ElementTree # noqa: F401
import functools, platform, ctypes, ctypes.wintypes # noqa: F401
import zstandard # noqa: F401
import pilk # noqa: F401
import Crypto, Crypto.Cipher, Crypto.Cipher.AES, Crypto.Util.Padding # noqa: F401
import wxwork_crypto # noqa: F401
import export_wxwork_messages # noqa: F401
def _run_subtask(task: str):
"""在子进程中被调用,直接执行对应脚本逻辑"""
# 强制 stdout/stderr 为 UTF-8
if sys.platform == "win32":
for s in (sys.stdout, sys.stderr):
if hasattr(s, "reconfigure"):
s.reconfigure(encoding="utf-8", errors="replace")
# onefile: _MEIPASS 临时目录; onedir: _internal/; 开发: BASE_DIR
if getattr(sys, "frozen", False):
script_dir = getattr(sys, "_MEIPASS", os.path.join(os.path.dirname(sys.executable), "_internal"))
else:
script_dir = BASE_DIR
# 让 import 能找到脚本同目录的模块
if script_dir not in sys.path:
sys.path.insert(0, script_dir)
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
mapping = {
"decrypt": "main.py",
"export": "export_messages.py",
"voice": "voice_to_mp3.py",
"find_image_key": "find_image_key.py",
"decrypt_sns": "decrypt_sns.py",
"export_sns": "export_sns.py",
"find_wxwork_keys": "find_wxwork_keys.py",
"decrypt_wxwork": "decrypt_wxwork_db.py",
"export_wxwork": "export_wxwork_messages.py",
}
script = mapping.get(task)
if not script:
print(f"未知任务: {task}", flush=True)
sys.exit(1)
script_path = os.path.join(script_dir, script)
if not os.path.exists(script_path):
# 开发模式回退到 BASE_DIR
script_path = os.path.join(BASE_DIR, script)
if not os.path.exists(script_path):
print(f"脚本不存在: {script_path}", flush=True)
sys.exit(1)
# 将 decrypt 命令传给 main.py
if task == "decrypt":
sys.argv = ["main.py", "decrypt"]
elif task == "find_image_key":
sys.argv = ["find_image_key.py"]
elif task == "find_wxwork_keys":
sys.argv = ["find_wxwork_keys.py"]
elif task == "decrypt_wxwork":
sys.argv = ["decrypt_wxwork_db.py"]
elif task == "export_wxwork":
sys.argv = ["export_wxwork_messages.py"]
else:
sys.argv = [script]
# 设置环境变量,让 config.py 等脚本知道真正的应用目录
os.environ["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR
os.chdir(BASE_DIR)
# 加载并执行脚本
spec = importlib.util.spec_from_file_location("__main__", script_path)
mod = importlib.util.module_from_spec(spec)
mod.__name__ = "__main__"
spec.loader.exec_module(mod)
# ── 检查是否为子任务模式 ──────────────────────────────────────────────────────
if len(sys.argv) >= 3 and sys.argv[1] == "--task":
_run_subtask(sys.argv[2])
sys.exit(0)
# ── GUI 模式:隐藏控制台窗口 ────────────────────────────────────────────────
if sys.platform == "win32":
try:
import ctypes
ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 0)
except Exception:
pass
# ── 联系人发现 ────────────────────────────────────────────────────────────────
def _load_contact_map(decrypted_dir):
"""从 contact.db 加载联系人映射 {username: {remark, nick_name, ...}}"""
contact_map = {}
db_path = os.path.join(decrypted_dir, "contact", "contact.db")
if not os.path.exists(db_path):
return contact_map
try:
conn = sqlite3.connect(db_path)
for uname, alias, remark, nick_name in conn.execute(
"SELECT username, alias, remark, nick_name FROM contact"
):
contact_map[uname] = {
"remark": remark or "",
"nick_name": nick_name or "",
}
conn.close()
except Exception:
pass
return contact_map
def _display_name(username, contact_map):
info = contact_map.get(username, {})
return info.get("remark") or info.get("nick_name") or username
def _discover_contacts():
"""扫描所有联系人/会话,返回 (contacts, has_voice)
contacts: [(username, display_name), ...]
has_voice: 是否存在语音数据
"""
from config import load_config
cfg = load_config()
decrypted_dir = cfg["decrypted_dir"]
if not os.path.isdir(decrypted_dir):
raise FileNotFoundError(f"解密目录不存在: {decrypted_dir}\n请先运行「解密数据库」")
contact_map = _load_contact_map(decrypted_dir)
usernames = set()
has_voice = False
# 从消息数据库扫描
msg_dir = os.path.join(decrypted_dir, "message")
if os.path.isdir(msg_dir):
db_files = [
f for f in globmod.glob(os.path.join(msg_dir, "message_*.db"))
if not f.endswith(("_fts.db", "_resource.db"))
]
print(f"找到 {len(db_files)} 个消息数据库", flush=True)
for db_path in db_files:
try:
conn = sqlite3.connect(db_path)
hash_to_uname = {}
for row in conn.execute("SELECT rowid, user_name FROM Name2Id"):
uname = row[1]
if uname:
h = hashlib.md5(uname.encode()).hexdigest()
hash_to_uname[h] = uname
for (tbl,) in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
):
h = tbl[4:]
uname = hash_to_uname.get(h)
if uname:
usernames.add(uname)
conn.close()
except Exception as e:
print(f" 读取 {os.path.basename(db_path)} 失败: {e}", flush=True)
continue
else:
print(f"消息目录不存在: {msg_dir}", flush=True)
# 从语音数据库扫描
voice_db = os.path.join(msg_dir, "media_0.db")
if os.path.exists(voice_db):
try:
conn = sqlite3.connect(voice_db)
name_map = {}
for rowid, uname in conn.execute("SELECT rowid, user_name FROM Name2Id"):
name_map[rowid] = uname
for (cid,) in conn.execute("SELECT DISTINCT chat_name_id FROM VoiceInfo"):
uname = name_map.get(cid)
if uname:
usernames.add(uname)
has_voice = True
conn.close()
except Exception as e:
print(f" 读取语音数据库失败: {e}", flush=True)
print(f"共发现 {len(usernames)} 个会话", flush=True)
result = [(u, _display_name(u, contact_map)) for u in usernames]
result.sort(key=lambda x: x[1].lower())
return result, has_voice
# ── 导出选项对话框 ──────────────────────────────────────────────────────────
class ExportOptionsDialog(tk.Toplevel):
def __init__(self, parent, contacts, has_voice=False):
"""contacts: [(username, display_name), ...]
has_voice: 是否检测到语音数据
"""
super().__init__(parent)
self.title("导出选项")
self.geometry("460x600")
self.transient(parent)
self.grab_set()
self.result = None
self.configure(bg="#f0f0f0")
self._contacts = contacts
self._vars = {} # username -> BooleanVar
# ── 导出格式 ──
fmt_frame = ttk.LabelFrame(self, text="导出格式", padding=6)
fmt_frame.pack(fill="x", padx=12, pady=(10, 4))
self._fmt_csv = tk.BooleanVar(value=True)
self._fmt_html = tk.BooleanVar(value=False)
self._fmt_json = tk.BooleanVar(value=False)
ttk.Checkbutton(fmt_frame, text="CSV默认", variable=self._fmt_csv).pack(side="left", padx=10)
ttk.Checkbutton(fmt_frame, text="HTML", variable=self._fmt_html).pack(side="left", padx=10)
ttk.Checkbutton(fmt_frame, text="JSON", variable=self._fmt_json).pack(side="left", padx=10)
# ── 其他选项 ──
opt_frame = ttk.LabelFrame(self, text="其他选项", padding=6)
opt_frame.pack(fill="x", padx=12, pady=(0, 4))
self._image_var = tk.BooleanVar(value=True)
ttk.Checkbutton(opt_frame, text="导出并解密图片",
variable=self._image_var).pack(anchor="w", padx=8)
self._sns_var = tk.BooleanVar(value=False)
ttk.Checkbutton(opt_frame, text="导出朋友圈动态(文案/评论)",
variable=self._sns_var).pack(anchor="w", padx=8)
self._sns_media_var = tk.BooleanVar(value=False)
ttk.Checkbutton(opt_frame, text=" ↳ 尝试下载朋友圈媒体(可能较慢)",
variable=self._sns_media_var).pack(anchor="w", padx=24)
self._voice_var = tk.BooleanVar(value=False)
if has_voice:
ttk.Checkbutton(opt_frame, text="同时转换语音为 MP3",
variable=self._voice_var).pack(anchor="w", padx=8)
# ── 联系人选择 ──
top = ttk.Frame(self)
top.pack(fill="x", padx=12, pady=(4, 4))
ttk.Label(top, text=f"{len(contacts)} 个会话",
font=("Microsoft YaHei UI", 10)).pack(side="left")
self._all_selected = True
self._toggle_btn = ttk.Button(top, text="取消全选", command=self._toggle_all)
self._toggle_btn.pack(side="right")
# 搜索框
search_frame = ttk.Frame(self)
search_frame.pack(fill="x", padx=12, pady=(0, 4))
self._search_var = tk.StringVar()
self._search_var.trace_add("write", lambda *_: self._filter_list())
ttk.Entry(search_frame, textvariable=self._search_var,
font=("Microsoft YaHei UI", 10)).pack(fill="x")
# 滚动区域
container = ttk.Frame(self)
container.pack(fill="both", expand=True, padx=12, pady=4)
self._canvas = tk.Canvas(container, bg="#ffffff", highlightthickness=0)
scrollbar = ttk.Scrollbar(container, orient="vertical", command=self._canvas.yview)
self._inner = ttk.Frame(self._canvas)
self._inner.bind("<Configure>",
lambda e: self._canvas.configure(scrollregion=self._canvas.bbox("all")))
self._canvas.create_window((0, 0), window=self._inner, anchor="nw")
self._canvas.configure(yscrollcommand=scrollbar.set)
self._canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# 绑定鼠标滚轮
self._canvas.bind("<Enter>", lambda e: self._bind_mousewheel())
self._canvas.bind("<Leave>", lambda e: self._unbind_mousewheel())
# 创建 Checkbutton 列表
self._cb_widgets = []
for username, dname in contacts:
var = tk.BooleanVar(value=True)
self._vars[username] = var
label = f"{dname} ({username})" if dname != username else username
cb = ttk.Checkbutton(self._inner, text=label, variable=var)
cb.pack(anchor="w", padx=6, pady=1)
self._cb_widgets.append((username, dname, cb))
# 底部按钮
bottom = ttk.Frame(self)
bottom.pack(fill="x", padx=12, pady=(4, 10))
ttk.Button(bottom, text="确定", command=self._on_ok).pack(side="right", padx=4)
ttk.Button(bottom, text="取消", command=self._on_cancel).pack(side="right", padx=4)
def _bind_mousewheel(self):
self._canvas.bind_all("<MouseWheel>",
lambda e: self._canvas.yview_scroll(-1 * (e.delta // 120), "units"))
def _unbind_mousewheel(self):
self._canvas.unbind_all("<MouseWheel>")
def _toggle_all(self):
self._all_selected = not self._all_selected
for var in self._vars.values():
var.set(self._all_selected)
self._toggle_btn.configure(text="取消全选" if self._all_selected else "全选")
def _filter_list(self):
keyword = self._search_var.get().strip().lower()
for username, dname, cb in self._cb_widgets:
if not keyword or keyword in dname.lower() or keyword in username.lower():
cb.pack(anchor="w", padx=6, pady=1)
else:
cb.pack_forget()
def _on_ok(self):
formats = []
if self._fmt_csv.get():
formats.append("csv")
if self._fmt_html.get():
formats.append("html")
if self._fmt_json.get():
formats.append("json")
if not formats and not self._voice_var.get() and not self._sns_var.get():
from tkinter import messagebox
messagebox.showwarning("提示", "请至少选择一种导出格式、朋友圈导出或语音转换", parent=self)
return
self.result = {
"contacts": [u for u, var in self._vars.items() if var.get()],
"formats": formats,
"include_voice": self._voice_var.get(),
"include_images": self._image_var.get(),
"include_sns": self._sns_var.get(),
"include_sns_media": self._sns_media_var.get(),
}
self.destroy()
def _on_cancel(self):
self.result = None
self.destroy()
class WxworkExportOptionsDialog(tk.Toplevel):
def __init__(self, parent, conversations):
"""conversations: [{conversation_id, display_name, kind, message_count, last_time}, ...]"""
super().__init__(parent)
self.title("企业微信导出选项")
self.geometry("560x620")
self.transient(parent)
self.grab_set()
self.result = None
self.configure(bg="#f0f0f0")
self._conversations = conversations
self._vars = {}
fmt_frame = ttk.LabelFrame(self, text="导出格式", padding=6)
fmt_frame.pack(fill="x", padx=12, pady=(10, 4))
self._fmt_csv = tk.BooleanVar(value=True)
self._fmt_html = tk.BooleanVar(value=False)
self._fmt_json = tk.BooleanVar(value=False)
ttk.Checkbutton(fmt_frame, text="CSV默认", variable=self._fmt_csv).pack(side="left", padx=10)
ttk.Checkbutton(fmt_frame, text="HTML", variable=self._fmt_html).pack(side="left", padx=10)
ttk.Checkbutton(fmt_frame, text="JSON", variable=self._fmt_json).pack(side="left", padx=10)
top = ttk.Frame(self)
top.pack(fill="x", padx=12, pady=(4, 4))
ttk.Label(top, text=f"{len(conversations)} 个企业微信会话",
font=("Microsoft YaHei UI", 10)).pack(side="left")
self._all_selected = True
self._toggle_btn = ttk.Button(top, text="取消全选", command=self._toggle_all)
self._toggle_btn.pack(side="right")
search_frame = ttk.Frame(self)
search_frame.pack(fill="x", padx=12, pady=(0, 4))
self._search_var = tk.StringVar()
self._search_var.trace_add("write", lambda *_: self._filter_list())
ttk.Entry(search_frame, textvariable=self._search_var,
font=("Microsoft YaHei UI", 10)).pack(fill="x")
container = ttk.Frame(self)
container.pack(fill="both", expand=True, padx=12, pady=4)
self._canvas = tk.Canvas(container, bg="#ffffff", highlightthickness=0)
scrollbar = ttk.Scrollbar(container, orient="vertical", command=self._canvas.yview)
self._inner = ttk.Frame(self._canvas)
self._inner.bind("<Configure>",
lambda e: self._canvas.configure(scrollregion=self._canvas.bbox("all")))
self._canvas.create_window((0, 0), window=self._inner, anchor="nw")
self._canvas.configure(yscrollcommand=scrollbar.set)
self._canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
self._canvas.bind("<Enter>", lambda e: self._bind_mousewheel())
self._canvas.bind("<Leave>", lambda e: self._unbind_mousewheel())
self._cb_widgets = []
for conv in conversations:
cid = conv["conversation_id"]
var = tk.BooleanVar(value=True)
self._vars[cid] = var
last_time = self._format_time(conv.get("last_time"))
suffix = f" · {last_time}" if last_time else ""
label = (
f"[{conv.get('kind', '会话')}] {conv.get('display_name') or cid}"
f" · {conv.get('message_count', 0)}{suffix}"
)
cb = ttk.Checkbutton(self._inner, text=label, variable=var)
cb.pack(anchor="w", padx=6, pady=1)
self._cb_widgets.append((cid, label.lower(), cb))
bottom = ttk.Frame(self)
bottom.pack(fill="x", padx=12, pady=(4, 10))
ttk.Button(bottom, text="确定", command=self._on_ok).pack(side="right", padx=4)
ttk.Button(bottom, text="取消", command=self._on_cancel).pack(side="right", padx=4)
def _format_time(self, value):
if not value:
return ""
try:
from datetime import datetime
return datetime.fromtimestamp(int(value)).strftime("%Y-%m-%d")
except Exception:
return ""
def _bind_mousewheel(self):
self._canvas.bind_all("<MouseWheel>",
lambda e: self._canvas.yview_scroll(-1 * (e.delta // 120), "units"))
def _unbind_mousewheel(self):
self._canvas.unbind_all("<MouseWheel>")
def _toggle_all(self):
self._all_selected = not self._all_selected
for var in self._vars.values():
var.set(self._all_selected)
self._toggle_btn.configure(text="取消全选" if self._all_selected else "全选")
def _filter_list(self):
keyword = self._search_var.get().strip().lower()
for _cid, label, cb in self._cb_widgets:
if not keyword or keyword in label:
cb.pack(anchor="w", padx=6, pady=1)
else:
cb.pack_forget()
def _on_ok(self):
formats = []
if self._fmt_csv.get():
formats.append("csv")
if self._fmt_html.get():
formats.append("html")
if self._fmt_json.get():
formats.append("json")
if not formats:
from tkinter import messagebox
messagebox.showwarning("提示", "请至少选择一种导出格式", parent=self)
return
self.result = {
"conversations": [cid for cid, var in self._vars.items() if var.get()],
"formats": formats,
}
self.destroy()
def _on_cancel(self):
self.result = None
self.destroy()
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("WeChat Decrypt 工具箱")
self.geometry("820x600")
self.resizable(True, True)
self.configure(bg="#f0f0f0")
self._running = False
self._auto_export = False
self._selected_contacts = None
self._export_formats = None
self._include_voice = False
self._include_images = True
self._include_sns = False
self._include_sns_media = False
self._selected_wxwork_conversations = None
self._wxwork_export_formats = None
self._build_ui()
# ── UI 构建 ────────────────────────────────────────────────────────────
def _build_ui(self):
style = ttk.Style(self)
style.theme_use("clam")
style.configure("Big.TButton", font=("Microsoft YaHei UI", 11), padding=(16, 10))
style.configure("TLabel", font=("Microsoft YaHei UI", 10), background="#f0f0f0")
# 标题
title = ttk.Label(self, text="WeChat Decrypt 工具箱", font=("Microsoft YaHei UI", 16, "bold"))
title.pack(pady=(14, 6))
# 按钮区域
btn_frame = ttk.Frame(self)
btn_frame.pack(fill="x", padx=20, pady=(4, 4))
self.btn_decrypt = ttk.Button(
btn_frame, text="① 微信解密", style="Big.TButton",
command=lambda: self._run_task("decrypt")
)
self.btn_decrypt.pack(side="left", expand=True, fill="x", padx=4)
self.btn_imgkey = ttk.Button(
btn_frame, text="② 图片密钥", style="Big.TButton",
command=lambda: self._run_task("find_image_key")
)
self.btn_imgkey.pack(side="left", expand=True, fill="x", padx=4)
self.btn_export = ttk.Button(
btn_frame, text="③ 导出数据", style="Big.TButton",
command=lambda: self._run_task("export")
)
self.btn_export.pack(side="left", expand=True, fill="x", padx=4)
self.btn_sns = ttk.Button(
btn_frame, text="④ 朋友圈图片", style="Big.TButton",
command=lambda: self._run_task("decrypt_sns")
)
self.btn_sns.pack(side="left", expand=True, fill="x", padx=4)
wxwork_frame = ttk.Frame(self)
wxwork_frame.pack(fill="x", padx=20, pady=(0, 6))
self.btn_wxwork = ttk.Button(
wxwork_frame, text="⑤ 企业微信解密", style="Big.TButton",
command=lambda: self._run_task("wxwork_decrypt")
)
self.btn_wxwork.pack(side="left", expand=True, fill="x", padx=4)
self.btn_wxwork_export = ttk.Button(
wxwork_frame, text="⑥ 企业微信导出", style="Big.TButton",
command=lambda: self._run_task("wxwork_export")
)
self.btn_wxwork_export.pack(side="left", expand=True, fill="x", padx=4)
# 提示信息
tips_frame = ttk.LabelFrame(self, text="使用提示", padding=6)
tips_frame.pack(fill="x", padx=20, pady=(0, 4))
tips_text = (
"• 微信解密:需要微信正在运行中,会自动提取密钥并解密\n"
"• 查找图片密钥:先在微信中打开 2-3 张图片查看,然后立即运行\n"
"• 导出数据:选择联系人和格式,可同时导出消息/图片/语音\n"
"• 朋友圈图片解密朋友圈缓存图片_t缩略图自动跳过\n"
"• 企业微信解密:需要企业微信正在运行中,输出到 wxwork_decrypted/\n"
"• 企业微信导出:选择某个人或群,输出 CSV / HTML / JSON 到 wxwork_export/"
)
ttk.Label(tips_frame, text=tips_text, font=("Microsoft YaHei UI", 9),
wraplength=760, justify="left").pack(anchor="w")
# 进度条
self.progress = ttk.Progressbar(self, mode="indeterminate")
self.progress.pack(fill="x", padx=20, pady=(0, 4))
# 日志区域
log_label = ttk.Label(self, text="运行日志:")
log_label.pack(anchor="w", padx=20)
self.log = scrolledtext.ScrolledText(
self, wrap="word", height=18,
font=("Consolas", 10), bg="#1e1e1e", fg="#d4d4d4",
insertbackground="#fff", state="disabled"
)
self.log.pack(fill="both", expand=True, padx=20, pady=(2, 10))
# 底部状态
self.status_var = tk.StringVar(value="就绪")
status = ttk.Label(self, textvariable=self.status_var, font=("Microsoft YaHei UI", 9))
status.pack(anchor="w", padx=20, pady=(0, 8))
# ── 日志写入 ───────────────────────────────────────────────────────────
def _log(self, text: str):
self.log.configure(state="normal")
self.log.insert("end", text)
self.log.see("end")
self.log.configure(state="disabled")
def _clear_log(self):
self.log.configure(state="normal")
self.log.delete("1.0", "end")
self.log.configure(state="disabled")
# ── 按钮状态 ───────────────────────────────────────────────────────────
def _set_buttons(self, enabled: bool):
state = "normal" if enabled else "disabled"
self.btn_decrypt.configure(state=state)
self.btn_imgkey.configure(state=state)
self.btn_export.configure(state=state)
self.btn_sns.configure(state=state)
self.btn_wxwork.configure(state=state)
self.btn_wxwork_export.configure(state=state)
# ── 任务调度 ───────────────────────────────────────────────────────────
def _run_task(self, task: str):
if self._running:
return
self._running = True
self._selected_contacts = None
self._export_formats = None
self._include_voice = False
self._include_sns = False
self._include_sns_media = False
self._selected_wxwork_conversations = None
self._wxwork_export_formats = None
self._clear_log()
self._set_buttons(False)
if task == "export":
self.progress.start(15)
self.status_var.set("正在扫描联系人...")
threading.Thread(
target=self._discover_and_select, daemon=True
).start()
elif task == "find_image_key":
self.progress.start(15)
self.status_var.set("正在扫描微信进程内存...")
threading.Thread(target=self._exec_task, args=(task,), daemon=True).start()
elif task == "decrypt_sns":
self.progress.start(15)
self.status_var.set("正在解密朋友圈图片...")
threading.Thread(target=self._exec_task, args=(task,), daemon=True).start()
elif task == "wxwork_decrypt":
self.progress.start(15)
self.status_var.set("正在解密企业微信数据库...")
threading.Thread(target=self._exec_wxwork_decrypt, daemon=True).start()
elif task == "wxwork_export":
self.progress.start(15)
self.status_var.set("正在扫描企业微信会话...")
threading.Thread(target=self._discover_wxwork_and_select, daemon=True).start()
else:
self.progress.start(15)
labels = {"decrypt": "解密数据库"}
self.status_var.set(f"正在{labels.get(task, task)}...")
threading.Thread(target=self._exec_task, args=(task,), daemon=True).start()
def _discover_and_select(self):
"""后台扫描联系人,然后在主线程弹出选择对话框"""
try:
contacts, has_voice = _discover_contacts()
except Exception as e:
self.after(0, self._log, f"扫描联系人失败: {e}\n")
self.after(0, self._on_task_done)
return
if not contacts:
self.after(0, self._log, "未找到任何联系人/会话\n")
self.after(0, self._on_task_done)
return
self.after(0, self._show_contact_dialog, contacts, has_voice)
def _show_contact_dialog(self, contacts, has_voice):
self.progress.stop()
self.status_var.set(f"请选择导出选项 ({len(contacts)} 个会话)")
dlg = ExportOptionsDialog(self, contacts, has_voice=has_voice)
self.wait_window(dlg)
if dlg.result is None:
self._on_task_done()
return
if not dlg.result["contacts"]:
self._log("未选择任何联系人\n")
self._on_task_done()
return
self._selected_contacts = dlg.result["contacts"]
self._export_formats = dlg.result["formats"]
self._include_voice = dlg.result["include_voice"]
self._include_images = dlg.result["include_images"]
self._include_sns = dlg.result["include_sns"]
self._include_sns_media = dlg.result["include_sns_media"]
self._clear_log()
self.progress.start(15)
n_sel = len(dlg.result["contacts"])
parts = []
if self._export_formats:
parts.append(f"导出 {'/'.join(f.upper() for f in self._export_formats)}")
if self._include_sns:
parts.append("朋友圈")
if self._include_voice:
parts.append("转换语音")
action = " + ".join(parts) or "处理"
self.status_var.set(f"正在{action}...{n_sel}/{len(contacts)} 个联系人)")
threading.Thread(target=self._exec_combined, daemon=True).start()
def _discover_wxwork_and_select(self):
"""后台扫描企业微信会话,然后在主线程弹出选择对话框"""
try:
from export_wxwork_messages import discover_conversations
conversations = discover_conversations()
except Exception as e:
self.after(0, self._log, f"扫描企业微信会话失败: {e}\n")
self.after(0, self._on_task_done)
return
if not conversations:
self.after(0, self._log, "未找到任何企业微信会话,请先运行「企业微信解密」\n")
self.after(0, self._on_task_done)
return
self.after(0, self._show_wxwork_dialog, conversations)
def _show_wxwork_dialog(self, conversations):
self.progress.stop()
self.status_var.set(f"请选择企业微信导出选项 ({len(conversations)} 个会话)")
dlg = WxworkExportOptionsDialog(self, conversations)
self.wait_window(dlg)
if dlg.result is None:
self._on_task_done()
return
if not dlg.result["conversations"]:
self._log("未选择任何企业微信会话\n")
self._on_task_done()
return
self._selected_wxwork_conversations = dlg.result["conversations"]
self._wxwork_export_formats = dlg.result["formats"]
self._clear_log()
self.progress.start(15)
n_sel = len(dlg.result["conversations"])
self.status_var.set(
f"正在导出企业微信 {'/'.join(f.upper() for f in self._wxwork_export_formats)}..."
f"{n_sel}/{len(conversations)} 个会话)"
)
threading.Thread(target=self._exec_wxwork_export, daemon=True).start()
# ── 子进程执行 ─────────────────────────────────────────────────────────
def _run_subprocess(self, task: str) -> int:
"""运行子进程,返回退出码"""
cmd = [sys.executable, "--task", task]
self.after(0, self._log, f">>> {' '.join(cmd)}\n\n")
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
env["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR
env["WECHAT_DECRYPT_GUI"] = "1"
env["WECHAT_DECRYPT_NONINTERACTIVE"] = "1"
if self._selected_contacts:
env["WECHAT_EXPORT_CONTACTS"] = ",".join(self._selected_contacts)
if self._export_formats:
env["WECHAT_EXPORT_FORMATS"] = ",".join(self._export_formats)
env["WECHAT_EXPORT_IMAGES"] = "1" if getattr(self, '_include_images', True) else "0"
if getattr(self, '_include_sns_media', False):
env["WECHAT_SNS_DOWNLOAD_MEDIA"] = "1"
if self._selected_wxwork_conversations:
env["WXWORK_EXPORT_CONVERSATIONS"] = ",".join(self._selected_wxwork_conversations)
if self._wxwork_export_formats:
env["WXWORK_EXPORT_FORMATS"] = ",".join(self._wxwork_export_formats)
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=BASE_DIR,
env=env,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
)
for raw in proc.stdout:
line = raw.decode("utf-8", errors="replace")
self.after(0, self._log, line)
proc.wait()
return proc.returncode
def _exec_combined(self):
"""执行导出(消息 + 可选语音)"""
try:
if self._export_formats:
rc = self._run_subprocess("export")
if rc != 0:
self.after(0, self._log, f"\n❌ 导出失败 (返回码 {rc})\n")
self.after(0, self.status_var.set, f"失败 (返回码 {rc})")
return
if getattr(self, '_include_sns', False):
if self._export_formats:
self.after(0, self._log, "\n\n━━━ 开始导出朋友圈 ━━━\n\n")
rc = self._run_subprocess("export_sns")
if rc != 0:
self.after(0, self._log, f"\n❌ 朋友圈导出失败 (返回码 {rc})\n")
self.after(0, self.status_var.set, f"朋友圈导出失败 (返回码 {rc})")
return
if self._include_voice:
if self._export_formats or getattr(self, '_include_sns', False):
self.after(0, self._log, "\n\n━━━ 开始转换语音 ━━━\n\n")
rc = self._run_subprocess("voice")
if rc != 0:
self.after(0, self._log, f"\n❌ 语音转换失败 (返回码 {rc})\n")
self.after(0, self.status_var.set, f"语音转换失败 (返回码 {rc})")
return
self.after(0, self._log, "\n✅ 全部完成!\n")
self.after(0, self.status_var.set, "完成")
except Exception as e:
self.after(0, self._log, f"\n❌ 异常: {e}\n")
self.after(0, self.status_var.set, "异常")
finally:
self._selected_contacts = None
self._export_formats = None
self._include_voice = False
self._include_images = True
self._include_sns = False
self._include_sns_media = False
self.after(0, self._on_task_done)
def _exec_wxwork_decrypt(self):
"""执行企业微信 key 提取 + 数据库解密。"""
try:
self.after(0, self._log, "━━━ 开始提取企业微信密钥 ━━━\n\n")
rc = self._run_subprocess("find_wxwork_keys")
if rc != 0:
self.after(0, self._log, f"\n❌ 企业微信密钥提取失败 (返回码 {rc})\n")
self.after(0, self.status_var.set, f"企业微信密钥提取失败 (返回码 {rc})")
return
self.after(0, self._log, "\n\n━━━ 开始解密企业微信数据库 ━━━\n\n")
rc = self._run_subprocess("decrypt_wxwork")
if rc != 0:
self.after(0, self._log, f"\n❌ 企业微信数据库解密失败 (返回码 {rc})\n")
self.after(0, self.status_var.set, f"企业微信解密失败 (返回码 {rc})")
return
self.after(0, self._log, "\n✅ 企业微信解密完成!输出目录: wxwork_decrypted\n")
self.after(0, self.status_var.set, "企业微信解密完成")
except Exception as e:
self.after(0, self._log, f"\n❌ 异常: {e}\n")
self.after(0, self.status_var.set, "异常")
finally:
self.after(0, self._on_task_done)
def _exec_wxwork_export(self):
"""执行企业微信消息导出。"""
try:
rc = self._run_subprocess("export_wxwork")
if rc != 0:
self.after(0, self._log, f"\n❌ 企业微信导出失败 (返回码 {rc})\n")
self.after(0, self.status_var.set, f"企业微信导出失败 (返回码 {rc})")
return
self.after(0, self._log, "\n✅ 企业微信导出完成!输出目录: wxwork_export\n")
self.after(0, self.status_var.set, "企业微信导出完成")
except Exception as e:
self.after(0, self._log, f"\n❌ 异常: {e}\n")
self.after(0, self.status_var.set, "异常")
finally:
self._selected_wxwork_conversations = None
self._wxwork_export_formats = None
self.after(0, self._on_task_done)
def _exec_task(self, task: str):
"""执行单一任务(解密)"""
try:
rc = self._run_subprocess(task)
if rc == 0:
self.after(0, self._log, "\n✅ 完成!\n")
self.after(0, self.status_var.set, "完成")
if task == "decrypt":
self._auto_export = True
else:
self.after(0, self._log, f"\n❌ 进程退出,返回码: {rc}\n")
self.after(0, self.status_var.set, f"失败 (返回码 {rc})")
except Exception as e:
self.after(0, self._log, f"\n❌ 异常: {e}\n")
self.after(0, self.status_var.set, "异常")
finally:
self.after(0, self._on_task_done)
def _on_task_done(self):
self._running = False
self.progress.stop()
self._set_buttons(True)
if self._auto_export:
self._auto_export = False
self._log("\n解密完成,自动进入导出流程...\n\n")
self.after(500, lambda: self._run_task("export"))
if __name__ == "__main__":
app = App()
app.mainloop()

181
batch_decrypt_images.py Normal file
View File

@@ -0,0 +1,181 @@
"""批量解密 .dat 图片文件
用法: python batch_decrypt_images.py <文件夹路径> [输出目录]
递归扫描指定文件夹下的所有 .dat 文件并解密。
输出目录默认为 <文件夹路径>_decoded/,保持原有子目录结构。
"""
import os
import sys
import glob
import struct
# Windows 控制台 UTF-8
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from config import load_config
_cfg = load_config()
IMAGE_AES_KEY = _cfg.get("image_aes_key", "")
IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88)
# ── V2/V1 magic ──────────────────────────────────────────────────────────────
_V2_MAGIC_FULL = b'\x07\x08V2\x08\x07'
_V1_MAGIC_FULL = b'\x07\x08V1\x08\x07'
_IMAGE_MAGICS = {
'jpg': [0xFF, 0xD8, 0xFF],
'png': [0x89, 0x50, 0x4E, 0x47],
'gif': [0x47, 0x49, 0x46, 0x38],
'webp': [0x52, 0x49, 0x46, 0x46],
'bmp': [0x42, 0x4D],
'tif': [0x49, 0x49, 0x2A, 0x00],
}
def _detect_format(header):
if header[:3] == bytes([0xFF, 0xD8, 0xFF]):
return 'jpg'
if header[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
return 'png'
if header[:3] == b'GIF':
return 'gif'
if header[:2] == b'BM':
return 'bmp'
if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WEBP':
return 'webp'
if header[:4] == bytes([0x49, 0x49, 0x2A, 0x00]):
return 'tif'
if header[:4] == b'wxgf':
return 'hevc'
return 'bin'
def decrypt_dat(dat_path):
"""解密单个 .dat 文件,返回 (bytes, format) 或 (None, None)"""
with open(dat_path, 'rb') as f:
data = f.read()
if len(data) < 6:
return None, None
head6 = data[:6]
# V2 / V1 格式 (AES-ECB + XOR)
if head6 in (_V2_MAGIC_FULL, _V1_MAGIC_FULL):
if head6 == _V1_MAGIC_FULL:
aes_key = b'cfcd208495d565ef'
elif IMAGE_AES_KEY:
aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16]
else:
return None, None
if len(aes_key) < 16:
return None, None
try:
from Crypto.Cipher import AES
from Crypto.Util import Padding
if len(data) < 15:
return None, None
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
aligned = aes_size - ~(~aes_size % 16)
offset = 15
if offset + aligned > len(data):
return None, None
cipher = AES.new(aes_key[:16], AES.MODE_ECB)
dec_aes = Padding.unpad(cipher.decrypt(data[offset:offset+aligned]), AES.block_size)
offset += aligned
raw_end = len(data) - xor_size
raw_data = data[offset:raw_end] if offset < raw_end else b''
xor_data = data[raw_end:]
xor_key = IMAGE_XOR_KEY if isinstance(IMAGE_XOR_KEY, int) else 0x88
dec_xor = bytes(b ^ xor_key for b in xor_data)
result = dec_aes + raw_data + dec_xor
fmt = _detect_format(result[:16])
return result, fmt
except Exception as e:
print(f" AES 解密失败: {e}")
return None, None
# 旧 XOR 格式
for fmt_name, magic in _IMAGE_MAGICS.items():
key = data[0] ^ magic[0]
match = all(i < len(data) and (data[i] ^ key) == magic[i] for i in range(len(magic)))
if match:
result = bytes(b ^ key for b in data)
fmt = _detect_format(result[:16])
return result, fmt
return None, None
def main():
if len(sys.argv) < 2:
print("用法: python batch_decrypt_images.py <文件夹路径> [输出目录]")
print(" 递归扫描文件夹下所有 .dat 文件并解密")
sys.exit(1)
source_dir = os.path.abspath(sys.argv[1])
if not os.path.isdir(source_dir):
print(f"目录不存在: {source_dir}")
sys.exit(1)
if len(sys.argv) >= 3:
output_dir = os.path.abspath(sys.argv[2])
else:
output_dir = source_dir.rstrip(os.sep) + "_decoded"
# 递归收集所有 .dat 文件
dat_files = []
for root, _dirs, files in os.walk(source_dir):
for f in files:
if f.lower().endswith('.dat'):
dat_files.append(os.path.join(root, f))
dat_files.sort()
print(f"源目录: {source_dir}")
print(f"输出目录: {output_dir}")
print(f"找到 {len(dat_files)} 个 .dat 文件")
print()
total = len(dat_files)
success = 0
skipped = 0
failed = 0
for dat_path in dat_files:
# 保持相对目录结构
rel = os.path.relpath(dat_path, source_dir)
rel_dir = os.path.dirname(rel)
out_subdir = os.path.join(output_dir, rel_dir) if rel_dir else output_dir
fname = os.path.splitext(os.path.basename(dat_path))[0]
# 去除 _t / _h 后缀获取基础名
base_name = fname
for suffix in ('_t', '_h'):
if base_name.endswith(suffix):
base_name = base_name[:-len(suffix)]
break
# 检查是否已解密
existing = glob.glob(os.path.join(out_subdir, f"{base_name}.*"))
if existing:
skipped += 1
continue
img_bytes, fmt = decrypt_dat(dat_path)
if not img_bytes or fmt == 'bin':
failed += 1
continue
os.makedirs(out_subdir, exist_ok=True)
out_path = os.path.join(out_subdir, f"{base_name}.{fmt}")
with open(out_path, 'wb') as f:
f.write(img_bytes)
success += 1
print(f"完成: 共 {total} 个文件, 成功 {success}, 跳过(已存在) {skipped}, 失败 {failed}")
print(f"输出: {output_dir}")
if __name__ == "__main__":
main()

59
build.bat Normal file
View File

@@ -0,0 +1,59 @@
@echo off
chcp 65001 >nul
echo ========================================
echo WeChatDecrypt 打包脚本
echo ========================================
echo.
:: 检查 pyinstaller
where pyinstaller >nul 2>&1
if errorlevel 1 (
echo [!] 未找到 pyinstaller正在安装...
pip install pyinstaller
)
echo [*] 开始打包...
echo.
pyinstaller --noconfirm --onefile --console --name "WeChatDecrypt" ^
--add-data "main.py;." ^
--add-data "config.py;." ^
--add-data "decrypt_db.py;." ^
--add-data "export_messages.py;." ^
--add-data "voice_to_mp3.py;." ^
--add-data "find_all_keys.py;." ^
--add-data "find_all_keys_windows.py;." ^
--add-data "find_all_keys_linux.py;." ^
--add-data "find_wxwork_keys.py;." ^
--add-data "decrypt_wxwork_db.py;." ^
--add-data "export_wxwork_messages.py;." ^
--add-data "wxwork_crypto.py;." ^
--add-data "key_scan_common.py;." ^
--add-data "key_utils.py;." ^
--add-data "decode_image.py;." ^
--add-data "find_image_key.py;." ^
--add-data "find_image_key_monitor.py;." ^
--add-data "decrypt_sns.py;." ^
--add-data "export_sns.py;." ^
--add-data "monitor.py;." ^
--add-data "monitor_web.py;." ^
--add-data "mcp_server.py;." ^
--add-data "config.example.json;." ^
--collect-all pilk ^
app_gui.py
if errorlevel 1 (
echo.
echo [!] 打包失败
pause
exit /b 1
)
echo.
echo ========================================
echo 打包完成!
echo 输出: dist\WeChatDecrypt.exe
for %%F in (dist\WeChatDecrypt.exe) do echo 大小: %%~zF bytes
echo ========================================
echo.
pause

View File

@@ -2,5 +2,10 @@
"db_dir": "D:\\xwechat_files\\your_wxid\\db_storage",
"keys_file": "all_keys.json",
"decrypted_dir": "decrypted",
"wechat_process": "Weixin.exe"
"wechat_process": "Weixin.exe",
"wxwork_db_dir": "",
"wxwork_keys_file": "wxwork_keys.json",
"wxwork_decrypted_dir": "wxwork_decrypted",
"wxwork_export_dir": "wxwork_export",
"wxwork_process": "WXWork.exe"
}

View File

@@ -10,6 +10,18 @@ import sys
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
# 打包后 __file__ 指向临时目录优先使用环境变量或5 cwd
def _app_base_dir():
d = os.environ.get("WECHAT_DECRYPT_APP_DIR")
if d and os.path.isdir(d):
return d
return os.path.dirname(os.path.abspath(__file__))
def _config_file_path():
base = _app_base_dir()
p = os.path.join(base, "config.json")
if os.path.exists(p):
return p
return CONFIG_FILE
_SYSTEM = platform.system().lower()
if _SYSTEM == "linux":
@@ -31,6 +43,11 @@ _DEFAULT = {
"decrypted_dir": "decrypted",
"decoded_image_dir": "decoded_images",
"wechat_process": _DEFAULT_PROCESS,
"wxwork_db_dir": "",
"wxwork_keys_file": "wxwork_keys.json",
"wxwork_decrypted_dir": "wxwork_decrypted",
"wxwork_export_dir": "wxwork_export",
"wxwork_process": "WXWork.exe",
# 语音转录后端: "local" (默认, 本地 Whisper) 或 "openai" (OpenAI API)
# 切到 openai 时语音将上传至 OpenAI 服务器, 详见 README "语音转录隐私" 章节
"transcription_backend": "local",
@@ -44,7 +61,11 @@ def _choose_candidate(candidates):
if len(candidates) == 1:
return candidates[0]
if len(candidates) > 1:
if not sys.stdin.isatty():
if (
os.environ.get("WECHAT_DECRYPT_NONINTERACTIVE") == "1"
or os.environ.get("WECHAT_DECRYPT_GUI") == "1"
or not sys.stdin.isatty()
):
return candidates[0]
print("[!] 检测到多个微信数据目录(请选择当前正在运行的微信账号):")
for i, c in enumerate(candidates, 1):
@@ -210,12 +231,13 @@ def auto_detect_db_dir():
def load_config():
cfg = {}
if os.path.exists(CONFIG_FILE):
config_file = _config_file_path()
if os.path.exists(config_file):
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
with open(config_file, encoding="utf-8") as f:
cfg = json.load(f)
except json.JSONDecodeError:
print(f"[!] {CONFIG_FILE} 格式损坏,将使用默认配置")
print(f"[!] {config_file} 格式损坏,将使用默认配置")
cfg = {}
# db_dir 缺失或仍为模板值时,尝试自动检测
db_dir = cfg.get("db_dir", "")
@@ -224,15 +246,15 @@ def load_config():
if detected:
print(f"[+] 自动检测到微信数据目录: {detected}")
cfg = {**_DEFAULT, **cfg, "db_dir": detected}
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
with open(config_file, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"[+] 已保存到: {CONFIG_FILE}")
print(f"[+] 已保存到: {config_file}")
else:
if not os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
if not os.path.exists(config_file):
with open(config_file, "w", encoding="utf-8") as f:
json.dump(_DEFAULT, f, indent=4, ensure_ascii=False)
print(f"[!] 未能自动检测微信数据目录")
print(f" 请手动编辑 {CONFIG_FILE} 中的 db_dir 字段")
print(f" 请手动编辑 {config_file} 中的 db_dir 字段")
if _SYSTEM == "linux":
print(" Linux 默认路径类似: ~/Documents/xwechat_files/<wxid>/db_storage")
elif _SYSTEM == "darwin":
@@ -243,6 +265,14 @@ def load_config():
else:
cfg = {**_DEFAULT, **cfg}
# 将相对路径转为绝对路径
base = _app_base_dir()
for key in (
"keys_file", "decrypted_dir", "decoded_image_dir",
"wxwork_keys_file", "wxwork_decrypted_dir", "wxwork_export_dir",
):
if key in cfg and cfg[key] and not os.path.isabs(cfg[key]):
cfg[key] = os.path.join(base, cfg[key])
# 路径展开:先 expanduser(~ 展开)+ expandvars($HOME / %USERPROFILE% 展开),
# 再判 isabs;还相对就 join 项目根。这样 config 里既能写
# "all_keys.json"(项目根相对),也能写 "~/Documents/wechat_decrypted" /
@@ -266,8 +296,34 @@ def load_config():
else:
cfg["wechat_base_dir"] = db_dir
# 输出目录:<app_dir>/wechat_files/<wxid>/
wxid = os.path.basename(os.path.normpath(cfg["wechat_base_dir"]))
cfg["output_base_dir"] = os.path.join(base, "wechat_files", wxid)
# decoded_image_dir 默认值
if "decoded_image_dir" not in cfg:
cfg["decoded_image_dir"] = os.path.join(base, "decoded_images")
# 自动检测 WeChat Files 目录FileStorage/MsgAttach, FileStorage/Sns/Cache
if not cfg.get("wechat_files_dir"):
wechat_files_base = os.path.join(os.path.expanduser("~"), "Documents", "WeChat Files")
if os.path.isdir(wechat_files_base):
# xwechat_files 的 wxid 可能带后缀如 _1d4c需要模糊匹配
wxid_prefix = wxid.rsplit("_", 1)[0] if "_" in wxid else wxid
for d in os.listdir(wechat_files_base):
if d == wxid or d == wxid_prefix or wxid.startswith(d):
candidate = os.path.join(wechat_files_base, d)
if os.path.isdir(os.path.join(candidate, "FileStorage")):
cfg["wechat_files_dir"] = candidate
break
wf_dir = cfg.get("wechat_files_dir", "")
cfg["msgattach_dir"] = os.path.join(wf_dir, "FileStorage", "MsgAttach") if wf_dir else ""
cfg["sns_cache_dir"] = os.path.join(wf_dir, "FileStorage", "Sns", "Cache") if wf_dir else ""
# xwechat_files 图片/缓存路径
wb = cfg["wechat_base_dir"]
cfg["xwechat_attach_dir"] = os.path.join(wb, "msg", "attach") if wb else ""
cfg["xwechat_cache_dir"] = os.path.join(wb, "cache") if wb else ""
return cfg

272
decrypt_sns.py Normal file
View File

@@ -0,0 +1,272 @@
"""解密微信朋友圈图片缓存
来源1: WeChat Files/FileStorage/Sns/Cache/<YYYY-MM>/<hash>[_t|_d]
来源2: xwechat_files/cache/<YYYY-MM>/Sns/Img/<hex>/<hash>
输出目录: <output_base_dir>/朋友圈图片/<YYYY-MM>/
_t 后缀为缩略图(跳过)
"""
import os
import sys
import glob
import struct
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from config import load_config
_cfg = load_config()
SNS_CACHE_DIR = _cfg.get("sns_cache_dir", "")
XWECHAT_CACHE_DIR = _cfg.get("xwechat_cache_dir", "")
OUTPUT_DIR = os.path.join(_cfg["output_base_dir"], "朋友圈图片")
IMAGE_AES_KEY = _cfg.get("image_aes_key")
IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88)
# ── V2/V1 magic ──────────────────────────────────────────────────────────────
_V2_MAGIC_FULL = b'\x07\x08V2\x08\x07'
_V1_MAGIC_FULL = b'\x07\x08V1\x08\x07'
_IMAGE_MAGICS = {
'jpg': [0xFF, 0xD8, 0xFF],
'png': [0x89, 0x50, 0x4E, 0x47],
'gif': [0x47, 0x49, 0x46, 0x38],
'webp': [0x52, 0x49, 0x46, 0x46],
'bmp': [0x42, 0x4D],
'tif': [0x49, 0x49, 0x2A, 0x00],
}
def _detect_format(header):
if header[:3] == bytes([0xFF, 0xD8, 0xFF]):
return 'jpg'
if header[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
return 'png'
if header[:3] == b'GIF':
return 'gif'
if header[:2] == b'BM':
return 'bmp'
if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WEBP':
return 'webp'
if header[:4] == bytes([0x49, 0x49, 0x2A, 0x00]):
return 'tif'
if header[:4] == b'wxgf':
return 'hevc'
return 'bin'
def decrypt_dat(dat_path):
"""解密单个 .dat 文件,返回 (bytes, format) 或 (None, None)"""
with open(dat_path, 'rb') as f:
data = f.read()
if len(data) < 6:
return None, None
head6 = data[:6]
# V2 / V1 格式
if head6 in (_V2_MAGIC_FULL, _V1_MAGIC_FULL):
if head6 == _V1_MAGIC_FULL:
aes_key = b'cfcd208495d565ef'
elif IMAGE_AES_KEY:
aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16]
else:
return None, None
if not aes_key or len(aes_key) < 16:
return None, None
try:
from Crypto.Cipher import AES
from Crypto.Util import Padding
if len(data) < 15:
return None, None
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
aligned = aes_size - ~(~aes_size % 16)
offset = 15
if offset + aligned > len(data):
return None, None
cipher = AES.new(aes_key[:16], AES.MODE_ECB)
dec_aes = Padding.unpad(cipher.decrypt(data[offset:offset+aligned]), AES.block_size)
offset += aligned
raw_end = len(data) - xor_size
raw_data = data[offset:raw_end] if offset < raw_end else b''
xor_data = data[raw_end:]
xor_key = IMAGE_XOR_KEY if isinstance(IMAGE_XOR_KEY, int) else 0x88
dec_xor = bytes(b ^ xor_key for b in xor_data)
result = dec_aes + raw_data + dec_xor
fmt = _detect_format(result[:16])
return result, fmt
except Exception as e:
print(f" AES 解密失败: {e}")
return None, None
# 旧 XOR 格式
for fmt_name, magic in _IMAGE_MAGICS.items():
key = data[0] ^ magic[0]
match = all(i < len(data) and (data[i] ^ key) == magic[i] for i in range(len(magic)))
if match:
result = bytes(b ^ key for b in data)
fmt = _detect_format(result[:16])
return result, fmt
return None, None
def _collect_xwechat_sns_files():
"""收集 xwechat cache/<YYYY-MM>/Sns/Img/<hex>/ 下的所有文件
返回 {month: [(file_path, basename), ...], ...}
"""
result = {}
if not XWECHAT_CACHE_DIR or not os.path.isdir(XWECHAT_CACHE_DIR):
return result
try:
months = sorted(os.listdir(XWECHAT_CACHE_DIR))
except OSError:
return result
for month in months:
sns_img = os.path.join(XWECHAT_CACHE_DIR, month, "Sns", "Img")
if not os.path.isdir(sns_img):
continue
files = []
try:
hex_dirs = os.listdir(sns_img)
except OSError:
continue
for hd in hex_dirs:
hd_path = os.path.join(sns_img, hd)
if not os.path.isdir(hd_path):
continue
try:
for fname in os.listdir(hd_path):
fp = os.path.join(hd_path, fname)
if os.path.isfile(fp):
files.append((fp, fname))
except OSError:
continue
if files:
result[month] = files
return result
def main():
has_wechat = SNS_CACHE_DIR and os.path.isdir(SNS_CACHE_DIR)
has_xwechat = XWECHAT_CACHE_DIR and os.path.isdir(XWECHAT_CACHE_DIR)
if not has_wechat and not has_xwechat:
print(f"朋友圈缓存目录不存在:")
print(f" WeChat Files: {SNS_CACHE_DIR}")
print(f" xwechat: {XWECHAT_CACHE_DIR}")
print("请确认 config.json 中的路径配置正确")
return
print(f"输出目录: {OUTPUT_DIR}")
total = 0
success = 0
skipped_thumb = 0
skipped_exist = 0
failed = 0
# ── 来源1: WeChat Files/FileStorage/Sns/Cache/<YYYY-MM>/ ──
if has_wechat:
print(f"\n[来源1] WeChat Files: {SNS_CACHE_DIR}")
months = sorted(d for d in os.listdir(SNS_CACHE_DIR)
if os.path.isdir(os.path.join(SNS_CACHE_DIR, d)))
has_month_dirs = any(len(m) == 7 and m[4] == '-' for m in months)
if has_month_dirs:
print(f" 时间目录: {len(months)}")
for month in months:
month_src = os.path.join(SNS_CACHE_DIR, month)
month_out = os.path.join(OUTPUT_DIR, month)
stats = _process_dir_stats(month_src, month_out, month)
total += stats[0]; success += stats[1]; skipped_thumb += stats[2]
skipped_exist += stats[3]; failed += stats[4]
else:
stats = _process_dir_stats(SNS_CACHE_DIR, OUTPUT_DIR, "")
total, success, skipped_thumb, skipped_exist, failed = stats
# ── 来源2: xwechat cache/<YYYY-MM>/Sns/Img/<hex>/ ──
if has_xwechat:
print(f"\n[来源2] xwechat: {XWECHAT_CACHE_DIR}")
xw_files = _collect_xwechat_sns_files()
if not xw_files:
print(" 未找到 Sns/Img 文件")
else:
print(f" 时间目录: {len(xw_files)}")
for month, file_list in sorted(xw_files.items()):
month_out = os.path.join(OUTPUT_DIR, month)
stats = _process_file_list(file_list, month_out, month)
total += stats[0]; success += stats[1]; skipped_thumb += stats[2]
skipped_exist += stats[3]; failed += stats[4]
print(f"\n完成: 共 {total} 个文件")
print(f" 成功解密: {success}")
print(f" 跳过缩略图(_t): {skipped_thumb}")
print(f" 跳过已存在: {skipped_exist}")
print(f" 解密失败: {failed}")
print(f"输出: {os.path.abspath(OUTPUT_DIR)}")
def _process_dir_stats(src_dir, out_dir, label):
"""处理一个目录中的所有文件,返回 (total, success, skipped_thumb, skipped_exist, failed)"""
try:
all_files = sorted(os.listdir(src_dir))
except OSError:
return (0, 0, 0, 0, 0)
dat_files = [(os.path.join(src_dir, f), f) for f in all_files
if os.path.isfile(os.path.join(src_dir, f))]
return _process_file_list(dat_files, out_dir, label)
def _process_file_list(file_list, out_dir, label):
"""处理文件列表 [(file_path, basename), ...], 返回 (total, success, skipped_thumb, skipped_exist, failed)"""
total = 0
success = 0
skipped_thumb = 0
skipped_exist = 0
failed = 0
if not file_list:
return (0, 0, 0, 0, 0)
if label:
print(f" [{label}] {len(file_list)} 个文件")
month_ok = 0
for file_path, fname in file_list:
total += 1
# 跳过缩略图
if fname.endswith('_t'):
skipped_thumb += 1
continue
# 去掉 _d 后缀得到基础名
base_name = fname
if base_name.endswith('_d'):
base_name = base_name[:-2]
# 检查是否已存在
existing = glob.glob(os.path.join(out_dir, f"{base_name}.*"))
if existing:
skipped_exist += 1
continue
img_bytes, fmt = decrypt_dat(file_path)
if not img_bytes or fmt in ('bin', 'hevc'):
failed += 1
continue
os.makedirs(out_dir, exist_ok=True)
out_path = os.path.join(out_dir, f"{base_name}.{fmt}")
with open(out_path, 'wb') as f:
f.write(img_bytes)
success += 1
month_ok += 1
if month_ok > 0 and label:
print(f" 解密成功: {month_ok}")
return (total, success, skipped_thumb, skipped_exist, failed)
if __name__ == "__main__":
main()

176
decrypt_wxwork_db.py Normal file
View File

@@ -0,0 +1,176 @@
"""
Decrypt WXWork databases encrypted with wxSQLite3 AES-128-CBC.
This handles the database page format. A 16-byte raw key is still required,
either from wxwork_keys.json or via --key.
"""
import argparse
import json
import os
import shutil
import sys
from key_utils import get_key_info, strip_key_metadata
from wxwork_crypto import (
decrypt_wxwork_database,
is_plain_sqlite_page,
is_wxsqlite3_aes128_page1,
verify_sqlite_file,
verify_wxsqlite3_aes128_key,
)
def _app_paths():
from config import _app_base_dir, _config_file_path
return _app_base_dir(), _config_file_path()
def _load_config():
base, config_file = _app_paths()
cfg = {}
if os.path.exists(config_file):
with open(config_file, encoding="utf-8") as f:
cfg = json.load(f)
db_dir = cfg.get("wxwork_db_dir", "")
if not db_dir or not os.path.isdir(db_dir):
from find_wxwork_keys import auto_detect_wxwork_db_dir
detected = auto_detect_wxwork_db_dir()
if detected:
db_dir = detected
else:
raise RuntimeError("wxwork_db_dir is not configured")
keys_file = cfg.get("wxwork_keys_file", "wxwork_keys.json")
if not os.path.isabs(keys_file):
keys_file = os.path.join(base, keys_file)
out_dir = cfg.get("wxwork_decrypted_dir", "wxwork_decrypted")
if not os.path.isabs(out_dir):
out_dir = os.path.join(base, out_dir)
return {
"db_dir": db_dir,
"keys_file": keys_file,
"out_dir": out_dir,
"global_key": cfg.get("wxwork_db_key", ""),
}
def _parse_key_hex(value):
value = (value or "").strip()
if value.startswith("x'") and value.endswith("'"):
value = value[2:-1]
if len(value) != 32:
raise ValueError("WXWork wxSQLite3 AES-128 key must be 32 hex chars")
return bytes.fromhex(value)
def _load_keys(keys_file):
if not os.path.exists(keys_file):
return {}
with open(keys_file, encoding="utf-8") as f:
return strip_key_metadata(json.load(f))
def _iter_db_files(db_dir):
for root, dirs, files in os.walk(db_dir):
dirs[:] = [d for d in dirs if d not in ("-journal",)]
for name in files:
if not name.endswith(".db") or name.endswith("-wal") or name.endswith("-shm"):
continue
path = os.path.join(root, name)
rel = os.path.relpath(path, db_dir)
yield rel, path
def main(argv=None):
parser = argparse.ArgumentParser(description="Decrypt WXWork wxSQLite3 AES-128 databases")
parser.add_argument("--key", help="16-byte raw key as 32 hex chars")
args = parser.parse_args(argv)
cfg = _load_config()
db_dir = cfg["db_dir"]
out_dir = cfg["out_dir"]
keys_file = cfg["keys_file"]
keys = _load_keys(keys_file)
global_key = None
key_arg = args.key or cfg.get("global_key")
if key_arg:
global_key = _parse_key_hex(key_arg)
print("=" * 60)
print(" WXWork Database Decryptor")
print("=" * 60)
print(f"DB dir: {db_dir}")
print(f"Output: {out_dir}")
if keys:
print(f"Loaded {len(keys)} per-DB keys from {keys_file}")
elif global_key:
print("Using global key from argument/config")
else:
print(f"No key available. Run find_wxwork_keys.py or pass --key.")
return 1
os.makedirs(out_dir, exist_ok=True)
success = 0
copied = 0
failed = 0
for rel, path in sorted(_iter_db_files(db_dir)):
out_path = os.path.join(out_dir, rel)
with open(path, "rb") as f:
page1 = f.read(4096)
if is_plain_sqlite_page(page1):
os.makedirs(os.path.dirname(out_path), exist_ok=True)
shutil.copy2(path, out_path)
copied += 1
print(f"COPY: {rel} (plain SQLite)")
continue
if not is_wxsqlite3_aes128_page1(page1):
failed += 1
print(f"SKIP: {rel} (unknown encrypted format)")
continue
key = global_key
key_info = get_key_info(keys, rel) if keys else None
if key_info:
try:
key = _parse_key_hex(key_info["enc_key"])
except (KeyError, ValueError) as exc:
failed += 1
print(f"FAIL: {rel} (bad key entry: {exc})")
continue
if key is None:
failed += 1
print(f"SKIP: {rel} (no key)")
continue
if not verify_wxsqlite3_aes128_key(key, page1):
failed += 1
print(f"FAIL: {rel} (key validation failed)")
continue
try:
decrypt_wxwork_database(path, out_path, key)
tables = verify_sqlite_file(out_path)
success += 1
table_preview = ", ".join(tables[:5])
suffix = f" tables: {table_preview}" if table_preview else " no tables"
print(f"OK: {rel} ({suffix})")
except Exception as exc:
failed += 1
print(f"FAIL: {rel} ({exc})")
print(f"\nResult: {success} decrypted, {copied} copied, {failed} failed")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())

713
export_messages.py Normal file
View File

@@ -0,0 +1,713 @@
"""导出微信消息记录到 CSV / HTML / JSON
目录结构: <output_base_dir>/<display_name>/messages.csv|html|json
图片导出: <output_base_dir>/<display_name>/image/<md5>.<ext>
"""
import base64
import sqlite3
import glob
import hashlib
import os
import json
import csv
import re
import struct
import sys
import xml.etree.ElementTree as ET
from datetime import datetime
import zstandard as zstd
# Windows PowerShell 控制台设为 UTF-8
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from config import load_config
_cfg = load_config()
MSG_DB_DIR = os.path.join(_cfg["decrypted_dir"], "message")
CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db")
OUTPUT_DIR = _cfg["output_base_dir"]
# 图片相关配置
WECHAT_BASE_DIR = _cfg.get("wechat_base_dir", "")
ATTACH_DIR = os.path.join(WECHAT_BASE_DIR, "msg", "attach") if WECHAT_BASE_DIR else ""
MSGATTACH_DIR = _cfg.get("msgattach_dir", "") # WeChat Files/FileStorage/MsgAttach
IMAGE_AES_KEY = _cfg.get("image_aes_key")
IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88)
MSG_RESOURCE_DB = os.path.join(_cfg["decrypted_dir"], "message", "message_resource.db")
_CONTACT_FILTER = None
_filter_raw = os.environ.get("WECHAT_EXPORT_CONTACTS", "").strip()
if _filter_raw:
_CONTACT_FILTER = set(_filter_raw.split(","))
print(f"联系人筛选: {len(_CONTACT_FILTER)}")
_EXPORT_FORMATS = None
_formats_raw = os.environ.get("WECHAT_EXPORT_FORMATS", "").strip()
if _formats_raw:
_EXPORT_FORMATS = set(_formats_raw.lower().split(","))
print(f"导出格式: {', '.join(sorted(_EXPORT_FORMATS))}")
_EXPORT_IMAGES = os.environ.get("WECHAT_EXPORT_IMAGES", "1").strip() == "1"
# ─── 图片解密辅助 ───────────────────────────────────────────────────────────────
def _extract_md5_from_packed_info(blob):
"""从 message_resource.db 的 packed_info 中提取文件 MD5"""
if not blob or not isinstance(blob, bytes):
return None
marker = b'\x12\x22\x0a\x20'
idx = blob.find(marker)
if idx >= 0 and idx + len(marker) + 32 <= len(blob):
md5_bytes = blob[idx + len(marker): idx + len(marker) + 32]
try:
md5_str = md5_bytes.decode('ascii')
int(md5_str, 16)
return md5_str
except (UnicodeDecodeError, ValueError):
pass
hex_chars = set(b'0123456789abcdef')
i = 0
while i <= len(blob) - 32:
if blob[i] in hex_chars:
candidate = blob[i:i+32]
if all(b in hex_chars for b in candidate):
try:
return candidate.decode('ascii')
except UnicodeDecodeError:
pass
i += 32
else:
i += 1
return None
def _load_resource_md5_map():
"""加载 message_resource.db 的 (chat_username, local_id) -> file_md5 映射"""
md5_map = {}
if not os.path.exists(MSG_RESOURCE_DB):
return md5_map
try:
conn = sqlite3.connect(MSG_RESOURCE_DB)
# chat_id -> username
chat_id_map = {}
for row in conn.execute("SELECT rowid, user_name FROM ChatName2Id"):
chat_id_map[row[0]] = row[1]
for row in conn.execute(
"SELECT chat_id, message_local_id, packed_info FROM MessageResourceInfo"
):
cid, lid, blob = row
md5 = _extract_md5_from_packed_info(blob)
if md5:
uname = chat_id_map.get(cid, "")
if uname:
md5_map[(uname, lid)] = md5
conn.close()
print(f"图片资源映射: {len(md5_map)}")
except Exception as e:
print(f"读取 message_resource.db 失败: {e}")
return md5_map
def _find_dat_file(username_hash, file_md5):
"""在 attach / MsgAttach 目录下查找 .dat 文件,优先高清版"""
search_patterns = []
# xwechat_files 的 msg/attach 目录: <hash>/<YYYY-MM>/Img/<md5>*.dat
if ATTACH_DIR and os.path.isdir(ATTACH_DIR):
search_base = os.path.join(ATTACH_DIR, username_hash)
if os.path.isdir(search_base):
search_patterns.append(os.path.join(search_base, "*", "Img", f"{file_md5}*.dat"))
# WeChat Files 的 MsgAttach 目录: <hash>/Image/<YYYY-MM>/<md5>*.dat
if MSGATTACH_DIR and os.path.isdir(MSGATTACH_DIR):
search_base = os.path.join(MSGATTACH_DIR, username_hash)
if os.path.isdir(search_base):
search_patterns.append(os.path.join(search_base, "Image", "*", f"{file_md5}*.dat"))
files = []
for pat in search_patterns:
files.extend(glob.glob(pat))
if not files:
return None
# 优先: 无后缀(原图) > _W(原图) > _h(高清) > _t/_t_W(缩略图)
# 先过滤掉缩略图
non_thumb = [f for f in files if '_t.' not in os.path.basename(f) and '_t_' not in os.path.basename(f)]
candidates = non_thumb if non_thumb else files
selected = candidates[0]
for f in candidates:
fname = os.path.basename(f)
# 精确匹配原图(无后缀)
if fname == f"{file_md5}.dat":
return f
for f in candidates:
fname = os.path.basename(f)
if fname == f"{file_md5}_W.dat":
return f
for f in candidates:
if '_h.' in os.path.basename(f) or '_h_' in os.path.basename(f):
return f
return selected
def _detect_image_format(header):
"""根据解密后的文件头检测图片格式"""
if header[:3] == bytes([0xFF, 0xD8, 0xFF]):
return 'jpg'
if header[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
return 'png'
if header[:3] == b'GIF':
return 'gif'
if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WEBP':
return 'webp'
if header[:4] == b'wxgf':
return 'hevc'
return 'bin'
# V2 格式常量
_V2_MAGIC_FULL = b'\x07\x08V2\x08\x07'
_V1_MAGIC_FULL = b'\x07\x08V1\x08\x07'
_IMAGE_MAGICS = {
'jpg': [0xFF, 0xD8, 0xFF],
'png': [0x89, 0x50, 0x4E, 0x47],
'gif': [0x47, 0x49, 0x46, 0x38],
'webp': [0x52, 0x49, 0x46, 0x46],
}
def _decrypt_dat_to_bytes(dat_path):
"""解密 .dat 文件,返回 (bytes, format) 或 (None, None)"""
with open(dat_path, 'rb') as f:
data = f.read()
if len(data) < 15:
return None, None
head6 = data[:6]
# V2 / V1 格式
if head6 in (_V2_MAGIC_FULL, _V1_MAGIC_FULL):
aes_key = None
if head6 == _V1_MAGIC_FULL:
aes_key = b'cfcd208495d565ef'
elif IMAGE_AES_KEY:
aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16]
if not aes_key or len(aes_key) < 16:
return None, None
try:
from Crypto.Cipher import AES as _AES
from Crypto.Util import Padding
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
aligned = aes_size - ~(~aes_size % 16)
offset = 15
if offset + aligned > len(data):
return None, None
cipher = _AES.new(aes_key[:16], _AES.MODE_ECB)
dec_aes = Padding.unpad(cipher.decrypt(data[offset:offset+aligned]), _AES.block_size)
offset += aligned
raw_end = len(data) - xor_size
raw_data = data[offset:raw_end] if offset < raw_end else b''
xor_data = data[raw_end:]
xor_key = IMAGE_XOR_KEY if isinstance(IMAGE_XOR_KEY, int) else 0x88
dec_xor = bytes(b ^ xor_key for b in xor_data)
result = dec_aes + raw_data + dec_xor
fmt = _detect_image_format(result[:16])
return result, fmt
except Exception:
return None, None
# 旧 XOR 格式
for fmt_name, magic in _IMAGE_MAGICS.items():
key = data[0] ^ magic[0]
match = all(i < len(data) and (data[i] ^ key) == magic[i] for i in range(len(magic)))
if match:
result = bytes(b ^ key for b in data)
fmt = _detect_image_format(result[:16])
return result, fmt
return None, None
_resource_md5_map = _load_resource_md5_map() if _EXPORT_IMAGES else {}
def decode_chat_images(chat_username, _messages_unused, out_dir):
"""直接扫描 attach 目录下该联系人的全部图片并解密
按月份分目录输出到 out_dir/image/<YYYY-MM>/
跳过 _t 缩略图,优先 _h 高清版
返回 {file_md5: relative_path} 用于 HTML 嵌入
"""
image_map = {}
username_hash = hashlib.md5(chat_username.encode()).hexdigest()
# 收集所有来源目录: [(base_path, sub_structure), ...]
# xwechat: attach/<hash>/<YYYY-MM>/Img/<md5>*.dat
# WeChat Files: MsgAttach/<hash>/Image/<YYYY-MM>/<md5>*.dat
source_dirs = []
if ATTACH_DIR:
p = os.path.join(ATTACH_DIR, username_hash)
if os.path.isdir(p):
source_dirs.append(("xwechat", p))
if MSGATTACH_DIR:
p = os.path.join(MSGATTACH_DIR, username_hash)
if os.path.isdir(p):
source_dirs.append(("wechat", p))
if not source_dirs:
return image_map
# 收集所有 dat 文件: {base_md5: (best_path, month)}
# 优先级: _h > 无后缀 > _W > 其他(跳过 _t
file_candidates = {} # base_md5 -> (priority, dat_path, month)
def _priority(fname):
"""返回优先级数字,越小越好"""
base = fname.rsplit('.', 1)[0]
if base.endswith('_h'):
return 0 # 高清
if '_' not in base[-3:]:
return 1 # 无后缀原图
if base.endswith('_W'):
return 2
return 9 # 其他
for src_type, base_path in source_dirs:
# xwechat: <hash>/<YYYY-MM>/Img/ — 直接列 base_path 得到月份
# wechat: <hash>/Image/<YYYY-MM>/ — 需要列 base_path/Image 得到月份
if src_type == "xwechat":
scan_base = base_path
else:
scan_base = os.path.join(base_path, "Image")
try:
months = sorted(os.listdir(scan_base))
except OSError:
continue
for month in months:
if src_type == "xwechat":
img_dir = os.path.join(base_path, month, "Img")
else:
img_dir = os.path.join(scan_base, month)
if not os.path.isdir(img_dir):
continue
try:
files = os.listdir(img_dir)
except OSError:
continue
for fname in files:
if not fname.endswith('.dat'):
continue
# 跳过缩略图 _t.dat 和 _t_W.dat
base_no_ext = fname.rsplit('.', 1)[0]
if '_t' in base_no_ext.split('_'):
continue
if base_no_ext.endswith('_t') or '_t_' in base_no_ext:
continue
# 提取 base md5
base_md5 = base_no_ext.split('_')[0]
pri = _priority(fname)
existing = file_candidates.get(base_md5)
if not existing or pri < existing[0]:
file_candidates[base_md5] = (pri, os.path.join(img_dir, fname), month)
if not file_candidates:
return image_map
decoded_count = 0
for base_md5, (pri, dat_path, month) in file_candidates.items():
month_dir = os.path.join(out_dir, "image", month)
# 检查是否已解密
existing = glob.glob(os.path.join(month_dir, f"{base_md5}.*"))
if existing:
rel = os.path.relpath(existing[0], out_dir).replace("\\", "/")
image_map[base_md5] = rel
continue
img_bytes, fmt = _decrypt_dat_to_bytes(dat_path)
if not img_bytes or fmt == 'bin':
continue
os.makedirs(month_dir, exist_ok=True)
out_path = os.path.join(month_dir, f"{base_md5}.{fmt}")
with open(out_path, 'wb') as f:
f.write(img_bytes)
image_map[base_md5] = f"image/{month}/{base_md5}.{fmt}"
decoded_count += 1
return image_map
MSG_TYPES = {
1: "文本",
3: "图片",
34: "语音",
42: "名片",
43: "视频",
47: "表情包",
48: "位置",
49: "分享/文件/小程序",
10000: "系统消息",
10002: "系统通知",
}
_zstd_ctx = zstd.ZstdDecompressor()
def decompress_zstd(data: bytes) -> str:
try:
return _zstd_ctx.decompress(data).decode("utf-8", errors="replace")
except Exception:
return ""
def get_content(raw, ct_flag) -> str:
if raw is None:
return ""
if isinstance(raw, bytes):
if ct_flag == 4:
return decompress_zstd(raw)
return raw.decode("utf-8", errors="replace")
return str(raw)
def safe_dirname(name: str) -> str:
for ch in r'\/:*?"<>|':
name = name.replace(ch, "_")
return name.strip() or "unknown"
def xml_extract(content: str, *tags) -> str:
"""从 XML 中提取第一个匹配的 tag 文本"""
try:
root = ET.fromstring(content)
for tag in tags:
el = root.find(".//" + tag)
if el is not None and el.text:
return el.text
except Exception:
pass
for tag in tags:
m = re.search(rf"<{tag}>(.*?)</{tag}>", content, re.DOTALL)
if m:
return m.group(1).strip()
return content[:200]
def friendly_content(msg_type: int, content: str) -> str:
"""返回适合显示的内容摘要"""
if msg_type == 1:
return content
if msg_type == 3:
return "[图片]"
if msg_type == 34:
return "[语音]"
if msg_type == 42:
title = xml_extract(content, "nickname")
return f"[名片: {title}]"
if msg_type == 43:
return "[视频]"
if msg_type == 47:
return "[表情包]"
if msg_type == 48:
loc = xml_extract(content, "label")
return f"[位置: {loc}]"
if msg_type == 49:
title = xml_extract(content, "title")
return f"[分享: {title}]" if title else "[文件/链接]"
if msg_type in (10000, 10002):
return f"[系统: {content[:100]}]"
return content[:200]
HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title}</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{background:#ededed;font-family:"PingFang SC","Helvetica Neue",Arial,sans-serif;font-size:14px}}
.header{{background:#44A848;color:#fff;padding:12px 16px;font-size:17px;font-weight:bold;position:sticky;top:0;z-index:10;box-shadow:0 1px 3px rgba(0,0,0,.3)}}
.chat{{padding:10px 0;max-width:800px;margin:0 auto}}
.date-sep{{text-align:center;margin:12px 0;color:#999;font-size:12px}}
.date-sep span{{background:#ddd;border-radius:10px;padding:2px 10px}}
.msg{{display:flex;align-items:flex-start;margin:6px 12px;max-width:100%}}
.msg.sent{{flex-direction:row-reverse}}
.msg.system{{justify-content:center;margin:4px 12px}}
.msg.system .bubble{{background:transparent;color:#999;font-size:12px;box-shadow:none;border-radius:0;padding:2px 8px}}
.avatar{{width:40px;height:40px;border-radius:6px;background:#7CC;color:#fff;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:bold;flex-shrink:0}}
.msg.sent .avatar{{background:#4CAF50}}
.msg-body{{max-width:70%;margin:0 8px}}
.sender-name{{font-size:12px;color:#888;margin-bottom:3px}}
.msg.sent .sender-name{{text-align:right}}
.bubble{{display:inline-block;padding:8px 12px;border-radius:6px;word-break:break-word;line-height:1.5;box-shadow:0 1px 2px rgba(0,0,0,.1);white-space:pre-wrap}}
.received .bubble{{background:#fff;border-radius:0 6px 6px 6px}}
.sent .bubble{{background:#95EC69;border-radius:6px 0 6px 6px}}
.bubble img{{max-width:100%;border-radius:4px;display:block;margin:2px 0}}
.type-tag{{font-size:11px;color:#aaa;margin-top:2px}}
</style>
</head>
<body>
<div class="header">{title}</div>
<div class="chat">
{body}
</div>
</body>
</html>
"""
def _html_escape(s: str) -> str:
return s.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace('"','&quot;')
def _write_html(path: str, title: str, is_group: bool, messages: list, image_map: dict = None, out_dir: str = None):
parts = []
last_date = None
for m in messages:
dt = datetime.fromtimestamp(m["create_time"])
day = dt.strftime("%Y年%m月%d")
if day != last_date:
parts.append(f'<div class="date-sep"><span>{day}</span></div>')
last_date = day
if m["is_system"]:
parts.append(
f'<div class="msg system"><div class="bubble">'
f'{_html_escape(m["display_content"])}</div></div>'
)
continue
side = "received" if m["is_received"] else "sent"
initial = (m["sender"] or "?")[0].upper()
sender_label = ""
if is_group or m["is_received"]:
sender_label = f'<div class="sender-name">{_html_escape(m["sender"])}</div>'
type_tag = ""
if m["type"] != 1:
type_tag = f'<div class="type-tag">{m["type_name"]}</div>'
# 图片消息嵌入
bubble_content = _html_escape(m["display_content"])
if m["type"] == 3 and image_map and m["local_id"] in image_map:
rel_path = image_map[m["local_id"]]
if out_dir:
abs_img = os.path.join(out_dir, rel_path)
if os.path.exists(abs_img):
ext = os.path.splitext(abs_img)[1].lstrip('.').lower()
mime = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
'gif': 'image/gif', 'webp': 'image/webp'}.get(ext, 'image/jpeg')
try:
with open(abs_img, 'rb') as imgf:
b64 = base64.b64encode(imgf.read()).decode('ascii')
bubble_content = f'<img src=\"data:{mime};base64,{b64}\" alt=\"图片\">'
except Exception:
bubble_content = f'<img src=\"{_html_escape(rel_path)}\" alt=\"图片\">'
else:
bubble_content = f'<img src=\"{_html_escape(rel_path)}\" alt=\"图片\">'
else:
bubble_content = f'<img src=\"{_html_escape(rel_path)}\" alt=\"图片\">'
parts.append(
f'<div class="msg {side}">'
f'<div class="avatar">{initial}</div>'
f'<div class="msg-body">'
f'{sender_label}'
f'<div class="bubble">{bubble_content}</div>'
f'{type_tag}'
f'<div class="type-tag">{m["time_str"]}</div>'
f'</div></div>'
)
body = "\n".join(parts)
with open(path, "w", encoding="utf-8") as f:
f.write(HTML_TEMPLATE.format(title=_html_escape(title), body=body))
# ─── 加载联系人信息 ─────────────────────────────────────────────────────────────
contact_map: dict[str, dict] = {}
try:
cconn = sqlite3.connect(CONTACT_DB_PATH)
for uname, alias, remark, nick_name in cconn.execute(
"SELECT username, alias, remark, nick_name FROM contact"
):
contact_map[uname] = {
"username": uname,
"alias": alias or "",
"remark": remark or "",
"nick_name": nick_name or "",
}
cconn.close()
print(f"联系人数据库: {len(contact_map)}")
except Exception as e:
print(f"联系人数据库读取失败: {e}")
def display_name(username: str) -> str:
info = contact_map.get(username, {})
return info.get("remark") or info.get("nick_name") or username
# ─── 遍历所有 message_*.db ──────────────────────────────────────────────────────
db_files = sorted(
f for f in glob.glob(os.path.join(MSG_DB_DIR, "message_*.db"))
if not f.endswith(("_fts.db", "_resource.db"))
)
print(f"找到 {len(db_files)} 个消息数据库")
total_chats = 0
total_msgs = 0
# ── 阶段1: 收集所有联系人的消息 ──────────────────────────────────────────────
# chat_data[chat_username] -> { dname, is_group, db_messages: [(db_name, messages)] }
chat_data: dict[str, dict] = {}
for db_path in sorted(db_files):
db_name = os.path.basename(db_path)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
# rowid -> username
sender_map: dict[int, str] = {}
for row in conn.execute("SELECT rowid, user_name FROM Name2Id"):
sender_map[row[0]] = row[1]
# 计算 username -> hash 映射
hash_to_username: dict[str, str] = {}
for username in sender_map.values():
if username:
h = hashlib.md5(username.encode()).hexdigest()
hash_to_username[h] = username
# 找出所有 Msg_<hash> 表
all_tables = [
r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
)
]
for table_name in all_tables:
h = table_name[4:] # strip "Msg_"
chat_username = hash_to_username.get(h, f"unknown_{h[:8]}")
if _CONTACT_FILTER and chat_username not in _CONTACT_FILTER:
continue
dname = safe_dirname(display_name(chat_username))
is_group = chat_username.endswith("@chatroom") or chat_username.endswith("@openim")
# 读取该表所有消息
try:
rows = conn.execute(
f"SELECT local_id, server_id, local_type, sort_seq, real_sender_id,"
f" create_time, status, message_content, WCDB_CT_message_content"
f" FROM {table_name} ORDER BY sort_seq"
).fetchall()
except Exception as e:
print(f" 读取 {table_name} 失败: {e}")
continue
if not rows:
continue
messages = []
for r in rows:
(local_id, server_id, local_type, sort_seq, real_sender_id,
create_time, status, raw_content, ct_flag) = tuple(r)
content = get_content(raw_content, ct_flag or 0)
sender_uname = sender_map.get(real_sender_id, "")
sender_dn = display_name(sender_uname) if sender_uname else ""
msg_type_name = MSG_TYPES.get(local_type, f"未知({local_type})")
display_content = friendly_content(local_type, content)
is_system = local_type in (10000, 10002)
messages.append({
"local_id": local_id,
"server_id": server_id,
"type": local_type,
"type_name": msg_type_name,
"sort_seq": sort_seq,
"sender_username": sender_uname,
"sender": sender_dn,
"create_time": create_time,
"time_str": datetime.fromtimestamp(create_time).strftime("%Y-%m-%d %H:%M:%S"),
"status": status,
"content": content,
"display_content": display_content,
"is_system": is_system,
"is_received": (sender_uname == chat_username) if not is_group else True,
})
if chat_username not in chat_data:
chat_data[chat_username] = {
"dname": dname, "is_group": is_group, "db_messages": []
}
chat_data[chat_username]["db_messages"].append((db_name, messages))
conn.close()
# ── 阶段2: 每个联系人解密图片一次,再写出文件 ────────────────────────────────
total_chats = 0
total_msgs = 0
for chat_username, cdata in chat_data.items():
dname = cdata["dname"]
is_group = cdata["is_group"]
out_dir = os.path.join(OUTPUT_DIR, dname)
os.makedirs(out_dir, exist_ok=True)
# ── .info 文件 ────────────────────────────────────────────────────────
info_path = os.path.join(out_dir, ".info")
if not os.path.exists(info_path):
info = contact_map.get(chat_username, {
"username": chat_username, "alias": "", "remark": "", "nick_name": ""
})
with open(info_path, "w", encoding="utf-8") as f:
f.write(f"username: {info['username']}\n")
f.write(f"alias: {info['alias']}\n")
f.write(f"nick_name: {info['nick_name']}\n")
f.write(f"remark: {info['remark']}\n")
f.write(f"is_group: {is_group}\n")
# ── 解密图片(每个联系人只执行一次)────────────────────────────────────
image_md5_map = {}
if _EXPORT_IMAGES:
image_md5_map = decode_chat_images(chat_username, None, out_dir)
if image_md5_map:
print(f" 图片解密: {len(image_md5_map)} 张 ({dname})")
# ── 按 DB 写出消息文件 ────────────────────────────────────────────────
for db_name, messages in cdata["db_messages"]:
# 建立 local_id -> rel_path 映射
image_map = {}
if image_md5_map:
for m in messages:
if m["type"] != 3:
continue
lid = m["local_id"]
file_md5 = _resource_md5_map.get((chat_username, lid))
if file_md5 and file_md5 in image_md5_map:
image_map[lid] = image_md5_map[file_md5]
# ── CSV ───────────────────────────────────────────────────────────
if not _EXPORT_FORMATS or "csv" in _EXPORT_FORMATS:
csv_path = os.path.join(out_dir, f"{db_name}.csv")
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
w.writerow(["时间", "发送者", "消息类型", "内容", "图片路径", "server_id"])
for m in messages:
img_path = image_map.get(m["local_id"], "") if m["type"] == 3 else ""
w.writerow([
m["time_str"], m["sender"], m["type_name"],
m["display_content"], img_path, m["server_id"]
])
# ── JSON ──────────────────────────────────────────────────────────
if not _EXPORT_FORMATS or "json" in _EXPORT_FORMATS:
json_path = os.path.join(out_dir, f"{db_name}.json")
with open(json_path, "w", encoding="utf-8") as f:
json.dump({
"chat_username": chat_username,
"display_name": dname,
"is_group": is_group,
"message_count": len(messages),
"messages": messages,
}, f, ensure_ascii=False, indent=2)
# ── HTML ──────────────────────────────────────────────────────────
if not _EXPORT_FORMATS or "html" in _EXPORT_FORMATS:
html_path = os.path.join(out_dir, f"{db_name}.html")
_write_html(html_path, dname, is_group, messages, image_map=image_map, out_dir=out_dir)
total_chats += 1
total_msgs += len(messages)
print(f" [{db_name}] {dname}: {len(messages)} 条消息")
print(f"\n完成: {total_chats} 个会话, 共 {total_msgs} 条消息")
print(f"输出目录: {os.path.abspath(OUTPUT_DIR)}")

820
export_sns.py Normal file
View File

@@ -0,0 +1,820 @@
"""导出微信朋友圈动态SnsTimeLine 表)
输出目录: <output_base_dir>/<display_name>/SNS/<yyyyMMddHHmmss000>.json
媒体文件: <output_base_dir>/<display_name>/SNS/<yyyyMMddHHmmss000>_<n>.<ext>
汇总文件: <output_base_dir>/<display_name>/SNS/timeline.json
时间线: <output_base_dir>/<display_name>/SNS/timeline.html
"""
import bisect
import os
import sys
import json
import sqlite3
import struct
import re
import xml.etree.ElementTree as ET
from datetime import datetime
from urllib.request import urlopen, Request
from urllib.error import URLError
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from config import load_config
_cfg = load_config()
DECRYPTED_DIR = _cfg["decrypted_dir"]
SNS_DB_PATH = os.path.join(DECRYPTED_DIR, "sns", "sns.db")
CONTACT_DB_PATH = os.path.join(DECRYPTED_DIR, "contact", "contact.db")
OUTPUT_DIR = _cfg["output_base_dir"]
# 图片缓存 / 解密相关配置
IMAGE_AES_KEY = _cfg.get("image_aes_key")
IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88)
XWECHAT_CACHE_DIR = _cfg.get("xwechat_cache_dir", "")
SNS_CACHE_DIR = _cfg.get("sns_cache_dir", "")
# 联系人筛选(与 export_messages.py 一致)
_CONTACT_FILTER = None
_filter_raw = os.environ.get("WECHAT_EXPORT_CONTACTS", "").strip()
if _filter_raw:
_CONTACT_FILTER = set(_filter_raw.split(","))
print(f"朋友圈联系人筛选: {len(_CONTACT_FILTER)}")
# ── 媒体下载 ─────────────────────────────────────────────────────────────────
_DOWNLOAD_TIMEOUT = 10 # 秒
# ── 本地缓存图片解密 & 匹配 ──────────────────────────────────────────────────
_V2_MAGIC = b'\x07\x08V2\x08\x07'
_V1_MAGIC = b'\x07\x08V1\x08\x07'
_IMAGE_MAGICS = {
'jpg': [0xFF, 0xD8, 0xFF],
'png': [0x89, 0x50, 0x4E, 0x47],
'gif': [0x47, 0x49, 0x46, 0x38],
'webp': [0x52, 0x49, 0x46, 0x46],
}
_TIME_WINDOW = 72 * 3600 # 72 小时
def _decrypt_sns_dat(dat_path):
"""解密 SNS 缓存 .dat 文件,返回 bytes 或 None"""
try:
with open(dat_path, 'rb') as f:
data = f.read()
except OSError:
return None
if len(data) < 15:
return None
head6 = data[:6]
# V2 / V1 格式xwechat cache
if head6 in (_V2_MAGIC, _V1_MAGIC):
aes_key = None
if head6 == _V1_MAGIC:
aes_key = b'cfcd208495d565ef'
elif IMAGE_AES_KEY:
aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16]
if not aes_key or len(aes_key) < 16:
return None
try:
from Crypto.Cipher import AES
from Crypto.Util import Padding
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
aligned = aes_size + (16 - aes_size % 16) if aes_size % 16 else aes_size + 16
offset = 15
if offset + aligned > len(data):
return None
cipher = AES.new(aes_key[:16], AES.MODE_ECB)
dec_aes = Padding.unpad(cipher.decrypt(data[offset:offset + aligned]), AES.block_size)
offset += aligned
raw_end = len(data) - xor_size
raw_data = data[offset:raw_end] if offset < raw_end else b''
xor_data = data[raw_end:]
xor_key = IMAGE_XOR_KEY if isinstance(IMAGE_XOR_KEY, int) else 0x88
dec_xor = bytes(b ^ xor_key for b in xor_data)
return dec_aes + raw_data + dec_xor
except Exception:
return None
# 旧 XOR 格式FileStorage Sns Cache
for magic in _IMAGE_MAGICS.values():
key = data[0] ^ magic[0]
if all(i < len(data) and (data[i] ^ key) == magic[i] for i in range(len(magic))):
return bytes(b ^ key for b in data)
return None
def _detect_format(header):
"""检测解密后数据的图片格式,返回扩展名"""
if header[:3] == b'\xff\xd8\xff':
return 'jpg'
if header[:4] == b'\x89PNG':
return 'png'
if header[:3] == b'GIF':
return 'gif'
if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WEBP':
return 'webp'
return 'bin'
def _image_size_from_bytes(data):
"""从解密后的图片数据提取 (width, height),失败返回 (0, 0)"""
if not data or len(data) < 24:
return 0, 0
# PNG: IHDR 位于字节 16-24
if data[:4] == b'\x89PNG':
w = struct.unpack('>I', data[16:20])[0]
h = struct.unpack('>I', data[20:24])[0]
return w, h
# JPEG: 查找 SOF 标记
if data[:2] == b'\xff\xd8':
i = 2
while i < len(data) - 9:
if data[i] != 0xFF:
break
marker = data[i + 1]
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
h = struct.unpack('>H', data[i + 5:i + 7])[0]
w = struct.unpack('>H', data[i + 7:i + 9])[0]
return w, h
if i + 3 >= len(data):
break
seg_len = struct.unpack('>H', data[i + 2:i + 4])[0]
i += 2 + seg_len
return 0, 0
# WEBP VP8
if data[:4] == b'RIFF' and len(data) >= 30 and data[8:12] == b'WEBP':
if data[12:16] == b'VP8 ':
w = struct.unpack('<H', data[26:28])[0] & 0x3FFF
h = struct.unpack('<H', data[28:30])[0] & 0x3FFF
return w, h
return 0, 0
def _build_sns_cache_index():
"""扫描 SNS 缓存目录,预解密文件头提取元数据
返回按 mtime 排序的索引:
[(mtime, path, est_dec_size, fmt, width, height), ...]
"""
raw_paths = [] # 先收集所有路径
# 1. xwechat cache: <cache_dir>/YYYY-MM/Sns/Img/<2hex>/<30hex>
if XWECHAT_CACHE_DIR and os.path.isdir(XWECHAT_CACHE_DIR):
for month_dir in os.listdir(XWECHAT_CACHE_DIR):
sns_img = os.path.join(XWECHAT_CACHE_DIR, month_dir, "Sns", "Img")
if not os.path.isdir(sns_img):
continue
for sub in os.listdir(sns_img):
sub_path = os.path.join(sns_img, sub)
if not os.path.isdir(sub_path):
continue
for fname in os.listdir(sub_path):
fp = os.path.join(sub_path, fname)
if os.path.isfile(fp):
raw_paths.append(fp)
# 2. FileStorage Sns Cache: <sns_cache_dir>/YYYY-MM/<hash>
if SNS_CACHE_DIR and os.path.isdir(SNS_CACHE_DIR):
for month_dir in os.listdir(SNS_CACHE_DIR):
month_path = os.path.join(SNS_CACHE_DIR, month_dir)
if not os.path.isdir(month_path):
continue
for fname in os.listdir(month_path):
if fname.endswith('_t'): # 跳过缩略图
continue
fp = os.path.join(month_path, fname)
if os.path.isfile(fp):
raw_paths.append(fp)
if not raw_paths:
return []
print(f" 预读取 {len(raw_paths)} 个缓存文件元数据...")
# 准备 AES key避免在循环内重复构造
aes_key = None
if IMAGE_AES_KEY:
aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16]
entries = []
for path in raw_paths:
try:
fsize = os.path.getsize(path)
mtime = os.path.getmtime(path)
if fsize < 15:
continue
with open(path, 'rb') as f:
data = f.read(min(fsize, 4096))
head6 = data[:6]
dec_header = None
est_dec_size = fsize
if head6 in (_V2_MAGIC, _V1_MAGIC):
# V2/V1: 解密 AES 部分获取文件头
k = b'cfcd208495d565ef' if head6 == _V1_MAGIC else aes_key
if not k or len(k) < 16:
continue
try:
from Crypto.Cipher import AES as _AES
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
aligned = aes_size + (16 - aes_size % 16) if aes_size % 16 else aes_size + 16
est_dec_size = fsize - 15 - (aligned - aes_size)
available = min(aligned, len(data) - 15)
# 按 16 字节块对齐ECB 可逐块解密)
usable = (available // 16) * 16
if usable < 16:
continue
cipher = _AES.new(k[:16], _AES.MODE_ECB)
dec_header = cipher.decrypt(data[15:15 + usable])
except Exception:
continue
else:
# XOR 格式
for magic in _IMAGE_MAGICS.values():
key = data[0] ^ magic[0]
if all(i < len(data) and (data[i] ^ key) == magic[i] for i in range(len(magic))):
dec_header = bytes(b ^ key for b in data[:4096])
est_dec_size = fsize
break
if dec_header is None:
continue
fmt = _detect_format(dec_header[:16])
if fmt == 'bin':
continue
w, h = _image_size_from_bytes(dec_header)
entries.append((mtime, path, est_dec_size, fmt, w, h))
except OSError:
continue
entries.sort(key=lambda x: x[0])
return entries
def _match_cache_images(create_time, media_list, index, index_mtimes):
"""为一条动态的所有媒体项匹配本地缓存图片(无需解密,仅查元数据索引)
返回: [(matched_path, fmt), ...] 与 media_list 等长,未匹配为 (None, None)
"""
results = []
if not index or not media_list:
return [(None, None)] * len(media_list)
t_low = create_time - _TIME_WINDOW
t_high = create_time + _TIME_WINDOW
lo = bisect.bisect_left(index_mtimes, t_low)
hi = bisect.bisect_right(index_mtimes, t_high)
# 如果时间窗口为空xwechat cache mtime 异常),扩大到全部
if lo >= hi:
lo, hi = 0, len(index)
used_paths = set()
for media in media_list:
mtype = media.get("type", "")
if mtype not in ("2", ""):
results.append((None, None))
continue
want_w = int(media.get("width") or 0)
want_h = int(media.get("height") or 0)
want_size = int(media.get("total_size") or 0)
candidates = [] # (score, path, fmt)
for i in range(lo, hi):
mtime_i, path_i, dec_size_i, fmt_i, w_i, h_i = index[i]
if path_i in used_paths:
continue
# 尺寸匹配
if want_w > 0 and want_h > 0 and w_i > 0 and h_i > 0:
if w_i != want_w or h_i != want_h:
continue
# 大小匹配
if want_size > 0:
if dec_size_i > want_size * 3 or dec_size_i < want_size * 0.3:
continue
size_diff = abs(dec_size_i - want_size) if want_size > 0 else 0
time_diff = abs(mtime_i - create_time)
candidates.append((size_diff, time_diff, path_i, fmt_i))
if candidates:
candidates.sort(key=lambda x: (x[0], x[1]))
best = candidates[0]
used_paths.add(best[2])
results.append((best[2], best[3]))
else:
results.append((None, None))
return results
# ContentObject type 含义(已知)
_CONTENT_TYPES = {
1: "图文",
2: "纯文本",
3: "链接",
5: "视频链接",
7: "位置",
15: "视频",
28: "短视频",
30: "音乐",
34: "笔记",
42: "小程序",
54: "直播",
}
def _try_download_media(url, save_path):
"""尝试下载媒体文件,返回 True/False
微信朋友圈 shmmsns.qpic.cn 图片需要携带 Referer 和 User-Agent。
注意: URL 返回的数据可能是加密的enc_idx=1 的情况),
解密算法尚未公开,此时下载的文件无法直接查看。
如果下载失败返回 False后续可替换为更复杂的下载逻辑。
"""
if not url or not url.startswith("http"):
return False
try:
req = Request(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://weixin.qq.com/",
})
with urlopen(req, timeout=_DOWNLOAD_TIMEOUT) as resp:
if resp.status != 200:
return False
data = resp.read()
if len(data) < 100:
return False
# 检测格式
if data[:3] == b'\xff\xd8\xff':
ext = '.jpg'
elif data[:4] == b'\x89PNG':
ext = '.png'
elif data[:4] == b'GIF8':
ext = '.gif'
elif data[:4] == b'RIFF' and data[8:12] == b'WEBP':
ext = '.webp'
else:
ext = '.bin'
if not os.path.splitext(save_path)[1]:
save_path += ext
with open(save_path, 'wb') as f:
f.write(data)
return True
except (URLError, OSError, Exception):
return False
def _parse_media_list(timeline_obj):
"""解析 TimelineObject 中的 mediaList返回 media 信息列表"""
medias = []
for media_el in timeline_obj.findall('.//media'):
media_type = media_el.findtext('type', '')
sub_type = media_el.findtext('sub_type', '')
vid_duration = media_el.findtext('videoDuration', '0')
thumb_el = media_el.find('thumb')
url_el = media_el.find('url')
size_el = media_el.find('size')
info = {
"type": media_type,
"sub_type": sub_type,
"video_duration": vid_duration,
}
if thumb_el is not None:
info["thumb_url"] = thumb_el.text or ""
info["thumb_key"] = thumb_el.get("key", "")
info["thumb_token"] = thumb_el.get("token", "")
if url_el is not None:
info["url"] = url_el.text or ""
info["url_md5"] = url_el.get("md5", "")
info["url_key"] = url_el.get("key", "")
info["url_token"] = url_el.get("token", "")
if size_el is not None:
info["width"] = size_el.get("width", "")
info["height"] = size_el.get("height", "")
info["total_size"] = size_el.get("totalSize", "")
medias.append(info)
return medias
def _parse_timeline_xml(content_xml):
"""解析 SnsTimeLine 的 Content XML返回结构化数据"""
try:
root = ET.fromstring(content_xml)
except ET.ParseError:
return None
tl = root.find('.//TimelineObject')
if tl is None:
return None
create_time_str = tl.findtext('createTime', '0')
try:
create_time = int(create_time_str)
except ValueError:
create_time = 0
content_type = tl.findtext('.//ContentObject/type', '0')
try:
content_type_int = int(content_type)
except ValueError:
content_type_int = 0
# 解析位置
loc_el = tl.find('.//location')
location = None
if loc_el is not None:
lat = loc_el.get('latitude', '0')
lon = loc_el.get('longitude', '0')
if lat != '0' or lon != '0':
location = {
"latitude": lat,
"longitude": lon,
"poi_name": loc_el.get("poiName", ""),
}
return {
"id": tl.findtext('id', ''),
"username": tl.findtext('username', ''),
"create_time": create_time,
"create_time_str": datetime.fromtimestamp(create_time).strftime("%Y-%m-%d %H:%M:%S") if create_time else "",
"content_desc": tl.findtext('contentDesc', ''),
"content_type": content_type_int,
"content_type_name": _CONTENT_TYPES.get(content_type_int, f"未知({content_type_int})"),
"nickname": root.findtext('.//LocalExtraInfo/nickname', ''),
"is_private": tl.findtext('private', '0') == '1',
"location": location,
"media": _parse_media_list(tl),
}
def _load_comments(conn):
"""加载 SnsMessage_tmp3 评论/点赞,按 feed_id 分组"""
comments = {}
try:
rows = conn.execute(
"SELECT feed_id, create_time, type, from_username, from_nickname,"
" to_username, to_nickname, content"
" FROM SnsMessage_tmp3 ORDER BY create_time"
).fetchall()
for feed_id, ctime, ctype, from_u, from_n, to_u, to_n, content in rows:
if feed_id not in comments:
comments[feed_id] = []
comments[feed_id].append({
"create_time": ctime,
"create_time_str": datetime.fromtimestamp(ctime).strftime("%Y-%m-%d %H:%M:%S") if ctime else "",
"type": ctype, # 1=点赞, 2=评论
"type_name": "点赞" if ctype == 1 else "评论" if ctype == 2 else f"未知({ctype})",
"from_username": from_u or "",
"from_nickname": from_n or "",
"to_username": to_u or "",
"to_nickname": to_n or "",
"content": content or "",
})
except Exception as e:
print(f"读取评论数据失败: {e}")
return comments
def _safe_dirname(name: str) -> str:
"""清理文件夹名中的非法字符"""
for ch in r'\/:*?"<>|':
name = name.replace(ch, "_")
return name.strip() or "unknown"
def _load_contact_map():
"""从 contact.db 加载 {username: display_name}"""
cmap = {}
if not os.path.exists(CONTACT_DB_PATH):
return cmap
try:
conn = sqlite3.connect(CONTACT_DB_PATH)
for uname, remark, nick_name in conn.execute(
"SELECT username, remark, nick_name FROM contact"
):
dname = remark or nick_name or uname
cmap[uname] = _safe_dirname(dname)
conn.close()
except Exception as e:
print(f"读取联系人数据库失败: {e}")
return cmap
def _timestamp_filename(unix_ts):
"""Unix 时间戳 → yyyyMMddHHmmss000 文件名(毫秒部分为 000"""
if not unix_ts:
return "00000000000000000"
dt = datetime.fromtimestamp(unix_ts)
return dt.strftime("%Y%m%d%H%M%S") + "000"
def _html_escape(text):
"""简单 HTML 转义"""
return (text or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def _generate_timeline_html(display_name, posts, sns_dir, image_files):
"""生成朋友圈时间线 HTML
Args:
display_name: 联系人显示名
posts: 按时间倒序排列的动态列表
sns_dir: SNS 输出目录
image_files: {final_name: [(rel_path, ext), ...]} 每条动态的图片文件列表
"""
html_path = os.path.join(sns_dir, "timeline.html")
parts = [f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_html_escape(display_name)} - 朋友圈</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #f5f5f5; color: #333; }}
h1 {{ text-align: center; color: #07c160; border-bottom: 2px solid #07c160; padding-bottom: 10px; }}
.stats {{ text-align: center; color: #888; margin-bottom: 30px; font-size: 14px; }}
.post {{ background: #fff; border-radius: 10px; padding: 16px 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
.post-time {{ font-size: 12px; color: #999; margin-bottom: 8px; }}
.post-type {{ display: inline-block; font-size: 11px; background: #e8f5e9; color: #2e7d32; padding: 1px 6px; border-radius: 3px; margin-left: 8px; }}
.post-text {{ margin: 8px 0; white-space: pre-wrap; word-break: break-word; line-height: 1.6; }}
.post-images {{ display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }}
.post-images img {{ max-width: 240px; max-height: 240px; border-radius: 6px; object-fit: cover; cursor: pointer; }}
.post-images img:hover {{ opacity: 0.85; }}
.post-location {{ font-size: 12px; color: #1a73e8; margin: 4px 0; }}
.comments {{ margin-top: 10px; padding-top: 8px; border-top: 1px solid #f0f0f0; }}
.comment {{ font-size: 13px; color: #555; margin: 4px 0; line-height: 1.5; }}
.comment-name {{ color: #576b95; font-weight: 500; }}
.comment-like {{ color: #e64a19; }}
.private-tag {{ font-size: 11px; background: #fff3e0; color: #e65100; padding: 1px 6px; border-radius: 3px; margin-left: 6px; }}
</style>
</head>
<body>
<h1>{_html_escape(display_name)} 的朋友圈</h1>
<div class="stats">共 {len(posts)} 条动态</div>
"""]
for post in posts:
final_name = post.get("_final_name", "")
time_str = _html_escape(post.get("create_time_str", ""))
type_name = _html_escape(post.get("content_type_name", ""))
text = _html_escape(post.get("content_desc", ""))
is_private = post.get("is_private", False)
parts.append('<div class="post">')
parts.append(f'<div class="post-time">{time_str}<span class="post-type">{type_name}</span>')
if is_private:
parts.append('<span class="private-tag">仅自己可见</span>')
parts.append('</div>')
if text:
parts.append(f'<div class="post-text">{text}</div>')
# 图片
imgs = image_files.get(final_name, [])
if imgs:
parts.append('<div class="post-images">')
for rel_path, _ in imgs:
parts.append(f'<img src="{_html_escape(rel_path)}" loading="lazy" onclick="window.open(this.src)">')
parts.append('</div>')
# 位置
loc = post.get("location")
if loc and loc.get("poi_name"):
parts.append(f'<div class="post-location">📍 {_html_escape(loc["poi_name"])}</div>')
# 评论
comments = post.get("comments", [])
if comments:
parts.append('<div class="comments">')
for c in comments:
if c.get("type") == 1:
parts.append(f'<div class="comment comment-like">❤️ <span class="comment-name">{_html_escape(c["from_nickname"])}</span></div>')
else:
to_part = ""
if c.get("to_nickname"):
to_part = f' 回复 <span class="comment-name">{_html_escape(c["to_nickname"])}</span>'
parts.append(f'<div class="comment"><span class="comment-name">{_html_escape(c["from_nickname"])}</span>{to_part}: {_html_escape(c.get("content", ""))}</div>')
parts.append('</div>')
parts.append('</div>')
parts.append('</body></html>')
with open(html_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(parts))
return html_path
def export_sns_timeline():
"""导出朋友圈动态主函数"""
if not os.path.exists(SNS_DB_PATH):
print(f"朋友圈数据库不存在: {SNS_DB_PATH}")
print("请先运行「解密数据库」")
return
# 加载联系人
contact_map = _load_contact_map()
print(f"联系人: {len(contact_map)}")
conn = sqlite3.connect(SNS_DB_PATH)
# 加载评论
print("加载评论数据...")
comments_map = _load_comments(conn)
print(f"评论/点赞: {sum(len(v) for v in comments_map.values())}")
# 读取所有动态
print("读取朋友圈动态...")
rows = conn.execute(
"SELECT tid, user_name, content FROM SnsTimeLine WHERE content IS NOT NULL"
).fetchall()
conn.close()
print(f"{len(rows)} 条动态")
if not rows:
return
# 是否尝试下载媒体
try_download = os.environ.get("WECHAT_SNS_DOWNLOAD_MEDIA", "0").strip() == "1"
# ── 构建缓存索引 ─────────────────────────────────────────────────────
print("扫描 SNS 图片缓存...")
cache_index = _build_sns_cache_index()
index_mtimes = [e[0] for e in cache_index]
print(f"缓存索引: {len(cache_index)} 个有效图片文件")
# ── 按 user_name 分组 ─────────────────────────────────────────────────
user_posts: dict[str, list] = {} # user_name -> [post, ...]
user_nicknames: dict[str, str] = {} # user_name -> nickname (从 XML 提取)
skipped = 0
for tid, user_name, content_xml in rows:
if not content_xml:
continue
if _CONTACT_FILTER and user_name not in _CONTACT_FILTER:
skipped += 1
continue
post = _parse_timeline_xml(content_xml)
if not post:
continue
post["tid"] = tid
post["db_user_name"] = user_name or ""
post["comments"] = comments_map.get(tid, [])
key = user_name or "unknown"
if key not in user_posts:
user_posts[key] = []
user_posts[key].append(post)
# 记录 nickname取第一个非空的
nick = post.get("nickname", "")
if nick and key not in user_nicknames:
user_nicknames[key] = nick
if skipped:
print(f"筛选跳过: {skipped}")
# ── 按联系人输出 ──────────────────────────────────────────────────────
total_posts = 0
cache_match_ok = 0
cache_match_fail = 0
media_download_ok = 0
media_download_fail = 0
for user_name, posts in user_posts.items():
dname = contact_map.get(user_name) or _safe_dirname(
user_nicknames.get(user_name) or user_name
)
sns_dir = os.path.join(OUTPUT_DIR, dname, "SNS")
os.makedirs(sns_dir, exist_ok=True)
# 用 set 处理同一秒多条动态的文件名冲突
used_names = set()
# image_files: {final_name: [(rel_path, ext), ...]} 用于 HTML 生成
image_files: dict[str, list] = {}
posts.sort(key=lambda p: p.get("create_time", 0))
for post in posts:
ts_name = _timestamp_filename(post.get("create_time"))
# 处理冲突: 递增末尾毫秒
final_name = ts_name
counter = 1
while final_name in used_names:
final_name = ts_name[:-3] + f"{counter:03d}"
counter += 1
used_names.add(final_name)
post["_final_name"] = final_name
# ── 缓存图片匹配 ─────────────────────────────────────────
media_list = post.get("media", [])
if media_list and cache_index:
matches = _match_cache_images(
post.get("create_time", 0), media_list,
cache_index, index_mtimes,
)
for i, (matched_path, fmt) in enumerate(matches):
if matched_path is not None:
dec_bytes = _decrypt_sns_dat(matched_path)
if dec_bytes:
ext = _detect_format(dec_bytes[:16])
img_name = f"{final_name}_{i}.{ext}"
img_path = os.path.join(sns_dir, img_name)
with open(img_path, 'wb') as f:
f.write(dec_bytes)
if final_name not in image_files:
image_files[final_name] = []
image_files[final_name].append((img_name, ext))
cache_match_ok += 1
continue
cache_match_fail += 1
else:
cache_match_fail += 1
# ── 网络下载(仅对缓存未匹配的媒体尝试) ─────────────────
if try_download and media_list:
existing_imgs = image_files.get(final_name, [])
existing_indices = {int(p.rsplit('_', 1)[1].split('.')[0]) for p, _ in existing_imgs} if existing_imgs else set()
for i, media in enumerate(media_list):
if i in existing_indices:
continue
media_url = media.get("url", "") or media.get("thumb_url", "")
if not media_url:
continue
save_name = os.path.join(sns_dir, f"{final_name}_{i}")
if _try_download_media(media_url, save_name):
# 下载成功后更新 image_files
for cand_ext in ('.jpg', '.png', '.gif', '.webp', '.bin'):
if os.path.exists(save_name + cand_ext):
if final_name not in image_files:
image_files[final_name] = []
image_files[final_name].append((f"{final_name}_{i}{cand_ext}", cand_ext[1:]))
break
media_download_ok += 1
else:
media_download_fail += 1
# 保存 JSON去掉内部字段
post_out = {k: v for k, v in post.items() if not k.startswith("_")}
post_file = os.path.join(sns_dir, f"{final_name}.json")
with open(post_file, 'w', encoding='utf-8') as f:
json.dump(post_out, f, ensure_ascii=False, indent=2)
total_posts += 1
# 每个联系人的汇总 JSON
posts.sort(key=lambda p: p.get("create_time", 0), reverse=True)
summary_posts = [{k: v for k, v in p.items() if not k.startswith("_")} for p in posts]
summary_path = os.path.join(sns_dir, "timeline.json")
with open(summary_path, 'w', encoding='utf-8') as f:
json.dump({
"user_name": user_name,
"display_name": dname,
"export_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"total_posts": len(posts),
"posts": summary_posts,
}, f, ensure_ascii=False, indent=2)
# 生成 HTML 时间线
_generate_timeline_html(dname, posts, sns_dir, image_files)
print(f" {dname}: {len(posts)} 条动态")
print(f"\n完成: {len(user_posts)} 个联系人, 共 {total_posts} 条动态")
if cache_index:
print(f"缓存匹配: 成功 {cache_match_ok}, 失败 {cache_match_fail}")
if try_download:
print(f"媒体下载: 成功 {media_download_ok}, 失败 {media_download_fail}")
print(f"输出目录: {os.path.abspath(OUTPUT_DIR)}")
if __name__ == "__main__":
export_sns_timeline()

745
export_wxwork_messages.py Normal file
View File

@@ -0,0 +1,745 @@
"""导出企业微信消息记录到 CSV / HTML / JSON。
输入目录默认来自 wxwork_decrypted_dir输出到 wxwork_export_dir。
可用环境变量:
WXWORK_EXPORT_CONVERSATIONS=conversation_id1,conversation_id2
WXWORK_EXPORT_FORMATS=csv,html,json
"""
import argparse
import csv
import json
import os
import re
import sqlite3
import sys
from collections import defaultdict
from datetime import datetime
from html import escape
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
MSG_TYPES = {
0: "文本/混合",
2: "文本",
4: "图片",
7: "语音",
15: "图片/文件",
38: "应用消息",
40: "通话/音视频",
503: "状态",
1011: "会议通知",
}
_MESSAGE_TABLES = ("message_table", "message_small_table", "kf_message_tableV1")
def _app_paths():
from config import _app_base_dir, _config_file_path
return _app_base_dir(), _config_file_path()
def _load_config():
base, config_file = _app_paths()
cfg = {}
if os.path.exists(config_file):
with open(config_file, encoding="utf-8") as f:
cfg = json.load(f)
decrypted_dir = cfg.get("wxwork_decrypted_dir", "wxwork_decrypted")
if not os.path.isabs(decrypted_dir):
decrypted_dir = os.path.join(base, decrypted_dir)
output_dir = cfg.get("wxwork_export_dir", "wxwork_export")
if not os.path.isabs(output_dir):
output_dir = os.path.join(base, output_dir)
db_dir = cfg.get("wxwork_db_dir", "")
return {
"base": base,
"decrypted_dir": decrypted_dir,
"output_dir": output_dir,
"self_id": _infer_self_id(db_dir),
}
def _infer_self_id(db_dir):
if not db_dir:
return None
parts = os.path.normpath(db_dir).split(os.sep)
for part in reversed(parts):
if part.isdigit() and len(part) >= 10:
return int(part)
return None
def _safe_dirname(name):
name = re.sub(r'[\\/:*?"<>|\r\n\t]+', "_", str(name))
name = re.sub(r"\s+", " ", name).strip(" .")
return (name or "unknown")[:120]
def _table_exists(conn, table):
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(table,),
).fetchone()
return row is not None
def _open_db(path):
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
return conn
def _load_user_map(decrypted_dir):
user_db = os.path.join(decrypted_dir, "user.db")
users = {}
if not os.path.exists(user_db):
return users
conn = _open_db(user_db)
try:
if _table_exists(conn, "user_table"):
for row in conn.execute(
"SELECT id, name, real_name, account, external_corp_name, external_job "
"FROM user_table"
):
name = row["real_name"] or row["name"] or row["account"] or ""
if row["external_corp_name"] and row["external_corp_name"] not in name:
name = f"{name} ({row['external_corp_name']})" if name else row["external_corp_name"]
if name:
users[int(row["id"])] = name
if _table_exists(conn, "external_user_relation_v3"):
for row in conn.execute(
"SELECT user_id, remarks, real_remarks, corp_remark FROM external_user_relation_v3"
):
name = row["real_remarks"] or row["remarks"] or row["corp_remark"] or ""
if name:
users[int(row["user_id"])] = name
finally:
conn.close()
return users
def _load_group_member_names(decrypted_dir):
session_db = os.path.join(decrypted_dir, "session.db")
members = defaultdict(dict)
if not os.path.exists(session_db):
return members
conn = _open_db(session_db)
try:
if _table_exists(conn, "conversation_user_table"):
for row in conn.execute(
"SELECT conversation_id, user_id, nick_name FROM conversation_user_table"
):
if row["nick_name"]:
members[row["conversation_id"]][int(row["user_id"])] = row["nick_name"]
if _table_exists(conn, "conversation_member_nickname_table"):
# 该表使用 room_id需要用 conversation_table.con_numeric_id 转成会话 ID。
room_map = {}
if _table_exists(conn, "conversation_table"):
for row in conn.execute("SELECT con_numeric_id, id FROM conversation_table"):
room_map[int(row["con_numeric_id"])] = row["id"]
for row in conn.execute(
"SELECT room_id, userid, nickname FROM conversation_member_nickname_table"
):
cid = room_map.get(int(row["room_id"]))
if cid and row["nickname"]:
members[cid][int(row["userid"])] = row["nickname"]
finally:
conn.close()
return members
def _conversation_kind(conversation_id):
if conversation_id.startswith("R:"):
return "群聊"
if conversation_id.startswith("S:"):
return "单聊"
if conversation_id.startswith("M:"):
return "微信联系人"
if conversation_id.startswith("O:"):
return "应用/公众号"
if conversation_id.startswith("Y:"):
return "系统会话"
return "其他"
def _name_from_conversation_id(conversation_id, user_map, self_id):
if conversation_id.startswith("S:"):
ids = []
for value in conversation_id[2:].split("_"):
if value.isdigit():
ids.append(int(value))
other_ids = [uid for uid in ids if self_id is None or uid != self_id]
for uid in other_ids or ids:
if uid in user_map:
return user_map[uid]
if ":" in conversation_id:
tail = conversation_id.split(":", 1)[1]
if tail.isdigit() and int(tail) in user_map:
return user_map[int(tail)]
return conversation_id
def _load_message_counts(decrypted_dir):
msg_db = os.path.join(decrypted_dir, "message.db")
counts = defaultdict(int)
last_times = defaultdict(int)
if not os.path.exists(msg_db):
return counts, last_times
conn = _open_db(msg_db)
try:
for table in _MESSAGE_TABLES:
if not _table_exists(conn, table):
continue
for row in conn.execute(
f'SELECT conversation_id, COUNT(*) AS c, MAX(send_time) AS t '
f'FROM "{table}" GROUP BY conversation_id'
):
cid = row["conversation_id"]
if not cid:
continue
counts[cid] += int(row["c"] or 0)
last_times[cid] = max(last_times[cid], int(row["t"] or 0))
finally:
conn.close()
return counts, last_times
def discover_conversations(decrypted_dir=None):
cfg = _load_config()
if decrypted_dir is None:
decrypted_dir = cfg["decrypted_dir"]
if not os.path.isdir(decrypted_dir):
raise FileNotFoundError(f"企业微信解密目录不存在: {decrypted_dir}")
user_map = _load_user_map(decrypted_dir)
counts, message_last_times = _load_message_counts(decrypted_dir)
session_db = os.path.join(decrypted_dir, "session.db")
conversations = {}
if os.path.exists(session_db):
conn = _open_db(session_db)
try:
if _table_exists(conn, "conversation_table"):
for row in conn.execute(
"SELECT id, name, roomname_remark, last_message_time, last_message_id "
"FROM conversation_table"
):
cid = row["id"]
if not cid:
continue
raw_name = row["roomname_remark"] or row["name"] or ""
display = raw_name or _name_from_conversation_id(
cid, user_map, cfg["self_id"]
)
last_time = max(
int(row["last_message_time"] or 0),
message_last_times.get(cid, 0),
)
conversations[cid] = {
"conversation_id": cid,
"display_name": display,
"kind": _conversation_kind(cid),
"message_count": counts.get(cid, 0),
"last_time": last_time,
"last_message_id": int(row["last_message_id"] or 0),
}
finally:
conn.close()
for cid, count in counts.items():
if cid in conversations:
conversations[cid]["message_count"] = count
conversations[cid]["last_time"] = max(
conversations[cid]["last_time"], message_last_times.get(cid, 0)
)
continue
conversations[cid] = {
"conversation_id": cid,
"display_name": _name_from_conversation_id(cid, user_map, cfg["self_id"]),
"kind": _conversation_kind(cid),
"message_count": count,
"last_time": message_last_times.get(cid, 0),
"last_message_id": 0,
}
result = [c for c in conversations.values() if c["message_count"] > 0]
result.sort(key=lambda c: (c["last_time"], c["message_count"]), reverse=True)
return result
def _read_varint(data, pos):
value = 0
shift = 0
while pos < len(data) and shift < 64:
b = data[pos]
pos += 1
value |= (b & 0x7F) << shift
if not (b & 0x80):
return value, pos
shift += 7
raise ValueError("bad varint")
def _clean_text(text):
text = "".join(
ch if ch in "\n\t" or (ch.isprintable() and ch not in "\x0b\x0c") else " "
for ch in text
)
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _looks_like_plain_text(data, text):
if not text:
return False
control = sum(1 for b in data if b < 32 and b not in (9, 10, 13))
if control / max(len(data), 1) > 0.08:
return False
printable = sum(1 for ch in text if ch.isprintable() or ch in "\n\t")
return printable / max(len(text), 1) > 0.9
def _decode_text_segment(segment):
if not segment or b"\x00" in segment:
return None
try:
text = segment.decode("utf-8")
except UnicodeDecodeError:
return None
text = _clean_text(text)
if len(text) < 2:
return None
if re.fullmatch(r"[0-9a-fA-F]{32,}", text):
return None
printable = sum(1 for ch in text if ch.isprintable() or ch in "\n\t")
if printable / max(len(text), 1) < 0.9:
return None
return text
def _parse_protobuf_strings(data, depth=0):
if depth > 4 or not data:
return []
pos = 0
out = []
fields = 0
try:
while pos < len(data):
tag, pos = _read_varint(data, pos)
if tag == 0:
return []
wire = tag & 7
fields += 1
if wire == 0:
_, pos = _read_varint(data, pos)
elif wire == 1:
pos += 8
elif wire == 5:
pos += 4
elif wire == 2:
length, pos = _read_varint(data, pos)
if length < 0 or pos + length > len(data):
return []
segment = data[pos:pos + length]
pos += length
text = _decode_text_segment(segment)
if text:
out.append(text)
else:
out.extend(_parse_protobuf_strings(segment, depth + 1))
else:
return []
if pos > len(data):
return []
except Exception:
return []
return out if fields else []
def _dedupe_texts(values):
seen = set()
out = []
for value in values:
value = _clean_text(value)
if not value or value in seen:
continue
seen.add(value)
out.append(value)
return out
def decode_content(raw):
if raw is None:
return ""
if isinstance(raw, str):
return _clean_text(raw)
data = bytes(raw)
if not data:
return ""
try:
plain = data.decode("utf-8")
if _looks_like_plain_text(data, plain):
return _clean_text(plain)
except UnicodeDecodeError:
pass
texts = _dedupe_texts(_parse_protobuf_strings(data))
if texts:
return "\n".join(texts[:12])
for enc in ("utf-8", "gbk", "utf-16le"):
try:
text = _clean_text(data.decode(enc, errors="replace"))
if text and "\ufffd" not in text[:20]:
return text[:2000]
except Exception:
continue
return f"[二进制内容 {len(data)} 字节]"
def _format_time(ts):
try:
ts = int(ts or 0)
except (TypeError, ValueError):
ts = 0
if ts <= 0:
return ""
if ts > 20_000_000_000:
ts = ts / 1000
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
def _message_type_name(content_type):
return MSG_TYPES.get(int(content_type or 0), f"未知({content_type})")
def _display_message_content(content_type, content, extra_content, local_extra_content):
text = content or extra_content or local_extra_content
if text:
return text
return f"[{_message_type_name(content_type)}]"
def _build_message(row, conv_map, user_map, member_names, self_id):
cid = row["conversation_id"]
sender_id = int(row["sender_id"] or 0)
sender = member_names.get(cid, {}).get(sender_id) or user_map.get(sender_id)
if self_id is not None and sender_id == self_id:
sender = ""
if not sender:
sender = str(sender_id) if sender_id else "系统"
content = decode_content(row["content"])
extra_content = decode_content(row["extra_content"])
local_extra_content = decode_content(row["local_extra_content"])
content_type = int(row["content_type"] or 0)
conv = conv_map.get(cid, {})
return {
"source_table": row["source_table"],
"message_id": int(row["message_id"] or 0),
"server_id": int(row["server_id"] or 0),
"sequence": int(row["sequence"] or 0),
"conversation_id": cid,
"conversation": conv.get("display_name") or cid,
"conversation_kind": conv.get("kind") or _conversation_kind(cid),
"sender_id": sender_id,
"sender": sender,
"content_type": content_type,
"type_name": _message_type_name(content_type),
"send_time": int(row["send_time"] or 0),
"time": _format_time(row["send_time"]),
"flag": int(row["flag"] or 0),
"content": content,
"extra_content": extra_content,
"local_extra_content": local_extra_content,
"display_content": _display_message_content(
content_type, content, extra_content, local_extra_content
),
"is_sent": self_id is not None and sender_id == self_id,
}
def _iter_message_rows(message_db, selected_ids=None):
selected_ids = set(selected_ids or [])
conn = _open_db(message_db)
try:
for table in _MESSAGE_TABLES:
if not _table_exists(conn, table):
continue
where = ""
params = []
if selected_ids:
placeholders = ",".join("?" for _ in selected_ids)
where = f"WHERE conversation_id IN ({placeholders})"
params = list(selected_ids)
sql = (
f'SELECT "{table}" AS source_table, message_id, server_id, sequence, '
f"sender_id, conversation_id, content_type, send_time, flag, "
f"content, extra_content, local_extra_content "
f'FROM "{table}" {where} '
f"ORDER BY send_time, sequence, message_id"
)
yield from conn.execute(sql, params)
finally:
conn.close()
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title}</title>
<style>
*{{box-sizing:border-box}}
body{{margin:0;background:#f4f4f2;color:#1f2328;font-family:"Microsoft YaHei UI","PingFang SC",Arial,sans-serif;font-size:14px}}
.header{{position:sticky;top:0;background:#1f6f50;color:#fff;padding:12px 18px;font-weight:700;box-shadow:0 1px 4px rgba(0,0,0,.18)}}
.meta{{font-weight:400;font-size:12px;opacity:.86;margin-top:3px}}
.chat{{max-width:880px;margin:0 auto;padding:12px 10px 24px}}
.day{{text-align:center;color:#777;font-size:12px;margin:14px 0 8px}}
.day span{{background:#deded8;border-radius:10px;padding:2px 10px}}
.msg{{display:flex;align-items:flex-start;gap:8px;margin:8px 0}}
.msg.sent{{flex-direction:row-reverse}}
.avatar{{width:36px;height:36px;border-radius:6px;background:#4977a8;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;flex:0 0 36px}}
.msg.sent .avatar{{background:#2b8a57}}
.body{{max-width:72%}}
.sender{{font-size:12px;color:#666;margin:0 0 3px 2px}}
.msg.sent .sender{{text-align:right;margin-right:2px}}
.bubble{{white-space:pre-wrap;word-break:break-word;line-height:1.55;background:#fff;border-radius:6px;padding:8px 11px;box-shadow:0 1px 2px rgba(0,0,0,.08)}}
.msg.sent .bubble{{background:#b9ed9b}}
.type{{font-size:11px;color:#888;margin-top:2px}}
</style>
</head>
<body>
<div class="header">{title}<div class="meta">{meta}</div></div>
<div class="chat">
{body}
</div>
</body>
</html>
"""
def _write_html(path, conv, messages):
parts = []
last_day = None
is_group = conv.get("kind") == "群聊"
for msg in messages:
day = msg["time"][:10] if msg["time"] else ""
if day and day != last_day:
parts.append(f'<div class="day"><span>{escape(day)}</span></div>')
last_day = day
side = "sent" if msg["is_sent"] else "received"
sender_label = ""
if is_group or not msg["is_sent"]:
sender_label = f'<div class="sender">{escape(msg["sender"])}</div>'
initial = escape((msg["sender"] or "?")[0].upper())
content = escape(msg["display_content"] or "")
type_line = escape(f'{msg["type_name"]} · {msg["time"]}')
parts.append(
f'<div class="msg {side}">'
f'<div class="avatar">{initial}</div>'
f'<div class="body">{sender_label}'
f'<div class="bubble">{content}</div>'
f'<div class="type">{type_line}</div>'
f'</div></div>'
)
meta = f'{conv.get("kind", "")} · {len(messages)} 条消息 · {conv["conversation_id"]}'
with open(path, "w", encoding="utf-8") as f:
f.write(
HTML_TEMPLATE.format(
title=escape(conv["display_name"]),
meta=escape(meta),
body="\n".join(parts),
)
)
def _write_csv(path, messages):
with open(path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow([
"时间", "会话", "会话ID", "发送者", "发送者ID", "消息类型",
"内容", "message_id", "server_id", "sequence", "flag",
])
for msg in messages:
writer.writerow([
msg["time"],
msg["conversation"],
msg["conversation_id"],
msg["sender"],
msg["sender_id"],
msg["type_name"],
msg["display_content"],
msg["message_id"],
msg["server_id"],
msg["sequence"],
msg["flag"],
])
def _write_json(path, conv, messages):
with open(path, "w", encoding="utf-8") as f:
json.dump(
{
"conversation": conv,
"message_count": len(messages),
"messages": messages,
},
f,
ensure_ascii=False,
indent=2,
)
def _selected_from_env():
raw = os.environ.get("WXWORK_EXPORT_CONVERSATIONS", "").strip()
if not raw:
return None
return {item.strip() for item in raw.split(",") if item.strip()}
def _formats_from_env():
raw = os.environ.get("WXWORK_EXPORT_FORMATS", "").strip()
if not raw:
return {"csv"}
formats = {item.strip().lower() for item in raw.split(",") if item.strip()}
valid = {"csv", "html", "json"}
return formats & valid or {"csv"}
def export_messages(selected_ids=None, formats=None):
cfg = _load_config()
decrypted_dir = cfg["decrypted_dir"]
output_dir = cfg["output_dir"]
message_db = os.path.join(decrypted_dir, "message.db")
if not os.path.isdir(decrypted_dir):
raise FileNotFoundError(f"企业微信解密目录不存在: {decrypted_dir}")
if not os.path.exists(message_db):
raise FileNotFoundError(f"企业微信消息库不存在: {message_db}")
formats = formats or _formats_from_env()
selected_ids = selected_ids if selected_ids is not None else _selected_from_env()
conversations = discover_conversations(decrypted_dir)
conv_map = {conv["conversation_id"]: conv for conv in conversations}
if selected_ids:
missing = sorted(selected_ids - set(conv_map))
if missing:
print(f"提示: {len(missing)} 个选择的会话没有消息或不存在")
user_map = _load_user_map(decrypted_dir)
member_names = _load_group_member_names(decrypted_dir)
grouped = defaultdict(list)
seen = set()
for row in _iter_message_rows(message_db, selected_ids):
key = (row["conversation_id"], row["message_id"], row["server_id"], row["sequence"])
if key in seen:
continue
seen.add(key)
msg = _build_message(row, conv_map, user_map, member_names, cfg["self_id"])
grouped[msg["conversation_id"]].append(msg)
os.makedirs(output_dir, exist_ok=True)
total_conversations = 0
total_messages = 0
for cid, messages in sorted(
grouped.items(),
key=lambda item: (conv_map.get(item[0], {}).get("last_time", 0), len(item[1])),
reverse=True,
):
conv = conv_map.get(cid) or {
"conversation_id": cid,
"display_name": cid,
"kind": _conversation_kind(cid),
"message_count": len(messages),
"last_time": messages[-1]["send_time"] if messages else 0,
}
folder = _safe_dirname(f'{conv["display_name"]}_{cid}')
out_dir = os.path.join(output_dir, folder)
os.makedirs(out_dir, exist_ok=True)
info_path = os.path.join(out_dir, ".info")
with open(info_path, "w", encoding="utf-8") as f:
f.write(f"conversation_id: {cid}\n")
f.write(f"display_name: {conv['display_name']}\n")
f.write(f"kind: {conv.get('kind', '')}\n")
f.write(f"message_count: {len(messages)}\n")
if "csv" in formats:
_write_csv(os.path.join(out_dir, "messages.csv"), messages)
if "html" in formats:
_write_html(os.path.join(out_dir, "messages.html"), conv, messages)
if "json" in formats:
_write_json(os.path.join(out_dir, "messages.json"), conv, messages)
total_conversations += 1
total_messages += len(messages)
print(f" {conv['display_name']} ({cid}): {len(messages)}")
print(f"\n完成: {total_conversations} 个会话, 共 {total_messages} 条消息")
print(f"输出目录: {os.path.abspath(output_dir)}")
return {
"conversation_count": total_conversations,
"message_count": total_messages,
"output_dir": os.path.abspath(output_dir),
}
def main(argv=None):
parser = argparse.ArgumentParser(description="Export WXWork messages")
parser.add_argument("--list", action="store_true", help="list conversations and exit")
parser.add_argument(
"--conversation",
action="append",
help="conversation ID to export; can be passed multiple times",
)
parser.add_argument("--formats", help="comma separated formats: csv,html,json")
args = parser.parse_args(argv)
if args.list:
conversations = discover_conversations()
print(f"发现 {len(conversations)} 个有消息的企业微信会话")
for conv in conversations:
last_time = _format_time(conv["last_time"])
print(
f"{conv['conversation_id']}\t{conv['message_count']}\t"
f"{last_time}\t{conv['kind']}\t{conv['display_name']}"
)
return 0
selected = set(args.conversation) if args.conversation else None
formats = None
if args.formats:
formats = {item.strip().lower() for item in args.formats.split(",") if item.strip()}
export_messages(selected_ids=selected, formats=formats)
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc:
print(f"导出失败: {exc}", file=sys.stderr)
sys.exit(1)

View File

@@ -333,9 +333,15 @@ def verify_and_decrypt(attach_dir, aes_key_str, xor_key):
def main():
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
from config import _config_file_path, load_config
config_path = _config_file_path()
# 加载完整配置用于逻辑,读取原始 JSON 用于保存
config = load_config()
try:
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
config_raw = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
config_raw = {}
db_dir = os.path.expanduser(os.path.expandvars(config['db_dir']))
base_dir = os.path.dirname(db_dir)
@@ -392,8 +398,11 @@ def main():
config['image_aes_key'] = aes_key
if xor_key is not None:
config['image_xor_key'] = xor_key
config_raw['image_aes_key'] = aes_key
if xor_key is not None:
config_raw['image_xor_key'] = xor_key
with open(config_path, 'w', encoding="utf-8") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
json.dump(config_raw, f, indent=2, ensure_ascii=False)
print(f"Saved to {config_path}", flush=True)
print("\n=== Verify decrypt ===", flush=True)

View File

@@ -226,9 +226,14 @@ def verify_and_decrypt(attach_dir, aes_key_str, xor_key):
def main():
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
from config import _config_file_path, load_config
config_path = _config_file_path()
config = load_config()
try:
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
config_raw = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
config_raw = {}
db_dir = os.path.expanduser(os.path.expandvars(config['db_dir']))
base_dir = os.path.dirname(db_dir)
@@ -292,8 +297,11 @@ def main():
config['image_aes_key'] = aes_key
if xor_key is not None:
config['image_xor_key'] = xor_key
config_raw['image_aes_key'] = aes_key
if xor_key is not None:
config_raw['image_xor_key'] = xor_key
with open(config_path, 'w', encoding="utf-8") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
json.dump(config_raw, f, indent=2, ensure_ascii=False)
print(f"Saved to {config_path}", flush=True)
verify_and_decrypt(attach_dir, aes_key, xor_key)

654
find_wxwork_keys.py Normal file
View File

@@ -0,0 +1,654 @@
"""
从企业微信(WXWork)进程内存中提取所有数据库的缓存raw key
企业微信的本地数据库与个人微信不同。实测 Windows 版使用 wxSQLite3
AES-128-CBC 页面加密16 字节 raw key每页按 page index 派生 AES key
和 IV页面没有 SQLCipher HMAC/reserve 区。
"""
import ctypes
import ctypes.wintypes as wt
import bisect
import functools
import hashlib
import hmac as hmac_mod
import json
import os
import re
import struct
import subprocess
import sys
import time
from key_scan_common import collect_db_files
from wxwork_crypto import (
is_plain_sqlite_page,
is_wxsqlite3_aes128_page1,
verify_wxsqlite3_aes128_key,
)
print = functools.partial(print, flush=True)
# ── Windows 内存读取原语 ──────────────────────────────────────────────
kernel32 = ctypes.windll.kernel32
MEM_COMMIT = 0x1000
READABLE = {0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80}
class MBI(ctypes.Structure):
_fields_ = [
("BaseAddress", ctypes.c_uint64), ("AllocationBase", ctypes.c_uint64),
("AllocationProtect", wt.DWORD), ("_pad1", wt.DWORD),
("RegionSize", ctypes.c_uint64), ("State", wt.DWORD),
("Protect", wt.DWORD), ("Type", wt.DWORD), ("_pad2", wt.DWORD),
]
def read_mem(h, addr, sz):
buf = ctypes.create_string_buffer(sz)
n = ctypes.c_size_t(0)
if kernel32.ReadProcessMemory(h, ctypes.c_uint64(addr), buf, sz, ctypes.byref(n)):
return buf.raw[:n.value]
return None
def enum_regions(h):
regs = []
addr = 0
mbi = MBI()
while addr < 0x7FFFFFFFFFFF:
if kernel32.VirtualQueryEx(h, ctypes.c_uint64(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)) == 0:
break
if mbi.State == MEM_COMMIT and mbi.Protect in READABLE and 0 < mbi.RegionSize < 500 * 1024 * 1024:
regs.append((mbi.BaseAddress, mbi.RegionSize))
nxt = mbi.BaseAddress + mbi.RegionSize
if nxt <= addr:
break
addr = nxt
return regs
# ── 常量 ─────────────────────────────────────────────────────────────
WXWORK_PROCESS = "WXWork.exe"
PAGE_SZ = 4096
SALT_SZ = 16
# 旧版本/其他平台可能回落到 SQLCipher 参数,保留作兼容验证。
# (key_sz, hmac_hash_name, hmac_sz, pbkdf2_iter, reserve_sz)
VERIFY_CONFIGS = [
# WCDB optimized cipher with AES-128, HMAC-SHA512 (最可能)
(16, "sha512", 64, 2, 80),
# WCDB with AES-128, HMAC-SHA256
(16, "sha256", 32, 2, 48),
# SQLCipher 3 defaults with AES-128
(16, "sha512", 64, 4000, 80),
(16, "sha256", 32, 4000, 48),
# AES-256 回退 (与个人微信相同参数)
(32, "sha512", 64, 2, 80),
]
def verify_enc_key_wxwork(enc_key, db_page1):
"""尝试多种参数组合验证密钥,返回 (成功?, 使用的配置描述)"""
if len(enc_key) == 16 and verify_wxsqlite3_aes128_key(enc_key, db_page1):
return True, "wxSQLite3 AES-128-CBC, per-page MD5 key/IV, no HMAC"
key_sz = len(enc_key)
for cfg_key_sz, hmac_hash, hmac_sz, iterations, reserve_sz in VERIFY_CONFIGS:
if key_sz != cfg_key_sz:
continue
salt = db_page1[:SALT_SZ]
mac_salt = bytes(b ^ 0x3A for b in salt)
mac_key = hashlib.pbkdf2_hmac(hmac_hash, enc_key, mac_salt, iterations, dklen=cfg_key_sz)
hmac_data = db_page1[SALT_SZ: PAGE_SZ - reserve_sz + 16]
stored_hmac = db_page1[PAGE_SZ - hmac_sz: PAGE_SZ]
hash_fn = getattr(hashlib, hmac_hash)
hm = hmac_mod.new(mac_key, hmac_data, hash_fn)
hm.update(struct.pack("<I", 1))
if hm.digest() == stored_hmac:
desc = f"AES-{cfg_key_sz * 8}, HMAC-{hmac_hash.upper()}, iter={iterations}"
return True, desc
return False, ""
# ── WXWork 进程发现 ──────────────────────────────────────────────────
def get_wxwork_pids():
"""返回所有 WXWork.exe 进程的 (pid, mem_kb) 列表,按内存降序"""
r = subprocess.run(
["tasklist", "/FI", f"IMAGENAME eq {WXWORK_PROCESS}", "/FO", "CSV", "/NH"],
capture_output=True, text=True,
)
pids = []
for line in r.stdout.strip().split('\n'):
if not line.strip():
continue
p = line.strip('"').split('","')
if len(p) >= 5:
pid = int(p[1])
mem = int(p[4].replace(',', '').replace(' K', '').strip() or '0')
pids.append((pid, mem))
if not pids:
raise RuntimeError(f"{WXWORK_PROCESS} 未运行")
pids.sort(key=lambda x: x[1], reverse=True)
for pid, mem in pids:
print(f"[+] {WXWORK_PROCESS} PID={pid} ({mem // 1024}MB)")
return pids
# ── WXWork 数据目录自动检测 ──────────────────────────────────────────
def _wxwork_data_dir_mtime(data_dir):
"""返回企业微信 Data 目录最近活跃时间,用于多账号自动选择。"""
latest = 0
for root, dirs, files in os.walk(data_dir):
dirs[:] = [d for d in dirs if d not in ("-journal",)]
for name in files:
if not name.endswith((".db", ".db-wal", ".db-shm")):
continue
path = os.path.join(root, name)
try:
latest = max(latest, os.path.getmtime(path))
except OSError:
pass
try:
latest = max(latest, os.path.getmtime(data_dir))
except OSError:
pass
return latest
def _is_noninteractive_mode():
return (
os.environ.get("WECHAT_DECRYPT_NONINTERACTIVE") == "1"
or os.environ.get("WXWORK_AUTO_SELECT_DB") == "1"
or os.environ.get("WECHAT_DECRYPT_GUI") == "1"
or not sys.stdin.isatty()
)
def auto_detect_wxwork_db_dir():
"""扫描 %USERPROFILE%\\Documents\\WXWork\\*\\Data 寻找包含加密DB的目录"""
docs = os.path.join(os.environ.get("USERPROFILE", ""), "Documents", "WXWork")
if not os.path.isdir(docs):
return None
candidates = []
for name in os.listdir(docs):
data_dir = os.path.join(docs, name, "Data")
if not os.path.isdir(data_dir):
continue
has_encrypted = False
for fname in os.listdir(data_dir):
if not fname.endswith(".db"):
continue
fpath = os.path.join(data_dir, fname)
if os.path.getsize(fpath) < PAGE_SZ:
continue
with open(fpath, "rb") as f:
header = f.read(16)
if header != b"SQLite format 3\x00":
has_encrypted = True
break
if has_encrypted:
candidates.append(data_dir)
if not candidates:
return None
candidates.sort(key=_wxwork_data_dir_mtime, reverse=True)
if len(candidates) == 1:
return candidates[0]
if _is_noninteractive_mode():
selected = candidates[0]
print("[!] 检测到多个企业微信数据目录,非交互模式下自动选择最近活跃目录:")
for i, c in enumerate(candidates, 1):
marker = " *" if c == selected else " "
print(f" {marker} {i}. {c}")
return candidates[0]
print("[!] 检测到多个企业微信数据目录:")
for i, c in enumerate(candidates, 1):
print(f" {i}. {c}")
print(" 0. 跳过,稍后手动配置")
try:
while True:
choice = input(f"请选择 [0-{len(candidates)}]: ").strip()
if choice == "0":
return None
if choice.isdigit() and 1 <= int(choice) <= len(candidates):
return candidates[int(choice) - 1]
print(" 无效输入,请重新选择")
except (EOFError, KeyboardInterrupt):
print()
return None
def filter_encrypted_dbs(db_files, salt_to_dbs):
"""过滤掉未加密的数据库。"""
filtered_files = [
entry for entry in db_files if not is_plain_sqlite_page(entry[4])
]
filtered_salts = {
s: dbs for s, dbs in salt_to_dbs.items()
if any(entry[3] == s and not is_plain_sqlite_page(entry[4]) for entry in db_files)
}
removed = len(db_files) - len(filtered_files)
if removed:
print(f"[*] 跳过 {removed} 个未加密数据库")
wxsqlite3_count = sum(1 for entry in filtered_files if is_wxsqlite3_aes128_page1(entry[4]))
if wxsqlite3_count:
print(f"[*] 检测到 {wxsqlite3_count} 个 wxSQLite3 AES-128 格式数据库")
return filtered_files, filtered_salts
# ── 企业微信内存扫描 ─────────────────────────────────────────────────
def scan_memory_for_wxwork_keys(data, hex_re, db_files, salt_to_dbs, key_map,
remaining_salts, base_addr, pid, print_fn):
"""扫描内存,匹配 hex 模式并用企业微信参数验证密钥。
企业微信 key=16字节(32 hex), salt=16字节(32 hex)
可能的缓存格式:
- x'<32hex_key><32hex_salt>' = 64 hex total
- x'<32hex_key>' = 32 hex (key only)
- x'<64hex_key><32hex_salt>' = 96 hex (AES-256 回退)
"""
matches = 0
for m in hex_re.finditer(data):
hex_str = m.group(1).decode()
addr = base_addr + m.start()
matches += 1
hex_len = len(hex_str)
# 尝试不同的解释方式
candidates = []
if hex_len == 32:
# 纯 16字节 key
candidates.append((hex_str, None))
elif hex_len == 64:
# 优先: 32hex key + 32hex salt (WeCom AES-128)
candidates.append((hex_str[:32], hex_str[32:]))
# 回退: 64hex = 32字节 key (personal WeChat AES-256)
candidates.append((hex_str, None))
elif hex_len == 96:
# 优先: 64hex key + 32hex salt (personal WeChat AES-256)
candidates.append((hex_str[:64], hex_str[64:]))
# 也尝试: 32hex key + ... + 32hex salt
candidates.append((hex_str[:32], hex_str[-32:]))
elif hex_len > 96 and hex_len % 2 == 0:
candidates.append((hex_str[:64], hex_str[-32:]))
candidates.append((hex_str[:32], hex_str[-32:]))
for enc_key_hex, salt_hex in candidates:
if len(enc_key_hex) not in (32, 64):
continue
enc_key = bytes.fromhex(enc_key_hex)
if salt_hex and salt_hex in remaining_salts:
# salt 匹配已知数据库
for rel, path, sz, s, page1 in db_files:
if s == salt_hex:
ok, desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
key_map[salt_hex] = enc_key_hex
remaining_salts.discard(salt_hex)
dbs = salt_to_dbs[salt_hex]
print_fn(f"\n [FOUND] salt={salt_hex}")
print_fn(f" enc_key={enc_key_hex}")
print_fn(f" params: {desc}")
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
print_fn(f" 数据库: {', '.join(dbs)}")
break
elif not salt_hex and remaining_salts:
# 没有 salt暴力尝试所有未匹配的数据库
for rel, path, sz, salt_hex_db, page1 in db_files:
if salt_hex_db in remaining_salts:
ok, desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
key_map[salt_hex_db] = enc_key_hex
remaining_salts.discard(salt_hex_db)
dbs = salt_to_dbs[salt_hex_db]
print_fn(f"\n [FOUND] salt={salt_hex_db}")
print_fn(f" enc_key={enc_key_hex}")
print_fn(f" params: {desc}")
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
print_fn(f" 数据库: {', '.join(dbs)}")
break
if not remaining_salts:
break
return matches
def _find_region(memory_regions, starts, addr, length=4):
idx = bisect.bisect_right(starts, addr) - 1
if idx < 0:
return None
base, end, data = memory_regions[idx]
if base <= addr and addr + length <= end:
return base, end, data
return None
def _read_u32(memory_regions, starts, addr):
region = _find_region(memory_regions, starts, addr, 4)
if not region:
return None
base, _end, data = region
return struct.unpack_from("<I", data, addr - base)[0]
def _valid_ptr(memory_regions, starts, addr, length=4):
return _find_region(memory_regions, starts, addr, length) is not None
def _wxwork_page_size_chain(memory_regions, starts, cipher_addr):
"""Validate the AES cipher object by following the page-size pointer chain.
In WXWork 5.x's inlined wxSQLite3 AES-128 code, the decrypt path uses:
raw_key = cipher + 0x08
aes_ctx = *(cipher + 0x2c)
page_size = *(*(*(cipher + 0x30) + 0x04) + 0x24)
"""
page_size_holder = _read_u32(memory_regions, starts, cipher_addr + 0x30)
if page_size_holder is None or not _valid_ptr(memory_regions, starts, page_size_holder, 8):
return None
page_size_obj = _read_u32(memory_regions, starts, page_size_holder + 4)
if page_size_obj is None or not _valid_ptr(memory_regions, starts, page_size_obj + 0x24, 4):
return None
return _read_u32(memory_regions, starts, page_size_obj + 0x24)
def _record_candidate_key(enc_key, db_files, salt_to_dbs, key_map,
remaining_salts, pid, addr, desc, print_fn):
matched = []
params_desc = desc
for rel, path, sz, salt_hex, page1 in db_files:
if salt_hex not in remaining_salts:
continue
ok, verified_desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
key_map[salt_hex] = enc_key.hex()
remaining_salts.discard(salt_hex)
params_desc = verified_desc or params_desc
matched.extend(salt_to_dbs[salt_hex])
if matched:
print_fn(f"\n [FOUND-STRUCT] enc_key={enc_key.hex()}")
print_fn(f" params: {params_desc}")
print_fn(f" PID={pid} cipher对象地址: 0x{addr:08X}")
print_fn(f" 数据库: {', '.join(sorted(set(matched)))}")
return bool(matched)
def scan_memory_for_wxwork_cipher_structs(h, regions, db_files, salt_to_dbs,
key_map, remaining_salts, pid,
print_fn, max_seconds=120):
"""Scan WXWork heap objects for the in-memory wxSQLite3 AES-128 cipher.
This is intentionally targeted: instead of brute-forcing every 16-byte
window as a key, it looks for the cipher object layout used by WXWork 5.x.
"""
t0 = time.time()
memory_regions = []
total_bytes = 0
for base, size in regions:
data = read_mem(h, base, size)
if data:
memory_regions.append((int(base), int(base) + len(data), data))
total_bytes += len(data)
memory_regions.sort(key=lambda item: item[0])
starts = [item[0] for item in memory_regions]
print_fn(f"[*] 结构体扫描内存: {total_bytes / 1024 / 1024:.0f}MB, {len(memory_regions)} 区域")
checked = 0
ptr_hits = 0
chain_hits = 0
key_tests = 0
page_sizes = {512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}
for base, end, data in memory_regions:
max_off = len(data) - 0x40
off = 0
while off >= 0 and off < max_off:
if time.time() - t0 > max_seconds:
print_fn(
f"[WARN] 结构体扫描超时: checked={checked}, "
f"ptr_hits={ptr_hits}, chain_hits={chain_hits}, key_tests={key_tests}"
)
return key_tests
# The AES-128 decrypt branch checks two non-zero flags at +0 and +4.
flag0, flag4 = struct.unpack_from("<II", data, off)
if flag0 in (1, 2) and flag4 in (1, 2, 4096, 8192, 16384):
cipher_addr = base + off
aes_ctx = struct.unpack_from("<I", data, off + 0x2C)[0]
if _valid_ptr(memory_regions, starts, aes_ctx, 0x40):
ptr_hits += 1
page_size = _wxwork_page_size_chain(memory_regions, starts, cipher_addr)
if page_size in page_sizes:
chain_hits += 1
enc_key = data[off + 8 : off + 24]
if enc_key != b"\x00" * 16 and len(set(enc_key)) >= 6:
key_tests += 1
if _record_candidate_key(
enc_key, db_files, salt_to_dbs, key_map,
remaining_salts, pid, cipher_addr,
f"wxSQLite3 AES-128-CBC, page_size={page_size}",
print_fn,
):
if not remaining_salts:
return key_tests
checked += 1
off += 4
print_fn(
f"[*] 结构体扫描完成: checked={checked}, ptr_hits={ptr_hits}, "
f"chain_hits={chain_hits}, key_tests={key_tests}"
)
return key_tests
def cross_verify_wxwork_keys(db_files, salt_to_dbs, key_map, print_fn):
"""用已找到的 key 交叉验证未匹配的 salt。"""
missing_salts = set(salt_to_dbs.keys()) - set(key_map.keys())
if not missing_salts or not key_map:
return
print_fn(f"\n还有 {len(missing_salts)} 个 salt 未匹配,尝试交叉验证...")
for salt_hex in list(missing_salts):
for rel, path, sz, s, page1 in db_files:
if s == salt_hex:
for known_salt, known_key_hex in key_map.items():
enc_key = bytes.fromhex(known_key_hex)
ok, desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
key_map[salt_hex] = known_key_hex
print_fn(f" [CROSS] salt={salt_hex} 可用 key from salt={known_salt}")
missing_salts.discard(salt_hex)
break
def save_wxwork_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print_fn):
"""输出扫描结果并保存 JSON。"""
print_fn(f"\n{'=' * 60}")
print_fn(f"结果: {len(key_map)}/{len(salt_to_dbs)} salts 找到密钥")
result = {}
for rel, path, sz, salt_hex, page1 in db_files:
if salt_hex in key_map:
result[rel] = {
"enc_key": key_map[salt_hex],
"salt": salt_hex,
"size_mb": round(sz / 1024 / 1024, 1)
}
print_fn(f" OK: {rel} ({sz / 1024 / 1024:.1f}MB)")
else:
print_fn(f" MISSING: {rel} (salt={salt_hex})")
if not result:
print_fn(f"\n[!] 未提取到任何密钥,保留已有的 {out_file}(如存在)")
raise RuntimeError("未能从任何企业微信进程中提取到密钥")
result["_db_dir"] = db_dir
with open(out_file, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2, ensure_ascii=False)
print_fn(f"\n密钥保存到: {out_file}")
missing = [rel for rel, path, sz, salt_hex, page1 in db_files if salt_hex not in key_map]
if missing:
print_fn(f"\n未找到密钥的数据库:")
for rel in missing:
print_fn(f" {rel}")
# ── 配置加载 ─────────────────────────────────────────────────────────
def _load_wxwork_config():
"""从 config.json 加载企业微信配置,必要时自动检测"""
from config import _config_file_path, _app_base_dir
config_file = _config_file_path()
cfg = {}
if os.path.exists(config_file):
try:
with open(config_file, encoding="utf-8") as f:
cfg = json.load(f)
except json.JSONDecodeError:
cfg = {}
db_dir = cfg.get("wxwork_db_dir", "")
if not db_dir or not os.path.isdir(db_dir):
detected = auto_detect_wxwork_db_dir()
if detected:
print(f"[+] 自动检测到企业微信数据目录: {detected}")
cfg["wxwork_db_dir"] = detected
with open(config_file, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"[+] 已保存到: {config_file}")
db_dir = detected
else:
print("[!] 未能自动检测企业微信数据目录")
print(f" 请在 {config_file} 中设置 wxwork_db_dir 字段")
print(" 路径格式: C:\\Users\\<用户>\\Documents\\WXWork\\<account_id>\\Data")
sys.exit(1)
keys_file = cfg.get("wxwork_keys_file", "wxwork_keys.json")
base = _app_base_dir()
if not os.path.isabs(keys_file):
keys_file = os.path.join(base, keys_file)
return {"wxwork_db_dir": db_dir, "wxwork_keys_file": keys_file}
# ── 主流程 ───────────────────────────────────────────────────────────
def main():
cfg = _load_wxwork_config()
db_dir = cfg["wxwork_db_dir"]
out_file = cfg["wxwork_keys_file"]
print("=" * 60)
print(" 提取所有企业微信数据库密钥")
print("=" * 60)
# 1. 收集所有DB文件及其salt
db_files, salt_to_dbs = collect_db_files(db_dir)
db_files, salt_to_dbs = filter_encrypted_dbs(db_files, salt_to_dbs)
print(f"\n找到 {len(db_files)} 个加密数据库, {len(salt_to_dbs)} 个不同的salt")
for salt_hex, dbs in sorted(salt_to_dbs.items(), key=lambda x: len(x[1]), reverse=True):
print(f" salt {salt_hex}: {', '.join(dbs)}")
# 2. 打开所有企业微信进程
pids = get_wxwork_pids()
# Some versions do not keep the key as SQL literal x'...'. Bare ASCII
# hex scanning is much slower, so keep it behind an explicit switch.
hex_re = re.compile(b"x'([0-9a-fA-F]{32,192})'")
scan_bare_hex = "--scan-bare-hex" in sys.argv
bare_hex_re = re.compile(b"(?<![0-9a-fA-F])([0-9a-fA-F]{32})(?![0-9a-fA-F])")
if scan_bare_hex:
print("[*] 已启用裸 32-hex key 扫描,速度会明显变慢")
key_map = {}
remaining_salts = set(salt_to_dbs.keys())
all_hex_matches = 0
all_bare_hex_matches = 0
t0 = time.time()
for pid, mem_kb in pids:
h = kernel32.OpenProcess(0x0010 | 0x0400, False, pid)
if not h:
print(f"[WARN] 无法打开进程 PID={pid},跳过")
continue
try:
regions = enum_regions(h)
total_bytes = sum(s for _, s in regions)
total_mb = total_bytes / 1024 / 1024
print(f"\n[*] 扫描 PID={pid} ({total_mb:.0f}MB, {len(regions)} 区域)")
scanned_bytes = 0
for reg_idx, (base, size) in enumerate(regions):
data = read_mem(h, base, size)
scanned_bytes += size
if not data:
continue
all_hex_matches += scan_memory_for_wxwork_keys(
data, hex_re, db_files, salt_to_dbs,
key_map, remaining_salts, base, pid, print,
)
if scan_bare_hex and remaining_salts:
all_bare_hex_matches += scan_memory_for_wxwork_keys(
data, bare_hex_re, db_files, salt_to_dbs,
key_map, remaining_salts, base, pid, print,
)
if (reg_idx + 1) % 200 == 0:
elapsed = time.time() - t0
progress = scanned_bytes / total_bytes * 100 if total_bytes else 100
print(
f" [{progress:.1f}%] {len(key_map)}/{len(salt_to_dbs)} salts matched, "
f"{all_hex_matches} x'...' patterns, "
f"{all_bare_hex_matches} bare hex patterns, {elapsed:.1f}s"
)
if remaining_salts:
print("\n[*] 未找到 x'...' 形式 key尝试 WXWork 5.x cipher 结构体扫描...")
scan_memory_for_wxwork_cipher_structs(
h, regions, db_files, salt_to_dbs,
key_map, remaining_salts, pid, print,
)
finally:
kernel32.CloseHandle(h)
if not remaining_salts:
print(f"\n[+] 所有密钥已找到,跳过剩余进程")
break
elapsed = time.time() - t0
print(
f"\n扫描完成: {elapsed:.1f}s, {len(pids)} 个进程, "
f"{all_hex_matches} x'...' 模式, {all_bare_hex_matches} bare hex 模式"
)
cross_verify_wxwork_keys(db_files, salt_to_dbs, key_map, print)
save_wxwork_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print)
if __name__ == '__main__':
try:
main()
except RuntimeError as e:
print(f"\n[ERROR] {e}")
sys.exit(1)

16
main.py
View File

@@ -261,6 +261,16 @@ def print_usage():
print(" python main.py status 显示当前状态和磁盘用量")
def _call_with_argv(func, argv):
"""调用子命令 main() 时临时隔离 sys.argv避免 argparse 读到外层命令。"""
old_argv = sys.argv[:]
try:
sys.argv = argv
return func()
finally:
sys.argv = old_argv
def main():
print("=" * 60)
print(" WeChat Decrypt")
@@ -301,19 +311,19 @@ def main():
print("[*] 开始解密全部数据库...")
print()
from decrypt_db import main as decrypt_all
decrypt_all()
_call_with_argv(decrypt_all, ["decrypt_db.py", *sys.argv[2:]])
elif cmd in ("export", "all"):
print("[*] 开始解密全部数据库...")
print()
from decrypt_db import main as decrypt_all
decrypt_all()
_call_with_argv(decrypt_all, ["decrypt_db.py"])
print()
print("[*] 开始批量导出聊天记录...")
print()
from export_all_chats import main as export_all
try:
export_all()
_call_with_argv(export_all, ["export_all_chats.py"])
except SystemExit:
pass

View File

@@ -1,4 +1,6 @@
pycryptodome>=3.19,<4
zstandard>=0.22,<1
mcp>=1.0,<2
pilk>=0.2
pyinstaller>=6.0
# 可选:进度条 (pip install tqdm)

View File

@@ -0,0 +1,48 @@
import os
from Crypto.Cipher import AES
from wxwork_crypto import (
PAGE_SZ,
SQLITE_HDR,
decrypt_wxsqlite3_aes128_page,
derive_wxsqlite3_aes128_page_key,
generate_initial_vector,
is_wxsqlite3_aes128_page1,
verify_wxsqlite3_aes128_key,
)
def _encrypt_block(raw_key, page_no, data):
page_key = derive_wxsqlite3_aes128_page_key(raw_key, page_no)
iv = generate_initial_vector(page_no)
return AES.new(page_key, AES.MODE_CBC, iv).encrypt(data)
def _encrypt_page1_new_scheme(raw_key, plain_page):
data = bytearray(plain_page)
db_header = bytes(data[16:24])
data[:16] = _encrypt_block(raw_key, 1, bytes(data[:16]))
data[16:] = _encrypt_block(raw_key, 1, bytes(data[16:]))
data[8:16] = data[16:24]
data[16:24] = db_header
return bytes(data)
def _plain_sqlite_page1():
page = bytearray(PAGE_SZ)
page[:16] = SQLITE_HDR
page[16:24] = bytes.fromhex("1000020200402020")
page[100] = 0x0D
return bytes(page)
def test_wxsqlite3_aes128_page1_roundtrip():
raw_key = bytes.fromhex("00112233445566778899aabbccddeeff")
plain = _plain_sqlite_page1()
encrypted = _encrypt_page1_new_scheme(raw_key, plain)
assert is_wxsqlite3_aes128_page1(encrypted)
assert verify_wxsqlite3_aes128_key(raw_key, encrypted)
assert not verify_wxsqlite3_aes128_key(os.urandom(16), encrypted)
assert decrypt_wxsqlite3_aes128_page(raw_key, encrypted, 1) == plain

133
voice_to_mp3.py Normal file
View File

@@ -0,0 +1,133 @@
"""从 media_0.db 提取所有语音数据按用户名分目录SILK_V3 转 MP3"""
import sqlite3
import subprocess
import tempfile
import os
import sys
from datetime import datetime
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
import pilk
from config import load_config
_cfg = load_config()
DB_PATH = os.path.join(_cfg["decrypted_dir"], "message", "media_0.db")
CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db")
OUTPUT_DIR = _cfg["output_base_dir"]
_CONTACT_FILTER = None
_filter_raw = os.environ.get("WECHAT_EXPORT_CONTACTS", "").strip()
if _filter_raw:
_CONTACT_FILTER = set(_filter_raw.split(","))
print(f"联系人筛选: {len(_CONTACT_FILTER)}")
def silk_to_mp3(voice_data, output_path):
"""将微信 SILK 语音数据转换为 MP3"""
# 去掉微信格式的 0x02 前缀
if voice_data[0:1] == b'\x02':
silk_data = voice_data[1:]
else:
silk_data = voice_data
if not silk_data.startswith(b'#!SILK_V3'):
print(f" 警告:数据不以 #!SILK_V3 开头,跳过")
return False
# 补上结尾标记
if not silk_data.endswith(b'\xff\xff'):
silk_data += b'\xff\xff'
silk_file = tempfile.mktemp(suffix=".silk")
pcm_file = tempfile.mktemp(suffix=".pcm")
try:
with open(silk_file, "wb") as f:
f.write(silk_data)
pilk.decode(silk_file, pcm_file)
result = subprocess.run([
"ffmpeg", "-y", "-f", "s16le", "-ar", "24000", "-ac", "1",
"-i", pcm_file, output_path
], capture_output=True, encoding="utf-8", errors="replace")
return result.returncode == 0
finally:
if os.path.exists(silk_file):
os.remove(silk_file)
if os.path.exists(pcm_file):
os.remove(pcm_file)
# 1. 读取 Name2Id 映射 (rowid -> user_name)
conn = sqlite3.connect(DB_PATH)
name_map = {}
for rowid, user_name in conn.execute("SELECT rowid, user_name FROM Name2Id"):
name_map[rowid] = user_name
print(f"{len(name_map)} 个用户")
# 2. 读取 contact 信息 (user_name -> {remark, nick_name, alias, ...})
contact_map = {}
try:
cconn = sqlite3.connect(CONTACT_DB_PATH)
for row in cconn.execute("SELECT username, alias, remark, nick_name FROM contact"):
uname, alias, remark, nick_name = row
contact_map[uname] = {"username": uname, "alias": alias or "", "remark": remark or "", "nick_name": nick_name or ""}
cconn.close()
print(f"联系人数据库加载: {len(contact_map)}")
except Exception as e:
print(f"联系人数据库读取失败: {e}")
def display_name(user_name):
"""优先 remark > nick_name > user_name"""
info = contact_map.get(user_name, {})
return info.get("remark") or info.get("nick_name") or user_name
def safe_dirname(name):
"""替换目录名中的非法字符"""
for ch in r'\/:*?"<>|':
name = name.replace(ch, "_")
return name.strip() or "unknown"
# 2. 查询所有语音,按 chat_name_id 关联用户名
rows = conn.execute("SELECT chat_name_id, create_time, local_id, voice_data FROM VoiceInfo ORDER BY chat_name_id, create_time").fetchall()
conn.close()
print(f"{len(rows)} 条语音")
# 3. 遍历转换
success = 0
fail = 0
for chat_name_id, create_time, local_id, voice_data in rows:
user_name = name_map.get(chat_name_id, f"unknown_{chat_name_id}")
if _CONTACT_FILTER and user_name not in _CONTACT_FILTER:
continue
dname = safe_dirname(display_name(user_name))
dt = datetime.fromtimestamp(create_time)
filename = dt.strftime("%Y%m%d_%H%M%S") + f"_{local_id}.mp3"
user_dir = os.path.join(OUTPUT_DIR, dname, "voice")
os.makedirs(user_dir, exist_ok=True)
# 写入 .info 文件(只写一次,写到联系人根目录)
info_path = os.path.join(OUTPUT_DIR, dname, ".info")
if not os.path.exists(info_path):
info = contact_map.get(user_name, {"username": user_name, "alias": "", "remark": "", "nick_name": ""})
with open(info_path, "w", encoding="utf-8") as f:
f.write(f"username: {info['username']}\n")
f.write(f"alias: {info['alias']}\n")
f.write(f"nick_name: {info['nick_name']}\n")
f.write(f"remark: {info['remark']}\n")
output_path = os.path.join(user_dir, filename)
if os.path.exists(output_path):
success += 1
continue
ok = silk_to_mp3(voice_data, output_path)
if ok:
success += 1
print(f" [{success}/{len(rows)}] {dname}/{filename}")
else:
fail += 1
print(f" 失败: {dname}/{filename}")
print(f"\n完成: 成功 {success}, 失败 {fail}")

132
wxwork_crypto.py Normal file
View File

@@ -0,0 +1,132 @@
import hashlib
import os
import sqlite3
import struct
from Crypto.Cipher import AES
PAGE_SZ = 4096
SQLITE_HDR = b"SQLite format 3\x00"
WXSQLITE3_SALT = b"sAlT"
def _modmult(a, b, c, m, s):
q = s // a
s = b * (s - a * q) - c * q
if s < 0:
s += m
return s
def generate_initial_vector(page_no):
"""Match SQLite3MultipleCiphers sqlite3mcGenerateInitialVector()."""
z = page_no + 1
initkey = bytearray(16)
for idx in range(4):
z = _modmult(52774, 40692, 3791, 2147483399, z)
initkey[idx * 4 : idx * 4 + 4] = struct.pack("<I", z & 0xFFFFFFFF)
return hashlib.md5(initkey).digest()
def derive_wxsqlite3_aes128_page_key(raw_key, page_no):
"""Derive the per-page AES-128 key used by wxSQLite3 AES-128-CBC."""
if len(raw_key) != 16:
raise ValueError("wxSQLite3 AES-128 raw key must be 16 bytes")
material = raw_key + struct.pack("<I", page_no) + WXSQLITE3_SALT
return hashlib.md5(material).digest()
def is_plain_sqlite_page(page):
return page[: len(SQLITE_HDR)] == SQLITE_HDR
def has_wxsqlite3_plain_header_fragment(page):
"""New wxSQLite3 AES mode keeps SQLite header bytes 16..23 in plaintext."""
if len(page) < 24:
return False
header = page[16:24]
page_size = (header[0] << 8) | header[1]
if page_size == 1:
page_size = 65536
return (
page_size >= 512
and page_size <= 65536
and (page_size & (page_size - 1)) == 0
and header[5] == 0x40
and header[6] == 0x20
and header[7] == 0x20
)
def is_wxsqlite3_aes128_page1(page):
return not is_plain_sqlite_page(page) and has_wxsqlite3_plain_header_fragment(page)
def _decrypt_aes128_cbc(raw_key, page_no, data):
page_key = derive_wxsqlite3_aes128_page_key(raw_key, page_no)
iv = generate_initial_vector(page_no)
return AES.new(page_key, AES.MODE_CBC, iv).decrypt(data)
def decrypt_wxsqlite3_aes128_page(raw_key, page_data, page_no):
"""Decrypt one wxSQLite3 AES-128-CBC page to a normal SQLite page."""
if len(page_data) != PAGE_SZ:
raise ValueError(f"page must be exactly {PAGE_SZ} bytes")
data = bytearray(page_data)
if page_no == 1 and has_wxsqlite3_plain_header_fragment(data):
db_header_fragment = bytes(data[16:24])
data[16:24] = data[8:16]
decrypted_tail = _decrypt_aes128_cbc(raw_key, page_no, bytes(data[16:]))
data[16:] = decrypted_tail
if bytes(data[16:24]) != db_header_fragment:
raise ValueError("wxSQLite3 AES-128 key validation failed")
data[:16] = SQLITE_HDR
return bytes(data)
return _decrypt_aes128_cbc(raw_key, page_no, bytes(data))
def looks_like_sqlite_page1(page):
if page[: len(SQLITE_HDR)] != SQLITE_HDR:
return False
if len(page) < 108:
return False
btree_page_type = page[100]
return btree_page_type in (0x02, 0x05, 0x0A, 0x0D)
def verify_wxsqlite3_aes128_key(raw_key, page1):
if len(raw_key) != 16 or len(page1) < PAGE_SZ:
return False
try:
decrypted = decrypt_wxsqlite3_aes128_page(raw_key, page1[:PAGE_SZ], 1)
except (ValueError, KeyError):
return False
return looks_like_sqlite_page1(decrypted)
def decrypt_wxwork_database(db_path, out_path, raw_key):
size = os.path.getsize(db_path)
total_pages = (size + PAGE_SZ - 1) // PAGE_SZ
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(db_path, "rb") as fin, open(out_path, "wb") as fout:
for page_no in range(1, total_pages + 1):
page = fin.read(PAGE_SZ)
if not page:
break
if len(page) < PAGE_SZ:
page += b"\x00" * (PAGE_SZ - len(page))
fout.write(decrypt_wxsqlite3_aes128_page(raw_key, page, page_no))
def verify_sqlite_file(path):
conn = sqlite3.connect(path)
try:
return [row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()]
finally:
conn.close()