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:
401
.gitignore
vendored
401
.gitignore
vendored
@@ -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
127
EXE_USAGE.md
Normal 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 BOM,Excel 双击即可正确显示中文
|
||||
- 字段:时间、发送者、消息类型、内容、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
118
README.md
@@ -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` | 一键打包为单 exe(PyInstaller) |
|
||||
| `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
45
WeChatDecrypt.spec
Normal 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
929
app_gui.py
Normal 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
181
batch_decrypt_images.py
Normal 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
59
build.bat
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
74
config.py
74
config.py
@@ -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
272
decrypt_sns.py
Normal 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
176
decrypt_wxwork_db.py
Normal 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
713
export_messages.py
Normal 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("&","&").replace("<","<").replace(">",">").replace('"','"')
|
||||
|
||||
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
820
export_sns.py
Normal 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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
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
745
export_wxwork_messages.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
654
find_wxwork_keys.py
Normal 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
16
main.py
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
48
tests/test_wxwork_crypto.py
Normal file
48
tests/test_wxwork_crypto.py
Normal 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
133
voice_to_mp3.py
Normal 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
132
wxwork_crypto.py
Normal 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()
|
||||
Reference in New Issue
Block a user