diff --git a/.gitignore b/.gitignore index db821c3..3537ab4 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/EXE_USAGE.md b/EXE_USAGE.md new file mode 100644 index 0000000..d72f7c0 --- /dev/null +++ b/EXE_USAGE.md @@ -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` diff --git a/README.md b/README.md index 5cefe36..81a5961 100644 --- a/README.md +++ b/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)。检测变化时: diff --git a/WeChatDecrypt.spec b/WeChatDecrypt.spec new file mode 100644 index 0000000..8f9218d --- /dev/null +++ b/WeChatDecrypt.spec @@ -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, +) diff --git a/app_gui.py b/app_gui.py new file mode 100644 index 0000000..74b5cc1 --- /dev/null +++ b/app_gui.py @@ -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() diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..51f5ee6 --- /dev/null +++ b/build.bat @@ -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 diff --git a/config.py b/config.py index 295687d..4099795 100644 --- a/config.py +++ b/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//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]) diff --git a/export_messages.py b/export_messages.py new file mode 100644 index 0000000..0deb581 --- /dev/null +++ b/export_messages.py @@ -0,0 +1,341 @@ +"""导出微信消息记录到 CSV / HTML / JSON +目录结构: export//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}>(.*?)", 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 = """\ + + + + + +{title} + + + +
{title}
+
+{body} +
+ + +""" + +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'
{day}
') + last_date = day + + if m["is_system"]: + parts.append( + f'
' + f'{_html_escape(m["display_content"])}
' + ) + 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'
{_html_escape(m["sender"])}
' + + type_tag = "" + if m["type"] != 1: + type_tag = f'
{m["type_name"]}
' + + parts.append( + f'
' + f'
{initial}
' + f'
' + f'{sender_label}' + f'
{_html_escape(m["display_content"])}
' + f'{type_tag}' + f'
{m["time_str"]}
' + f'
' + ) + + 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_ 表 + 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)}") diff --git a/requirements.txt b/requirements.txt index af9f591..ed397cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ pycryptodome>=3.19,<4 zstandard>=0.22,<1 mcp>=1.0,<2 +pilk>=0.2 +pyinstaller>=6.0 diff --git a/voice_to_mp3.py b/voice_to_mp3.py new file mode 100644 index 0000000..ffb2ad8 --- /dev/null +++ b/voice_to_mp3.py @@ -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}")