Add GUI, packaging and export/voice tools
Introduce a tkinter GUI and tooling to produce a single executable and export/convert message data. Adds app_gui.py (GUI launcher that runs decrypt/export/voice subtasks), export_messages.py (export messages to CSV/HTML/JSON), voice_to_mp3.py (extract SILK_V3 voice blobs and convert to MP3 via pilk + ffmpeg), WeChatDecrypt.spec and build.bat (PyInstaller spec and convenience build script), and EXE_USAGE.md (usage for the standalone exe). Update config.py to detect the application base directory when packaged, update README.md to document the GUI and packaging flow, and add pilk/pyinstaller to requirements.txt. Also expand .gitignore with common IDE/build/temp patterns and add output/data directories to ignore. These changes enable one-file packaging and provide end-user tools for decrypting, exporting and converting audio.
This commit is contained in:
406
.gitignore
vendored
406
.gitignore
vendored
@@ -1,25 +1,389 @@
|
||||
# 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
|
||||
|
||||
# Hook outputs
|
||||
hook_output.txt
|
||||
hook_start_output.txt
|
||||
hook_stderr.txt
|
||||
run_hook.bat
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Python
|
||||
# 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/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
*.pyc
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# 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/
|
||||
|
||||
data/
|
||||
export/
|
||||
build/
|
||||
dist/
|
||||
decrypted/
|
||||
all_keys.json
|
||||
config.json
|
||||
106
EXE_USAGE.md
Normal file
106
EXE_USAGE.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# WeChat Decrypt 工具箱 使用说明
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. **启动微信**并登录账号
|
||||
2. 双击 `WeChatDecrypt.exe` 打开工具箱
|
||||
3. 按顺序点击三个按钮:
|
||||
- **① 解密数据库** → 从微信进程提取密钥并解密数据库到 `decrypted/` 目录
|
||||
- **② 导出消息** → 将聊天记录导出为 CSV / HTML / JSON 到 `export/` 目录
|
||||
- **③ 转换音频** → 将语音消息从 SILK 格式转为 MP3 到 `data/` 目录
|
||||
|
||||
## 前置要求
|
||||
|
||||
- Windows 10 / 11
|
||||
- 微信 PC 版已登录(解密时需要微信进程运行)
|
||||
- [FFmpeg](https://ffmpeg.org/download.html) 已安装并加入 PATH(转换音频需要)
|
||||
|
||||
### 检查 FFmpeg
|
||||
|
||||
打开命令提示符,输入:
|
||||
```
|
||||
ffmpeg -version
|
||||
```
|
||||
如果提示"不是内部或外部命令",需要先安装 FFmpeg。
|
||||
|
||||
## 输出目录说明
|
||||
|
||||
运行后在 exe 所在目录下生成以下文件夹:
|
||||
|
||||
```
|
||||
WeChatDecrypt.exe
|
||||
config.json ← 首次运行自动生成的配置文件
|
||||
decrypted/ ← ① 解密后的数据库文件
|
||||
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"
|
||||
}
|
||||
```
|
||||
|
||||
如果自动检测失败,请手动修改 `db_dir` 为你的微信数据目录。
|
||||
路径可在:微信设置 → 文件管理 中找到。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 点击"解密数据库"提示未检测到微信进程**
|
||||
A: 请确保微信 PC 版已启动并登录,然后重试。
|
||||
|
||||
**Q: 解密失败 / 密钥提取失败**
|
||||
A: 检查 `config.json` 中的 `db_dir` 是否与当前登录的微信账号匹配。切换账号后需要删除 `all_keys.json` 重新提取。
|
||||
|
||||
**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`
|
||||
31
README.md
31
README.md
@@ -175,6 +175,10 @@ python find_image_key.py
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `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 |
|
||||
@@ -191,6 +195,33 @@ python find_image_key.py
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 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
|
||||
|
||||
#### 直接运行
|
||||
|
||||
```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)。检测变化时:
|
||||
|
||||
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', '.'), ('key_scan_common.py', '.'), ('key_utils.py', '.'), ('decode_image.py', '.'), ('find_image_key.py', '.'), ('find_image_key_monitor.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,
|
||||
)
|
||||
247
app_gui.py
Normal file
247
app_gui.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""WeChat Decrypt GUI — 一键解密 / 导出消息 / 转换音频"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
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)
|
||||
|
||||
|
||||
# ── 子任务入口(当以 --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
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
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"]
|
||||
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
|
||||
|
||||
|
||||
class App(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("WeChat Decrypt 工具箱")
|
||||
self.geometry("750x520")
|
||||
self.resizable(True, True)
|
||||
self.configure(bg="#f0f0f0")
|
||||
self._running = False
|
||||
|
||||
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, 8))
|
||||
|
||||
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_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_voice = ttk.Button(
|
||||
btn_frame, text="③ 转换音频", style="Big.TButton",
|
||||
command=lambda: self._run_task("voice")
|
||||
)
|
||||
self.btn_voice.pack(side="left", expand=True, fill="x", padx=4)
|
||||
|
||||
# 进度条
|
||||
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_export.configure(state=state)
|
||||
self.btn_voice.configure(state=state)
|
||||
|
||||
# ── 任务调度 ───────────────────────────────────────────────────────────
|
||||
def _run_task(self, task: str):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._clear_log()
|
||||
self._set_buttons(False)
|
||||
self.progress.start(15)
|
||||
|
||||
labels = {
|
||||
"decrypt": "解密数据库",
|
||||
"export": "导出消息记录",
|
||||
"voice": "转换音频文件",
|
||||
}
|
||||
self.status_var.set(f"正在{labels[task]}...")
|
||||
|
||||
thread = threading.Thread(target=self._exec_task, args=(task,), daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _exec_task(self, task: str):
|
||||
try:
|
||||
cmd = [sys.executable, "--task", task]
|
||||
|
||||
self._log(f">>> {' '.join(cmd)}\n\n")
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
env["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR
|
||||
|
||||
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()
|
||||
rc = proc.returncode
|
||||
if rc == 0:
|
||||
self.after(0, self._log, "\n✅ 完成!\n")
|
||||
self.after(0, self.status_var.set, "完成")
|
||||
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 __name__ == "__main__":
|
||||
app = App()
|
||||
app.mainloop()
|
||||
53
build.bat
Normal file
53
build.bat
Normal file
@@ -0,0 +1,53 @@
|
||||
@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 "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 "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
|
||||
31
config.py
31
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":
|
||||
@@ -167,12 +179,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", "")
|
||||
@@ -181,15 +194,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")
|
||||
else:
|
||||
@@ -199,7 +212,7 @@ def load_config():
|
||||
cfg = {**_DEFAULT, **cfg}
|
||||
|
||||
# 将相对路径转为绝对路径
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
base = _app_base_dir()
|
||||
for key in ("keys_file", "decrypted_dir", "decoded_image_dir"):
|
||||
if key in cfg and not os.path.isabs(cfg[key]):
|
||||
cfg[key] = os.path.join(base, cfg[key])
|
||||
|
||||
341
export_messages.py
Normal file
341
export_messages.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""导出微信消息记录到 CSV / HTML / JSON
|
||||
目录结构: export/<display_name>/messages.csv|html|json
|
||||
"""
|
||||
import sqlite3
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import json
|
||||
import csv
|
||||
import re
|
||||
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")
|
||||
|
||||
MSG_DB_DIR = r"decrypted\message"
|
||||
CONTACT_DB_PATH = r"decrypted\contact\contact.db"
|
||||
OUTPUT_DIR = "export"
|
||||
|
||||
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}}
|
||||
.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):
|
||||
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>'
|
||||
|
||||
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">{_html_escape(m["display_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
|
||||
|
||||
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]}")
|
||||
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,
|
||||
# 1-on-1: sender==chat_partner -> received(left), else sent(right)
|
||||
"is_received": (sender_uname == chat_username) if not is_group else True,
|
||||
})
|
||||
|
||||
# ── 输出目录 ──────────────────────────────────────────────────────────
|
||||
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")
|
||||
|
||||
# ── CSV ───────────────────────────────────────────────────────────────
|
||||
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:
|
||||
w.writerow([
|
||||
m["time_str"], m["sender"], m["type_name"],
|
||||
m["display_content"], m["server_id"]
|
||||
])
|
||||
|
||||
# ── JSON ──────────────────────────────────────────────────────────────
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
html_path = os.path.join(out_dir, f"{db_name}.html")
|
||||
_write_html(html_path, dname, is_group, messages)
|
||||
|
||||
total_chats += 1
|
||||
total_msgs += len(messages)
|
||||
print(f" [{db_name}] {dname}: {len(messages)} 条消息")
|
||||
|
||||
conn.close()
|
||||
|
||||
print(f"\n完成: {total_chats} 个会话, 共 {total_msgs} 条消息")
|
||||
print(f"输出目录: {os.path.abspath(OUTPUT_DIR)}")
|
||||
@@ -1,3 +1,5 @@
|
||||
pycryptodome>=3.19,<4
|
||||
zstandard>=0.22,<1
|
||||
mcp>=1.0,<2
|
||||
pilk>=0.2
|
||||
pyinstaller>=6.0
|
||||
|
||||
123
voice_to_mp3.py
Normal file
123
voice_to_mp3.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""从 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
|
||||
|
||||
DB_PATH = r"decrypted\message\media_0.db"
|
||||
CONTACT_DB_PATH = r"decrypted\contact\contact.db"
|
||||
OUTPUT_DIR = "data"
|
||||
|
||||
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}")
|
||||
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)
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
|
||||
# 写入 .info 文件(只写一次)
|
||||
info_path = os.path.join(user_dir, ".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}")
|
||||
Reference in New Issue
Block a user