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:
xincheng
2026-04-04 06:12:03 +08:00
parent b80e7d1c14
commit a84698b9fc
10 changed files with 1355 additions and 30 deletions

406
.gitignore vendored
View File

@@ -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
View 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 BOMExcel 双击即可正确显示中文
- 字段时间、发送者、消息类型、内容、server_id
### HTML
- 浏览器打开,模拟微信聊天界面
- 左侧气泡为接收消息,右侧为发送消息
- 按日期自动分组
### JSON
- 完整结构化数据,包含所有元信息
- 适合程序二次处理或 AI 训练
## 配置文件
首次运行会自动检测微信数据目录并生成 `config.json`
```json
{
"db_dir": "D:\\xwechat_files\\wxid_xxx\\db_storage",
"keys_file": "all_keys.json",
"decrypted_dir": "decrypted",
"wechat_process": "Weixin.exe"
}
```
如果自动检测失败,请手动修改 `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`

View File

@@ -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` | 一键打包为单 exePyInstaller |
| `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
View File

@@ -0,0 +1,45 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
datas = [('main.py', '.'), ('config.py', '.'), ('decrypt_db.py', '.'), ('export_messages.py', '.'), ('voice_to_mp3.py', '.'), ('find_all_keys.py', '.'), ('find_all_keys_windows.py', '.'), ('find_all_keys_linux.py', '.'), ('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
View 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
View 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

View File

@@ -10,6 +10,18 @@ import sys
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
# 打包后 __file__ 指向临时目录优先使用环境变量或5 cwd
def _app_base_dir():
d = os.environ.get("WECHAT_DECRYPT_APP_DIR")
if d and os.path.isdir(d):
return d
return os.path.dirname(os.path.abspath(__file__))
def _config_file_path():
base = _app_base_dir()
p = os.path.join(base, "config.json")
if os.path.exists(p):
return p
return CONFIG_FILE
_SYSTEM = platform.system().lower()
if _SYSTEM == "linux":
@@ -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
View 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("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace('"','&quot;')
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)}")

View File

@@ -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
View 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}")