From a84698b9fc661086ebb5db2c2b5d581a22ff2b27 Mon Sep 17 00:00:00 2001 From: xincheng Date: Sat, 4 Apr 2026 06:12:03 +0800 Subject: [PATCH 1/6] 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. --- .gitignore | 406 ++++++++++++++++++++++++++++++++++++++++++--- EXE_USAGE.md | 106 ++++++++++++ README.md | 31 ++++ WeChatDecrypt.spec | 45 +++++ app_gui.py | 247 +++++++++++++++++++++++++++ build.bat | 53 ++++++ config.py | 31 +++- export_messages.py | 341 +++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + voice_to_mp3.py | 123 ++++++++++++++ 10 files changed, 1355 insertions(+), 30 deletions(-) create mode 100644 EXE_USAGE.md create mode 100644 WeChatDecrypt.spec create mode 100644 app_gui.py create mode 100644 build.bat create mode 100644 export_messages.py create mode 100644 voice_to_mp3.py 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}") From ebbea8c89572b25b56dec306a1a386fc5ad490d1 Mon Sep 17 00:00:00 2001 From: xincheng Date: Sun, 5 Apr 2026 02:19:11 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BC=81?= =?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=92=8C=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.example.json | 5 +- config.py | 7 +- export_messages.py | 11 +- find_wxwork_keys.py | 455 ++++++++++++++++++++++++++++++++++++++++++++ voice_to_mp3.py | 8 +- 5 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 find_wxwork_keys.py diff --git a/config.example.json b/config.example.json index abbd8fa..df34721 100644 --- a/config.example.json +++ b/config.example.json @@ -2,5 +2,8 @@ "db_dir": "D:\\xwechat_files\\your_wxid\\db_storage", "keys_file": "all_keys.json", "decrypted_dir": "decrypted", - "wechat_process": "Weixin.exe" + "wechat_process": "Weixin.exe", + "wxwork_db_dir": "", + "wxwork_keys_file": "wxwork_keys.json", + "wxwork_process": "WXWork.exe" } diff --git a/config.py b/config.py index 4099795..a6c9960 100644 --- a/config.py +++ b/config.py @@ -41,6 +41,9 @@ _DEFAULT = { "decrypted_dir": "decrypted", "decoded_image_dir": "decoded_images", "wechat_process": _DEFAULT_PROCESS, + "wxwork_db_dir": "", + "wxwork_keys_file": "wxwork_keys.json", + "wxwork_process": "WXWork.exe", } @@ -213,8 +216,8 @@ def load_config(): # 将相对路径转为绝对路径 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]): + for key in ("keys_file", "decrypted_dir", "decoded_image_dir", "wxwork_keys_file"): + if key in cfg and cfg[key] and not os.path.isabs(cfg[key]): cfg[key] = os.path.join(base, cfg[key]) # 自动推导微信数据根目录(db_dir 的上级目录) diff --git a/export_messages.py b/export_messages.py index 0deb581..bf92e07 100644 --- a/export_messages.py +++ b/export_messages.py @@ -1,5 +1,5 @@ """导出微信消息记录到 CSV / HTML / JSON -目录结构: export//messages.csv|html|json +目录结构: /export//messages.csv|html|json """ import sqlite3 import glob @@ -18,9 +18,12 @@ import zstandard as zstd 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" +from config import load_config + +_cfg = load_config() +MSG_DB_DIR = os.path.join(_cfg["decrypted_dir"], "message") +CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db") +OUTPUT_DIR = os.path.join(_cfg["wechat_base_dir"], "export") MSG_TYPES = { 1: "文本", diff --git a/find_wxwork_keys.py b/find_wxwork_keys.py new file mode 100644 index 0000000..c99c4db --- /dev/null +++ b/find_wxwork_keys.py @@ -0,0 +1,455 @@ +""" +从企业微信(WXWork)进程内存中提取所有数据库的缓存raw key + +企业微信使用 WCDB/SQLCipher 加密,但与个人微信参数不同: +- 个人微信: AES-256-CBC, 32字节密钥, HMAC-SHA512 +- 企业微信: AES-128-CBC, 16字节密钥, 每页根据page index重新派生IV/key + +密钥在内存中的缓存格式仍为 x'',但 key 为16字节(32 hex chars) +""" +import ctypes +import ctypes.wintypes as wt +import functools +import hashlib +import hmac as hmac_mod +import json +import os +import re +import struct +import subprocess +import sys +import time + +from key_scan_common import collect_db_files + +print = functools.partial(print, flush=True) + +# ── Windows 内存读取原语 ────────────────────────────────────────────── + +kernel32 = ctypes.windll.kernel32 +MEM_COMMIT = 0x1000 +READABLE = {0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80} + + +class MBI(ctypes.Structure): + _fields_ = [ + ("BaseAddress", ctypes.c_uint64), ("AllocationBase", ctypes.c_uint64), + ("AllocationProtect", wt.DWORD), ("_pad1", wt.DWORD), + ("RegionSize", ctypes.c_uint64), ("State", wt.DWORD), + ("Protect", wt.DWORD), ("Type", wt.DWORD), ("_pad2", wt.DWORD), + ] + + +def read_mem(h, addr, sz): + buf = ctypes.create_string_buffer(sz) + n = ctypes.c_size_t(0) + if kernel32.ReadProcessMemory(h, ctypes.c_uint64(addr), buf, sz, ctypes.byref(n)): + return buf.raw[:n.value] + return None + + +def enum_regions(h): + regs = [] + addr = 0 + mbi = MBI() + while addr < 0x7FFFFFFFFFFF: + if kernel32.VirtualQueryEx(h, ctypes.c_uint64(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)) == 0: + break + if mbi.State == MEM_COMMIT and mbi.Protect in READABLE and 0 < mbi.RegionSize < 500 * 1024 * 1024: + regs.append((mbi.BaseAddress, mbi.RegionSize)) + nxt = mbi.BaseAddress + mbi.RegionSize + if nxt <= addr: + break + addr = nxt + return regs + + +# ── 常量 ───────────────────────────────────────────────────────────── + +WXWORK_PROCESS = "WXWork.exe" +SQLITE_HEADER_HEX = b"SQLite format 3\x00".hex() + +PAGE_SZ = 4096 +SALT_SZ = 16 + +# 企业微信可能的加密参数组合 (按可能性排序) +# (key_sz, hmac_hash_name, hmac_sz, pbkdf2_iter, reserve_sz) +VERIFY_CONFIGS = [ + # WCDB optimized cipher with AES-128, HMAC-SHA512 (最可能) + (16, "sha512", 64, 2, 80), + # WCDB with AES-128, HMAC-SHA256 + (16, "sha256", 32, 2, 48), + # SQLCipher 3 defaults with AES-128 + (16, "sha512", 64, 4000, 80), + (16, "sha256", 32, 4000, 48), + # AES-256 回退 (与个人微信相同参数) + (32, "sha512", 64, 2, 80), +] + + +def verify_enc_key_wxwork(enc_key, db_page1): + """尝试多种参数组合验证密钥,返回 (成功?, 使用的配置描述)""" + key_sz = len(enc_key) + for cfg_key_sz, hmac_hash, hmac_sz, iterations, reserve_sz in VERIFY_CONFIGS: + if key_sz != cfg_key_sz: + continue + salt = db_page1[:SALT_SZ] + mac_salt = bytes(b ^ 0x3A for b in salt) + mac_key = hashlib.pbkdf2_hmac(hmac_hash, enc_key, mac_salt, iterations, dklen=cfg_key_sz) + hmac_data = db_page1[SALT_SZ: PAGE_SZ - reserve_sz + 16] + stored_hmac = db_page1[PAGE_SZ - hmac_sz: PAGE_SZ] + hash_fn = getattr(hashlib, hmac_hash) + hm = hmac_mod.new(mac_key, hmac_data, hash_fn) + hm.update(struct.pack("= 5: + pid = int(p[1]) + mem = int(p[4].replace(',', '').replace(' K', '').strip() or '0') + pids.append((pid, mem)) + if not pids: + raise RuntimeError(f"{WXWORK_PROCESS} 未运行") + pids.sort(key=lambda x: x[1], reverse=True) + for pid, mem in pids: + print(f"[+] {WXWORK_PROCESS} PID={pid} ({mem // 1024}MB)") + return pids + + +# ── WXWork 数据目录自动检测 ────────────────────────────────────────── + +def auto_detect_wxwork_db_dir(): + """扫描 %USERPROFILE%\\Documents\\WXWork\\*\\Data 寻找包含加密DB的目录""" + docs = os.path.join(os.environ.get("USERPROFILE", ""), "Documents", "WXWork") + if not os.path.isdir(docs): + return None + + candidates = [] + for name in os.listdir(docs): + data_dir = os.path.join(docs, name, "Data") + if not os.path.isdir(data_dir): + continue + has_encrypted = False + for fname in os.listdir(data_dir): + if not fname.endswith(".db"): + continue + fpath = os.path.join(data_dir, fname) + if os.path.getsize(fpath) < PAGE_SZ: + continue + with open(fpath, "rb") as f: + header = f.read(16) + if header != b"SQLite format 3\x00": + has_encrypted = True + break + if has_encrypted: + candidates.append(data_dir) + + if not candidates: + return None + if len(candidates) == 1: + return candidates[0] + + if not sys.stdin.isatty(): + return candidates[0] + print("[!] 检测到多个企业微信数据目录:") + for i, c in enumerate(candidates, 1): + print(f" {i}. {c}") + print(" 0. 跳过,稍后手动配置") + try: + while True: + choice = input(f"请选择 [0-{len(candidates)}]: ").strip() + if choice == "0": + return None + if choice.isdigit() and 1 <= int(choice) <= len(candidates): + return candidates[int(choice) - 1] + print(" 无效输入,请重新选择") + except (EOFError, KeyboardInterrupt): + print() + return None + + +def filter_encrypted_dbs(db_files, salt_to_dbs): + """过滤掉未加密的数据库(salt 等于 SQLite header 的)""" + filtered_files = [ + entry for entry in db_files if entry[3] != SQLITE_HEADER_HEX + ] + filtered_salts = { + s: dbs for s, dbs in salt_to_dbs.items() if s != SQLITE_HEADER_HEX + } + removed = len(db_files) - len(filtered_files) + if removed: + print(f"[*] 跳过 {removed} 个未加密数据库") + return filtered_files, filtered_salts + + +# ── 企业微信内存扫描 ───────────────────────────────────────────────── + +def scan_memory_for_wxwork_keys(data, hex_re, db_files, salt_to_dbs, key_map, + remaining_salts, base_addr, pid, print_fn): + """扫描内存,匹配 hex 模式并用企业微信参数验证密钥。 + + 企业微信 key=16字节(32 hex), salt=16字节(32 hex) + 可能的缓存格式: + - x'<32hex_key><32hex_salt>' = 64 hex total + - x'<32hex_key>' = 32 hex (key only) + - x'<64hex_key><32hex_salt>' = 96 hex (AES-256 回退) + """ + matches = 0 + for m in hex_re.finditer(data): + hex_str = m.group(1).decode() + addr = base_addr + m.start() + matches += 1 + hex_len = len(hex_str) + + # 尝试不同的解释方式 + candidates = [] + + if hex_len == 32: + # 纯 16字节 key + candidates.append((hex_str, None)) + + elif hex_len == 64: + # 优先: 32hex key + 32hex salt (WeCom AES-128) + candidates.append((hex_str[:32], hex_str[32:])) + # 回退: 64hex = 32字节 key (personal WeChat AES-256) + candidates.append((hex_str, None)) + + elif hex_len == 96: + # 优先: 64hex key + 32hex salt (personal WeChat AES-256) + candidates.append((hex_str[:64], hex_str[64:])) + # 也尝试: 32hex key + ... + 32hex salt + candidates.append((hex_str[:32], hex_str[-32:])) + + elif hex_len > 96 and hex_len % 2 == 0: + candidates.append((hex_str[:64], hex_str[-32:])) + candidates.append((hex_str[:32], hex_str[-32:])) + + for enc_key_hex, salt_hex in candidates: + if len(enc_key_hex) not in (32, 64): + continue + enc_key = bytes.fromhex(enc_key_hex) + + if salt_hex and salt_hex in remaining_salts: + # salt 匹配已知数据库 + for rel, path, sz, s, page1 in db_files: + if s == salt_hex: + ok, desc = verify_enc_key_wxwork(enc_key, page1) + if ok: + key_map[salt_hex] = enc_key_hex + remaining_salts.discard(salt_hex) + dbs = salt_to_dbs[salt_hex] + print_fn(f"\n [FOUND] salt={salt_hex}") + print_fn(f" enc_key={enc_key_hex}") + print_fn(f" params: {desc}") + print_fn(f" PID={pid} 地址: 0x{addr:016X}") + print_fn(f" 数据库: {', '.join(dbs)}") + break + elif not salt_hex and remaining_salts: + # 没有 salt,暴力尝试所有未匹配的数据库 + for rel, path, sz, salt_hex_db, page1 in db_files: + if salt_hex_db in remaining_salts: + ok, desc = verify_enc_key_wxwork(enc_key, page1) + if ok: + key_map[salt_hex_db] = enc_key_hex + remaining_salts.discard(salt_hex_db) + dbs = salt_to_dbs[salt_hex_db] + print_fn(f"\n [FOUND] salt={salt_hex_db}") + print_fn(f" enc_key={enc_key_hex}") + print_fn(f" params: {desc}") + print_fn(f" PID={pid} 地址: 0x{addr:016X}") + print_fn(f" 数据库: {', '.join(dbs)}") + break + + if not remaining_salts: + break + + return matches + + +def cross_verify_wxwork_keys(db_files, salt_to_dbs, key_map, print_fn): + """用已找到的 key 交叉验证未匹配的 salt。""" + missing_salts = set(salt_to_dbs.keys()) - set(key_map.keys()) + if not missing_salts or not key_map: + return + print_fn(f"\n还有 {len(missing_salts)} 个 salt 未匹配,尝试交叉验证...") + for salt_hex in list(missing_salts): + for rel, path, sz, s, page1 in db_files: + if s == salt_hex: + for known_salt, known_key_hex in key_map.items(): + enc_key = bytes.fromhex(known_key_hex) + ok, desc = verify_enc_key_wxwork(enc_key, page1) + if ok: + key_map[salt_hex] = known_key_hex + print_fn(f" [CROSS] salt={salt_hex} 可用 key from salt={known_salt}") + missing_salts.discard(salt_hex) + break + + +def save_wxwork_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print_fn): + """输出扫描结果并保存 JSON。""" + print_fn(f"\n{'=' * 60}") + print_fn(f"结果: {len(key_map)}/{len(salt_to_dbs)} salts 找到密钥") + + result = {} + for rel, path, sz, salt_hex, page1 in db_files: + if salt_hex in key_map: + result[rel] = { + "enc_key": key_map[salt_hex], + "salt": salt_hex, + "size_mb": round(sz / 1024 / 1024, 1) + } + print_fn(f" OK: {rel} ({sz / 1024 / 1024:.1f}MB)") + else: + print_fn(f" MISSING: {rel} (salt={salt_hex})") + + if not result: + print_fn(f"\n[!] 未提取到任何密钥,保留已有的 {out_file}(如存在)") + raise RuntimeError("未能从任何企业微信进程中提取到密钥") + + result["_db_dir"] = db_dir + with open(out_file, 'w', encoding='utf-8') as f: + json.dump(result, f, indent=2, ensure_ascii=False) + print_fn(f"\n密钥保存到: {out_file}") + + missing = [rel for rel, path, sz, salt_hex, page1 in db_files if salt_hex not in key_map] + if missing: + print_fn(f"\n未找到密钥的数据库:") + for rel in missing: + print_fn(f" {rel}") + + +# ── 配置加载 ───────────────────────────────────────────────────────── + +def _load_wxwork_config(): + """从 config.json 加载企业微信配置,必要时自动检测""" + from config import _config_file_path, _app_base_dir + + config_file = _config_file_path() + cfg = {} + if os.path.exists(config_file): + try: + with open(config_file, encoding="utf-8") as f: + cfg = json.load(f) + except json.JSONDecodeError: + cfg = {} + + db_dir = cfg.get("wxwork_db_dir", "") + if not db_dir or not os.path.isdir(db_dir): + detected = auto_detect_wxwork_db_dir() + if detected: + print(f"[+] 自动检测到企业微信数据目录: {detected}") + cfg["wxwork_db_dir"] = detected + with open(config_file, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=4, ensure_ascii=False) + print(f"[+] 已保存到: {config_file}") + db_dir = detected + else: + print("[!] 未能自动检测企业微信数据目录") + print(f" 请在 {config_file} 中设置 wxwork_db_dir 字段") + print(" 路径格式: C:\\Users\\<用户>\\Documents\\WXWork\\\\Data") + sys.exit(1) + + keys_file = cfg.get("wxwork_keys_file", "wxwork_keys.json") + base = _app_base_dir() + if not os.path.isabs(keys_file): + keys_file = os.path.join(base, keys_file) + + return {"wxwork_db_dir": db_dir, "wxwork_keys_file": keys_file} + + +# ── 主流程 ─────────────────────────────────────────────────────────── + +def main(): + cfg = _load_wxwork_config() + db_dir = cfg["wxwork_db_dir"] + out_file = cfg["wxwork_keys_file"] + + print("=" * 60) + print(" 提取所有企业微信数据库密钥") + print("=" * 60) + + # 1. 收集所有DB文件及其salt + db_files, salt_to_dbs = collect_db_files(db_dir) + db_files, salt_to_dbs = filter_encrypted_dbs(db_files, salt_to_dbs) + + print(f"\n找到 {len(db_files)} 个加密数据库, {len(salt_to_dbs)} 个不同的salt") + for salt_hex, dbs in sorted(salt_to_dbs.items(), key=lambda x: len(x[1]), reverse=True): + print(f" salt {salt_hex}: {', '.join(dbs)}") + + # 2. 打开所有企业微信进程 + pids = get_wxwork_pids() + + # 宽松正则:匹配 32+ hex chars (16字节key起) + hex_re = re.compile(b"x'([0-9a-fA-F]{32,192})'") + key_map = {} + remaining_salts = set(salt_to_dbs.keys()) + all_hex_matches = 0 + t0 = time.time() + + for pid, mem_kb in pids: + h = kernel32.OpenProcess(0x0010 | 0x0400, False, pid) + if not h: + print(f"[WARN] 无法打开进程 PID={pid},跳过") + continue + + try: + regions = enum_regions(h) + total_bytes = sum(s for _, s in regions) + total_mb = total_bytes / 1024 / 1024 + print(f"\n[*] 扫描 PID={pid} ({total_mb:.0f}MB, {len(regions)} 区域)") + + scanned_bytes = 0 + for reg_idx, (base, size) in enumerate(regions): + data = read_mem(h, base, size) + scanned_bytes += size + if not data: + continue + + all_hex_matches += scan_memory_for_wxwork_keys( + data, hex_re, db_files, salt_to_dbs, + key_map, remaining_salts, base, pid, print, + ) + + if (reg_idx + 1) % 200 == 0: + elapsed = time.time() - t0 + progress = scanned_bytes / total_bytes * 100 if total_bytes else 100 + print( + f" [{progress:.1f}%] {len(key_map)}/{len(salt_to_dbs)} salts matched, " + f"{all_hex_matches} hex patterns, {elapsed:.1f}s" + ) + finally: + kernel32.CloseHandle(h) + + if not remaining_salts: + print(f"\n[+] 所有密钥已找到,跳过剩余进程") + break + + elapsed = time.time() - t0 + print(f"\n扫描完成: {elapsed:.1f}s, {len(pids)} 个进程, {all_hex_matches} hex模式") + + cross_verify_wxwork_keys(db_files, salt_to_dbs, key_map, print) + save_wxwork_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print) + + +if __name__ == '__main__': + try: + main() + except RuntimeError as e: + print(f"\n[ERROR] {e}") + sys.exit(1) diff --git a/voice_to_mp3.py b/voice_to_mp3.py index ffb2ad8..e8d7ebe 100644 --- a/voice_to_mp3.py +++ b/voice_to_mp3.py @@ -10,10 +10,12 @@ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") import pilk +from config import load_config -DB_PATH = r"decrypted\message\media_0.db" -CONTACT_DB_PATH = r"decrypted\contact\contact.db" -OUTPUT_DIR = "data" +_cfg = load_config() +DB_PATH = os.path.join(_cfg["decrypted_dir"], "message", "media_0.db") +CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db") +OUTPUT_DIR = os.path.join(_cfg["wechat_base_dir"], "voice") def silk_to_mp3(voice_data, output_path): """将微信 SILK 语音数据转换为 MP3""" From b9f3161f8460e4fe3084573ea5fc68110f2b8980 Mon Sep 17 00:00:00 2001 From: xincheng Date: Mon, 6 Apr 2026 23:57:51 +0800 Subject: [PATCH 3/6] Add SNS/image export and GUI contact export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce SNS (朋友圈) and batch image tooling and enhance the GUI export workflow. Added new scripts: decrypt_sns.py (decrypt WeChat SNS cache) and batch_decrypt_images.py (bulk .dat image decrypt). Update build scripts and PyInstaller spec to include the new files. app_gui.py: add contact discovery, contact selection/export options dialog, new buttons (find image key, SNS), orchestrate combined export/voice/SNS tasks via subprocesses and env flags, and auto-run export after decryption. config.py: add output_base_dir, auto-detect WeChat Files path, and expose msgattach/xwechat cache dirs. export_messages.py: switch to output_base_dir, add image resource lookup and .dat locating/decryption helpers, and support environment-driven contact/format/image filters. Misc: update build.bat and packaging datas to include the new modules. Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com> --- WeChatDecrypt.spec | 2 +- app_gui.py | 493 +++++++++++++++++++++-- batch_decrypt_images.py | 181 +++++++++ build.bat | 2 + config.py | 26 ++ decrypt_sns.py | 272 +++++++++++++ export_messages.py | 457 +++++++++++++++++++-- export_sns.py | 820 ++++++++++++++++++++++++++++++++++++++ find_image_key.py | 17 +- find_image_key_monitor.py | 16 +- voice_to_mp3.py | 16 +- 11 files changed, 2207 insertions(+), 95 deletions(-) create mode 100644 batch_decrypt_images.py create mode 100644 decrypt_sns.py create mode 100644 export_sns.py diff --git a/WeChatDecrypt.spec b/WeChatDecrypt.spec index 8f9218d..96ab499 100644 --- a/WeChatDecrypt.spec +++ b/WeChatDecrypt.spec @@ -1,7 +1,7 @@ # -*- 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', '.')] +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', '.'), ('decrypt_sns.py', '.'), ('export_sns.py', '.'), ('monitor.py', '.'), ('monitor_web.py', '.'), ('mcp_server.py', '.'), ('config.example.json', '.')] binaries = [] hiddenimports = [] tmp_ret = collect_all('pilk') diff --git a/app_gui.py b/app_gui.py index 74b5cc1..4b00fe2 100644 --- a/app_gui.py +++ b/app_gui.py @@ -3,6 +3,9 @@ import os import sys import subprocess import threading +import sqlite3 +import hashlib +import glob as globmod import tkinter as tk from tkinter import ttk, scrolledtext @@ -12,6 +15,7 @@ if getattr(sys, "frozen", False): else: BASE_DIR = os.path.dirname(os.path.abspath(__file__)) os.chdir(BASE_DIR) +os.environ["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR # ── 子任务入口(当以 --task 参数调用时直接执行对应脚本) ────────────────────── @@ -52,6 +56,9 @@ def _run_subtask(task: str): "decrypt": "main.py", "export": "export_messages.py", "voice": "voice_to_mp3.py", + "find_image_key": "find_image_key.py", + "decrypt_sns": "decrypt_sns.py", + "export_sns": "export_sns.py", } script = mapping.get(task) if not script: @@ -69,6 +76,8 @@ def _run_subtask(task: str): # 将 decrypt 命令传给 main.py if task == "decrypt": sys.argv = ["main.py", "decrypt"] + elif task == "find_image_key": + sys.argv = ["find_image_key.py"] else: sys.argv = [script] @@ -97,6 +106,260 @@ if sys.platform == "win32": pass +# ── 联系人发现 ──────────────────────────────────────────────────────────────── + +def _load_contact_map(decrypted_dir): + """从 contact.db 加载联系人映射 {username: {remark, nick_name, ...}}""" + contact_map = {} + db_path = os.path.join(decrypted_dir, "contact", "contact.db") + if not os.path.exists(db_path): + return contact_map + try: + conn = sqlite3.connect(db_path) + for uname, alias, remark, nick_name in conn.execute( + "SELECT username, alias, remark, nick_name FROM contact" + ): + contact_map[uname] = { + "remark": remark or "", + "nick_name": nick_name or "", + } + conn.close() + except Exception: + pass + return contact_map + + +def _display_name(username, contact_map): + info = contact_map.get(username, {}) + return info.get("remark") or info.get("nick_name") or username + + +def _discover_contacts(): + """扫描所有联系人/会话,返回 (contacts, has_voice) + contacts: [(username, display_name), ...] + has_voice: 是否存在语音数据 + """ + from config import load_config + cfg = load_config() + decrypted_dir = cfg["decrypted_dir"] + + if not os.path.isdir(decrypted_dir): + raise FileNotFoundError(f"解密目录不存在: {decrypted_dir}\n请先运行「解密数据库」") + + contact_map = _load_contact_map(decrypted_dir) + usernames = set() + has_voice = False + + # 从消息数据库扫描 + msg_dir = os.path.join(decrypted_dir, "message") + if os.path.isdir(msg_dir): + db_files = [ + f for f in globmod.glob(os.path.join(msg_dir, "message_*.db")) + if not f.endswith(("_fts.db", "_resource.db")) + ] + print(f"找到 {len(db_files)} 个消息数据库", flush=True) + for db_path in db_files: + try: + conn = sqlite3.connect(db_path) + hash_to_uname = {} + for row in conn.execute("SELECT rowid, user_name FROM Name2Id"): + uname = row[1] + if uname: + h = hashlib.md5(uname.encode()).hexdigest() + hash_to_uname[h] = uname + for (tbl,) in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" + ): + h = tbl[4:] + uname = hash_to_uname.get(h) + if uname: + usernames.add(uname) + conn.close() + except Exception as e: + print(f" 读取 {os.path.basename(db_path)} 失败: {e}", flush=True) + continue + else: + print(f"消息目录不存在: {msg_dir}", flush=True) + + # 从语音数据库扫描 + voice_db = os.path.join(msg_dir, "media_0.db") + if os.path.exists(voice_db): + try: + conn = sqlite3.connect(voice_db) + name_map = {} + for rowid, uname in conn.execute("SELECT rowid, user_name FROM Name2Id"): + name_map[rowid] = uname + for (cid,) in conn.execute("SELECT DISTINCT chat_name_id FROM VoiceInfo"): + uname = name_map.get(cid) + if uname: + usernames.add(uname) + has_voice = True + conn.close() + except Exception as e: + print(f" 读取语音数据库失败: {e}", flush=True) + + print(f"共发现 {len(usernames)} 个会话", flush=True) + result = [(u, _display_name(u, contact_map)) for u in usernames] + result.sort(key=lambda x: x[1].lower()) + return result, has_voice + + +# ── 导出选项对话框 ────────────────────────────────────────────────────────── + +class ExportOptionsDialog(tk.Toplevel): + def __init__(self, parent, contacts, has_voice=False): + """contacts: [(username, display_name), ...] + has_voice: 是否检测到语音数据 + """ + super().__init__(parent) + self.title("导出选项") + self.geometry("460x600") + self.transient(parent) + self.grab_set() + self.result = None + self.configure(bg="#f0f0f0") + self._contacts = contacts + self._vars = {} # username -> BooleanVar + + # ── 导出格式 ── + fmt_frame = ttk.LabelFrame(self, text="导出格式", padding=6) + fmt_frame.pack(fill="x", padx=12, pady=(10, 4)) + + self._fmt_csv = tk.BooleanVar(value=True) + self._fmt_html = tk.BooleanVar(value=False) + self._fmt_json = tk.BooleanVar(value=False) + + ttk.Checkbutton(fmt_frame, text="CSV(默认)", variable=self._fmt_csv).pack(side="left", padx=10) + ttk.Checkbutton(fmt_frame, text="HTML", variable=self._fmt_html).pack(side="left", padx=10) + ttk.Checkbutton(fmt_frame, text="JSON", variable=self._fmt_json).pack(side="left", padx=10) + + # ── 其他选项 ── + opt_frame = ttk.LabelFrame(self, text="其他选项", padding=6) + opt_frame.pack(fill="x", padx=12, pady=(0, 4)) + + self._image_var = tk.BooleanVar(value=True) + ttk.Checkbutton(opt_frame, text="导出并解密图片", + variable=self._image_var).pack(anchor="w", padx=8) + + self._sns_var = tk.BooleanVar(value=False) + ttk.Checkbutton(opt_frame, text="导出朋友圈动态(文案/评论)", + variable=self._sns_var).pack(anchor="w", padx=8) + + self._sns_media_var = tk.BooleanVar(value=False) + ttk.Checkbutton(opt_frame, text=" ↳ 尝试下载朋友圈媒体(可能较慢)", + variable=self._sns_media_var).pack(anchor="w", padx=24) + + self._voice_var = tk.BooleanVar(value=False) + if has_voice: + ttk.Checkbutton(opt_frame, text="同时转换语音为 MP3", + variable=self._voice_var).pack(anchor="w", padx=8) + + # ── 联系人选择 ── + top = ttk.Frame(self) + top.pack(fill="x", padx=12, pady=(4, 4)) + + ttk.Label(top, text=f"共 {len(contacts)} 个会话", + font=("Microsoft YaHei UI", 10)).pack(side="left") + + self._all_selected = True + self._toggle_btn = ttk.Button(top, text="取消全选", command=self._toggle_all) + self._toggle_btn.pack(side="right") + + # 搜索框 + search_frame = ttk.Frame(self) + search_frame.pack(fill="x", padx=12, pady=(0, 4)) + self._search_var = tk.StringVar() + self._search_var.trace_add("write", lambda *_: self._filter_list()) + ttk.Entry(search_frame, textvariable=self._search_var, + font=("Microsoft YaHei UI", 10)).pack(fill="x") + + # 滚动区域 + container = ttk.Frame(self) + container.pack(fill="both", expand=True, padx=12, pady=4) + + self._canvas = tk.Canvas(container, bg="#ffffff", highlightthickness=0) + scrollbar = ttk.Scrollbar(container, orient="vertical", command=self._canvas.yview) + self._inner = ttk.Frame(self._canvas) + + self._inner.bind("", + lambda e: self._canvas.configure(scrollregion=self._canvas.bbox("all"))) + self._canvas.create_window((0, 0), window=self._inner, anchor="nw") + self._canvas.configure(yscrollcommand=scrollbar.set) + + self._canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # 绑定鼠标滚轮 + self._canvas.bind("", lambda e: self._bind_mousewheel()) + self._canvas.bind("", lambda e: self._unbind_mousewheel()) + + # 创建 Checkbutton 列表 + self._cb_widgets = [] + for username, dname in contacts: + var = tk.BooleanVar(value=True) + self._vars[username] = var + label = f"{dname} ({username})" if dname != username else username + cb = ttk.Checkbutton(self._inner, text=label, variable=var) + cb.pack(anchor="w", padx=6, pady=1) + self._cb_widgets.append((username, dname, cb)) + + # 底部按钮 + bottom = ttk.Frame(self) + bottom.pack(fill="x", padx=12, pady=(4, 10)) + + ttk.Button(bottom, text="确定", command=self._on_ok).pack(side="right", padx=4) + ttk.Button(bottom, text="取消", command=self._on_cancel).pack(side="right", padx=4) + + def _bind_mousewheel(self): + self._canvas.bind_all("", + lambda e: self._canvas.yview_scroll(-1 * (e.delta // 120), "units")) + + def _unbind_mousewheel(self): + self._canvas.unbind_all("") + + def _toggle_all(self): + self._all_selected = not self._all_selected + for var in self._vars.values(): + var.set(self._all_selected) + self._toggle_btn.configure(text="取消全选" if self._all_selected else "全选") + + def _filter_list(self): + keyword = self._search_var.get().strip().lower() + for username, dname, cb in self._cb_widgets: + if not keyword or keyword in dname.lower() or keyword in username.lower(): + cb.pack(anchor="w", padx=6, pady=1) + else: + cb.pack_forget() + + def _on_ok(self): + formats = [] + if self._fmt_csv.get(): + formats.append("csv") + if self._fmt_html.get(): + formats.append("html") + if self._fmt_json.get(): + formats.append("json") + + if not formats and not self._voice_var.get() and not self._sns_var.get(): + from tkinter import messagebox + messagebox.showwarning("提示", "请至少选择一种导出格式、朋友圈导出或语音转换", parent=self) + return + + self.result = { + "contacts": [u for u, var in self._vars.items() if var.get()], + "formats": formats, + "include_voice": self._voice_var.get(), + "include_images": self._image_var.get(), + "include_sns": self._sns_var.get(), + "include_sns_media": self._sns_media_var.get(), + } + self.destroy() + + def _on_cancel(self): + self.result = None + self.destroy() + + class App(tk.Tk): def __init__(self): super().__init__() @@ -105,6 +368,13 @@ class App(tk.Tk): self.resizable(True, True) self.configure(bg="#f0f0f0") self._running = False + self._auto_export = False + self._selected_contacts = None + self._export_formats = None + self._include_voice = False + self._include_images = True + self._include_sns = False + self._include_sns_media = False self._build_ui() @@ -121,7 +391,7 @@ class App(tk.Tk): # 按钮区域 btn_frame = ttk.Frame(self) - btn_frame.pack(fill="x", padx=20, pady=(4, 8)) + btn_frame.pack(fill="x", padx=20, pady=(4, 4)) self.btn_decrypt = ttk.Button( btn_frame, text="① 解密数据库", style="Big.TButton", @@ -129,17 +399,35 @@ class App(tk.Tk): ) self.btn_decrypt.pack(side="left", expand=True, fill="x", padx=4) + self.btn_imgkey = ttk.Button( + btn_frame, text="② 查找图片密钥", style="Big.TButton", + command=lambda: self._run_task("find_image_key") + ) + self.btn_imgkey.pack(side="left", expand=True, fill="x", padx=4) + self.btn_export = ttk.Button( - btn_frame, text="② 导出消息", style="Big.TButton", + 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_sns = ttk.Button( + btn_frame, text="④ 朋友圈图片", style="Big.TButton", + command=lambda: self._run_task("decrypt_sns") ) - self.btn_voice.pack(side="left", expand=True, fill="x", padx=4) + self.btn_sns.pack(side="left", expand=True, fill="x", padx=4) + + # 提示信息 + tips_frame = ttk.LabelFrame(self, text="使用提示", padding=6) + tips_frame.pack(fill="x", padx=20, pady=(0, 4)) + tips_text = ( + "• 解密数据库:需要微信正在运行中,会自动提取密钥并解密\n" + "• 查找图片密钥:先在微信中打开 2-3 张图片查看,然后立即运行\n" + "• 导出数据:选择联系人和格式,可同时导出消息/图片/语音\n" + "• 朋友圈图片:解密朋友圈缓存图片(_t缩略图自动跳过)" + ) + ttk.Label(tips_frame, text=tips_text, font=("Microsoft YaHei UI", 9), + wraplength=680, justify="left").pack(anchor="w") # 进度条 self.progress = ttk.Progressbar(self, mode="indeterminate") @@ -177,56 +465,181 @@ class App(tk.Tk): def _set_buttons(self, enabled: bool): state = "normal" if enabled else "disabled" self.btn_decrypt.configure(state=state) + self.btn_imgkey.configure(state=state) self.btn_export.configure(state=state) - self.btn_voice.configure(state=state) + self.btn_sns.configure(state=state) # ── 任务调度 ─────────────────────────────────────────────────────────── def _run_task(self, task: str): if self._running: return self._running = True + self._selected_contacts = None + self._export_formats = None + self._include_voice = False + self._include_sns = False + self._include_sns_media = False self._clear_log() self._set_buttons(False) + + if task == "export": + self.progress.start(15) + self.status_var.set("正在扫描联系人...") + threading.Thread( + target=self._discover_and_select, daemon=True + ).start() + elif task == "find_image_key": + self.progress.start(15) + self.status_var.set("正在扫描微信进程内存...") + threading.Thread(target=self._exec_task, args=(task,), daemon=True).start() + elif task == "decrypt_sns": + self.progress.start(15) + self.status_var.set("正在解密朋友圈图片...") + threading.Thread(target=self._exec_task, args=(task,), daemon=True).start() + else: + self.progress.start(15) + labels = {"decrypt": "解密数据库"} + self.status_var.set(f"正在{labels.get(task, task)}...") + threading.Thread(target=self._exec_task, args=(task,), daemon=True).start() + + def _discover_and_select(self): + """后台扫描联系人,然后在主线程弹出选择对话框""" + try: + contacts, has_voice = _discover_contacts() + except Exception as e: + self.after(0, self._log, f"扫描联系人失败: {e}\n") + self.after(0, self._on_task_done) + return + + if not contacts: + self.after(0, self._log, "未找到任何联系人/会话\n") + self.after(0, self._on_task_done) + return + + self.after(0, self._show_contact_dialog, contacts, has_voice) + + def _show_contact_dialog(self, contacts, has_voice): + self.progress.stop() + self.status_var.set(f"请选择导出选项 ({len(contacts)} 个会话)") + + dlg = ExportOptionsDialog(self, contacts, has_voice=has_voice) + self.wait_window(dlg) + + if dlg.result is None: + self._on_task_done() + return + + if not dlg.result["contacts"]: + self._log("未选择任何联系人\n") + self._on_task_done() + return + + self._selected_contacts = dlg.result["contacts"] + self._export_formats = dlg.result["formats"] + self._include_voice = dlg.result["include_voice"] + self._include_images = dlg.result["include_images"] + self._include_sns = dlg.result["include_sns"] + self._include_sns_media = dlg.result["include_sns_media"] + + self._clear_log() self.progress.start(15) + n_sel = len(dlg.result["contacts"]) + parts = [] + if self._export_formats: + parts.append(f"导出 {'/'.join(f.upper() for f in self._export_formats)}") + if self._include_sns: + parts.append("朋友圈") + if self._include_voice: + parts.append("转换语音") + action = " + ".join(parts) or "处理" + self.status_var.set(f"正在{action}...({n_sel}/{len(contacts)} 个联系人)") + threading.Thread(target=self._exec_combined, daemon=True).start() - labels = { - "decrypt": "解密数据库", - "export": "导出消息记录", - "voice": "转换音频文件", - } - self.status_var.set(f"正在{labels[task]}...") + # ── 子进程执行 ───────────────────────────────────────────────────────── + def _run_subprocess(self, task: str) -> int: + """运行子进程,返回退出码""" + cmd = [sys.executable, "--task", task] + self.after(0, self._log, f">>> {' '.join(cmd)}\n\n") - thread = threading.Thread(target=self._exec_task, args=(task,), daemon=True) - thread.start() + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + env["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR + + if self._selected_contacts: + env["WECHAT_EXPORT_CONTACTS"] = ",".join(self._selected_contacts) + if self._export_formats: + env["WECHAT_EXPORT_FORMATS"] = ",".join(self._export_formats) + env["WECHAT_EXPORT_IMAGES"] = "1" if getattr(self, '_include_images', True) else "0" + if getattr(self, '_include_sns_media', False): + env["WECHAT_SNS_DOWNLOAD_MEDIA"] = "1" + + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=BASE_DIR, + env=env, + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0, + ) + + for raw in proc.stdout: + line = raw.decode("utf-8", errors="replace") + self.after(0, self._log, line) + + proc.wait() + return proc.returncode + + def _exec_combined(self): + """执行导出(消息 + 可选语音)""" + try: + if self._export_formats: + rc = self._run_subprocess("export") + if rc != 0: + self.after(0, self._log, f"\n❌ 导出失败 (返回码 {rc})\n") + self.after(0, self.status_var.set, f"失败 (返回码 {rc})") + return + + if getattr(self, '_include_sns', False): + if self._export_formats: + self.after(0, self._log, "\n\n━━━ 开始导出朋友圈 ━━━\n\n") + rc = self._run_subprocess("export_sns") + if rc != 0: + self.after(0, self._log, f"\n❌ 朋友圈导出失败 (返回码 {rc})\n") + self.after(0, self.status_var.set, f"朋友圈导出失败 (返回码 {rc})") + return + + if self._include_voice: + if self._export_formats or getattr(self, '_include_sns', False): + self.after(0, self._log, "\n\n━━━ 开始转换语音 ━━━\n\n") + rc = self._run_subprocess("voice") + if rc != 0: + self.after(0, self._log, f"\n❌ 语音转换失败 (返回码 {rc})\n") + self.after(0, self.status_var.set, f"语音转换失败 (返回码 {rc})") + return + + self.after(0, self._log, "\n✅ 全部完成!\n") + self.after(0, self.status_var.set, "完成") + except Exception as e: + self.after(0, self._log, f"\n❌ 异常: {e}\n") + self.after(0, self.status_var.set, "异常") + finally: + self._selected_contacts = None + self._export_formats = None + self._include_voice = False + self._include_images = True + self._include_sns = False + self._include_sns_media = False + self.after(0, self._on_task_done) def _exec_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 + rc = self._run_subprocess(task) if rc == 0: self.after(0, self._log, "\n✅ 完成!\n") self.after(0, self.status_var.set, "完成") + if task == "decrypt": + self._auto_export = True else: self.after(0, self._log, f"\n❌ 进程退出,返回码: {rc}\n") self.after(0, self.status_var.set, f"失败 (返回码 {rc})") @@ -240,6 +653,10 @@ class App(tk.Tk): self._running = False self.progress.stop() self._set_buttons(True) + if self._auto_export: + self._auto_export = False + self._log("\n解密完成,自动进入导出流程...\n\n") + self.after(500, lambda: self._run_task("export")) if __name__ == "__main__": diff --git a/batch_decrypt_images.py b/batch_decrypt_images.py new file mode 100644 index 0000000..23f5505 --- /dev/null +++ b/batch_decrypt_images.py @@ -0,0 +1,181 @@ +"""批量解密 .dat 图片文件 + +用法: python batch_decrypt_images.py <文件夹路径> [输出目录] + +递归扫描指定文件夹下的所有 .dat 文件并解密。 +输出目录默认为 <文件夹路径>_decoded/,保持原有子目录结构。 +""" +import os +import sys +import glob +import struct + +# Windows 控制台 UTF-8 +if sys.platform == "win32": + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from config import load_config + +_cfg = load_config() +IMAGE_AES_KEY = _cfg.get("image_aes_key", "") +IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88) + +# ── V2/V1 magic ────────────────────────────────────────────────────────────── +_V2_MAGIC_FULL = b'\x07\x08V2\x08\x07' +_V1_MAGIC_FULL = b'\x07\x08V1\x08\x07' + +_IMAGE_MAGICS = { + 'jpg': [0xFF, 0xD8, 0xFF], + 'png': [0x89, 0x50, 0x4E, 0x47], + 'gif': [0x47, 0x49, 0x46, 0x38], + 'webp': [0x52, 0x49, 0x46, 0x46], + 'bmp': [0x42, 0x4D], + 'tif': [0x49, 0x49, 0x2A, 0x00], +} + + +def _detect_format(header): + if header[:3] == bytes([0xFF, 0xD8, 0xFF]): + return 'jpg' + if header[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): + return 'png' + if header[:3] == b'GIF': + return 'gif' + if header[:2] == b'BM': + return 'bmp' + if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WEBP': + return 'webp' + if header[:4] == bytes([0x49, 0x49, 0x2A, 0x00]): + return 'tif' + if header[:4] == b'wxgf': + return 'hevc' + return 'bin' + + +def decrypt_dat(dat_path): + """解密单个 .dat 文件,返回 (bytes, format) 或 (None, None)""" + with open(dat_path, 'rb') as f: + data = f.read() + if len(data) < 6: + return None, None + + head6 = data[:6] + + # V2 / V1 格式 (AES-ECB + XOR) + if head6 in (_V2_MAGIC_FULL, _V1_MAGIC_FULL): + if head6 == _V1_MAGIC_FULL: + aes_key = b'cfcd208495d565ef' + elif IMAGE_AES_KEY: + aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16] + else: + return None, None + if len(aes_key) < 16: + return None, None + try: + from Crypto.Cipher import AES + from Crypto.Util import Padding + if len(data) < 15: + return None, None + aes_size, xor_size = struct.unpack_from(' len(data): + return None, None + cipher = AES.new(aes_key[:16], AES.MODE_ECB) + dec_aes = Padding.unpad(cipher.decrypt(data[offset:offset+aligned]), AES.block_size) + offset += aligned + raw_end = len(data) - xor_size + raw_data = data[offset:raw_end] if offset < raw_end else b'' + xor_data = data[raw_end:] + xor_key = IMAGE_XOR_KEY if isinstance(IMAGE_XOR_KEY, int) else 0x88 + dec_xor = bytes(b ^ xor_key for b in xor_data) + result = dec_aes + raw_data + dec_xor + fmt = _detect_format(result[:16]) + return result, fmt + except Exception as e: + print(f" AES 解密失败: {e}") + return None, None + + # 旧 XOR 格式 + for fmt_name, magic in _IMAGE_MAGICS.items(): + key = data[0] ^ magic[0] + match = all(i < len(data) and (data[i] ^ key) == magic[i] for i in range(len(magic))) + if match: + result = bytes(b ^ key for b in data) + fmt = _detect_format(result[:16]) + return result, fmt + + return None, None + + +def main(): + if len(sys.argv) < 2: + print("用法: python batch_decrypt_images.py <文件夹路径> [输出目录]") + print(" 递归扫描文件夹下所有 .dat 文件并解密") + sys.exit(1) + + source_dir = os.path.abspath(sys.argv[1]) + if not os.path.isdir(source_dir): + print(f"目录不存在: {source_dir}") + sys.exit(1) + + if len(sys.argv) >= 3: + output_dir = os.path.abspath(sys.argv[2]) + else: + output_dir = source_dir.rstrip(os.sep) + "_decoded" + + # 递归收集所有 .dat 文件 + dat_files = [] + for root, _dirs, files in os.walk(source_dir): + for f in files: + if f.lower().endswith('.dat'): + dat_files.append(os.path.join(root, f)) + dat_files.sort() + + print(f"源目录: {source_dir}") + print(f"输出目录: {output_dir}") + print(f"找到 {len(dat_files)} 个 .dat 文件") + print() + + total = len(dat_files) + success = 0 + skipped = 0 + failed = 0 + + for dat_path in dat_files: + # 保持相对目录结构 + rel = os.path.relpath(dat_path, source_dir) + rel_dir = os.path.dirname(rel) + out_subdir = os.path.join(output_dir, rel_dir) if rel_dir else output_dir + + fname = os.path.splitext(os.path.basename(dat_path))[0] + # 去除 _t / _h 后缀获取基础名 + base_name = fname + for suffix in ('_t', '_h'): + if base_name.endswith(suffix): + base_name = base_name[:-len(suffix)] + break + + # 检查是否已解密 + existing = glob.glob(os.path.join(out_subdir, f"{base_name}.*")) + if existing: + skipped += 1 + continue + + img_bytes, fmt = decrypt_dat(dat_path) + if not img_bytes or fmt == 'bin': + failed += 1 + continue + + os.makedirs(out_subdir, exist_ok=True) + out_path = os.path.join(out_subdir, f"{base_name}.{fmt}") + with open(out_path, 'wb') as f: + f.write(img_bytes) + success += 1 + + print(f"完成: 共 {total} 个文件, 成功 {success}, 跳过(已存在) {skipped}, 失败 {failed}") + print(f"输出: {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/build.bat b/build.bat index 51f5ee6..d0b43eb 100644 --- a/build.bat +++ b/build.bat @@ -29,6 +29,8 @@ pyinstaller --noconfirm --onefile --console --name "WeChatDecrypt" ^ --add-data "decode_image.py;." ^ --add-data "find_image_key.py;." ^ --add-data "find_image_key_monitor.py;." ^ + --add-data "decrypt_sns.py;." ^ + --add-data "export_sns.py;." ^ --add-data "monitor.py;." ^ --add-data "monitor_web.py;." ^ --add-data "mcp_server.py;." ^ diff --git a/config.py b/config.py index a6c9960..7398c2f 100644 --- a/config.py +++ b/config.py @@ -229,8 +229,34 @@ def load_config(): else: cfg["wechat_base_dir"] = db_dir + # 输出目录:/wechat_files// + wxid = os.path.basename(os.path.normpath(cfg["wechat_base_dir"])) + cfg["output_base_dir"] = os.path.join(base, "wechat_files", wxid) + # decoded_image_dir 默认值 if "decoded_image_dir" not in cfg: cfg["decoded_image_dir"] = os.path.join(base, "decoded_images") + # 自动检测 WeChat Files 目录(FileStorage/MsgAttach, FileStorage/Sns/Cache) + if not cfg.get("wechat_files_dir"): + wechat_files_base = os.path.join(os.path.expanduser("~"), "Documents", "WeChat Files") + if os.path.isdir(wechat_files_base): + # xwechat_files 的 wxid 可能带后缀如 _1d4c,需要模糊匹配 + wxid_prefix = wxid.rsplit("_", 1)[0] if "_" in wxid else wxid + for d in os.listdir(wechat_files_base): + if d == wxid or d == wxid_prefix or wxid.startswith(d): + candidate = os.path.join(wechat_files_base, d) + if os.path.isdir(os.path.join(candidate, "FileStorage")): + cfg["wechat_files_dir"] = candidate + break + + wf_dir = cfg.get("wechat_files_dir", "") + cfg["msgattach_dir"] = os.path.join(wf_dir, "FileStorage", "MsgAttach") if wf_dir else "" + cfg["sns_cache_dir"] = os.path.join(wf_dir, "FileStorage", "Sns", "Cache") if wf_dir else "" + + # xwechat_files 图片/缓存路径 + wb = cfg["wechat_base_dir"] + cfg["xwechat_attach_dir"] = os.path.join(wb, "msg", "attach") if wb else "" + cfg["xwechat_cache_dir"] = os.path.join(wb, "cache") if wb else "" + return cfg diff --git a/decrypt_sns.py b/decrypt_sns.py new file mode 100644 index 0000000..174ec0f --- /dev/null +++ b/decrypt_sns.py @@ -0,0 +1,272 @@ +"""解密微信朋友圈图片缓存 +来源1: WeChat Files/FileStorage/Sns/Cache//[_t|_d] +来源2: xwechat_files/cache//Sns/Img// +输出目录: /朋友圈图片// +_t 后缀为缩略图(跳过) +""" +import os +import sys +import glob +import struct + +if sys.platform == "win32": + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from config import load_config + +_cfg = load_config() +SNS_CACHE_DIR = _cfg.get("sns_cache_dir", "") +XWECHAT_CACHE_DIR = _cfg.get("xwechat_cache_dir", "") +OUTPUT_DIR = os.path.join(_cfg["output_base_dir"], "朋友圈图片") +IMAGE_AES_KEY = _cfg.get("image_aes_key") +IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88) + +# ── V2/V1 magic ────────────────────────────────────────────────────────────── +_V2_MAGIC_FULL = b'\x07\x08V2\x08\x07' +_V1_MAGIC_FULL = b'\x07\x08V1\x08\x07' + +_IMAGE_MAGICS = { + 'jpg': [0xFF, 0xD8, 0xFF], + 'png': [0x89, 0x50, 0x4E, 0x47], + 'gif': [0x47, 0x49, 0x46, 0x38], + 'webp': [0x52, 0x49, 0x46, 0x46], + 'bmp': [0x42, 0x4D], + 'tif': [0x49, 0x49, 0x2A, 0x00], +} + + +def _detect_format(header): + if header[:3] == bytes([0xFF, 0xD8, 0xFF]): + return 'jpg' + if header[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): + return 'png' + if header[:3] == b'GIF': + return 'gif' + if header[:2] == b'BM': + return 'bmp' + if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WEBP': + return 'webp' + if header[:4] == bytes([0x49, 0x49, 0x2A, 0x00]): + return 'tif' + if header[:4] == b'wxgf': + return 'hevc' + return 'bin' + + +def decrypt_dat(dat_path): + """解密单个 .dat 文件,返回 (bytes, format) 或 (None, None)""" + with open(dat_path, 'rb') as f: + data = f.read() + if len(data) < 6: + return None, None + + head6 = data[:6] + + # V2 / V1 格式 + if head6 in (_V2_MAGIC_FULL, _V1_MAGIC_FULL): + if head6 == _V1_MAGIC_FULL: + aes_key = b'cfcd208495d565ef' + elif IMAGE_AES_KEY: + aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16] + else: + return None, None + if not aes_key or len(aes_key) < 16: + return None, None + try: + from Crypto.Cipher import AES + from Crypto.Util import Padding + if len(data) < 15: + return None, None + aes_size, xor_size = struct.unpack_from(' len(data): + return None, None + cipher = AES.new(aes_key[:16], AES.MODE_ECB) + dec_aes = Padding.unpad(cipher.decrypt(data[offset:offset+aligned]), AES.block_size) + offset += aligned + raw_end = len(data) - xor_size + raw_data = data[offset:raw_end] if offset < raw_end else b'' + xor_data = data[raw_end:] + xor_key = IMAGE_XOR_KEY if isinstance(IMAGE_XOR_KEY, int) else 0x88 + dec_xor = bytes(b ^ xor_key for b in xor_data) + result = dec_aes + raw_data + dec_xor + fmt = _detect_format(result[:16]) + return result, fmt + except Exception as e: + print(f" AES 解密失败: {e}") + return None, None + + # 旧 XOR 格式 + for fmt_name, magic in _IMAGE_MAGICS.items(): + key = data[0] ^ magic[0] + match = all(i < len(data) and (data[i] ^ key) == magic[i] for i in range(len(magic))) + if match: + result = bytes(b ^ key for b in data) + fmt = _detect_format(result[:16]) + return result, fmt + + return None, None + + +def _collect_xwechat_sns_files(): + """收集 xwechat cache//Sns/Img// 下的所有文件 + 返回 {month: [(file_path, basename), ...], ...} + """ + result = {} + if not XWECHAT_CACHE_DIR or not os.path.isdir(XWECHAT_CACHE_DIR): + return result + try: + months = sorted(os.listdir(XWECHAT_CACHE_DIR)) + except OSError: + return result + for month in months: + sns_img = os.path.join(XWECHAT_CACHE_DIR, month, "Sns", "Img") + if not os.path.isdir(sns_img): + continue + files = [] + try: + hex_dirs = os.listdir(sns_img) + except OSError: + continue + for hd in hex_dirs: + hd_path = os.path.join(sns_img, hd) + if not os.path.isdir(hd_path): + continue + try: + for fname in os.listdir(hd_path): + fp = os.path.join(hd_path, fname) + if os.path.isfile(fp): + files.append((fp, fname)) + except OSError: + continue + if files: + result[month] = files + return result + + +def main(): + has_wechat = SNS_CACHE_DIR and os.path.isdir(SNS_CACHE_DIR) + has_xwechat = XWECHAT_CACHE_DIR and os.path.isdir(XWECHAT_CACHE_DIR) + + if not has_wechat and not has_xwechat: + print(f"朋友圈缓存目录不存在:") + print(f" WeChat Files: {SNS_CACHE_DIR}") + print(f" xwechat: {XWECHAT_CACHE_DIR}") + print("请确认 config.json 中的路径配置正确") + return + + print(f"输出目录: {OUTPUT_DIR}") + + total = 0 + success = 0 + skipped_thumb = 0 + skipped_exist = 0 + failed = 0 + + # ── 来源1: WeChat Files/FileStorage/Sns/Cache// ── + if has_wechat: + print(f"\n[来源1] WeChat Files: {SNS_CACHE_DIR}") + months = sorted(d for d in os.listdir(SNS_CACHE_DIR) + if os.path.isdir(os.path.join(SNS_CACHE_DIR, d))) + has_month_dirs = any(len(m) == 7 and m[4] == '-' for m in months) + + if has_month_dirs: + print(f" 时间目录: {len(months)} 个") + for month in months: + month_src = os.path.join(SNS_CACHE_DIR, month) + month_out = os.path.join(OUTPUT_DIR, month) + stats = _process_dir_stats(month_src, month_out, month) + total += stats[0]; success += stats[1]; skipped_thumb += stats[2] + skipped_exist += stats[3]; failed += stats[4] + else: + stats = _process_dir_stats(SNS_CACHE_DIR, OUTPUT_DIR, "") + total, success, skipped_thumb, skipped_exist, failed = stats + + # ── 来源2: xwechat cache//Sns/Img// ── + if has_xwechat: + print(f"\n[来源2] xwechat: {XWECHAT_CACHE_DIR}") + xw_files = _collect_xwechat_sns_files() + if not xw_files: + print(" 未找到 Sns/Img 文件") + else: + print(f" 时间目录: {len(xw_files)} 个") + for month, file_list in sorted(xw_files.items()): + month_out = os.path.join(OUTPUT_DIR, month) + stats = _process_file_list(file_list, month_out, month) + total += stats[0]; success += stats[1]; skipped_thumb += stats[2] + skipped_exist += stats[3]; failed += stats[4] + + print(f"\n完成: 共 {total} 个文件") + print(f" 成功解密: {success}") + print(f" 跳过缩略图(_t): {skipped_thumb}") + print(f" 跳过已存在: {skipped_exist}") + print(f" 解密失败: {failed}") + print(f"输出: {os.path.abspath(OUTPUT_DIR)}") + + +def _process_dir_stats(src_dir, out_dir, label): + """处理一个目录中的所有文件,返回 (total, success, skipped_thumb, skipped_exist, failed)""" + try: + all_files = sorted(os.listdir(src_dir)) + except OSError: + return (0, 0, 0, 0, 0) + + dat_files = [(os.path.join(src_dir, f), f) for f in all_files + if os.path.isfile(os.path.join(src_dir, f))] + return _process_file_list(dat_files, out_dir, label) + + +def _process_file_list(file_list, out_dir, label): + """处理文件列表 [(file_path, basename), ...], 返回 (total, success, skipped_thumb, skipped_exist, failed)""" + total = 0 + success = 0 + skipped_thumb = 0 + skipped_exist = 0 + failed = 0 + + if not file_list: + return (0, 0, 0, 0, 0) + + if label: + print(f" [{label}] {len(file_list)} 个文件") + + month_ok = 0 + for file_path, fname in file_list: + total += 1 + # 跳过缩略图 + if fname.endswith('_t'): + skipped_thumb += 1 + continue + + # 去掉 _d 后缀得到基础名 + base_name = fname + if base_name.endswith('_d'): + base_name = base_name[:-2] + + # 检查是否已存在 + existing = glob.glob(os.path.join(out_dir, f"{base_name}.*")) + if existing: + skipped_exist += 1 + continue + + img_bytes, fmt = decrypt_dat(file_path) + if not img_bytes or fmt in ('bin', 'hevc'): + failed += 1 + continue + + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, f"{base_name}.{fmt}") + with open(out_path, 'wb') as f: + f.write(img_bytes) + success += 1 + month_ok += 1 + + if month_ok > 0 and label: + print(f" 解密成功: {month_ok} 张") + + return (total, success, skipped_thumb, skipped_exist, failed) + + +if __name__ == "__main__": + main() diff --git a/export_messages.py b/export_messages.py index bf92e07..bf2201d 100644 --- a/export_messages.py +++ b/export_messages.py @@ -1,6 +1,8 @@ """导出微信消息记录到 CSV / HTML / JSON -目录结构: /export//messages.csv|html|json +目录结构: //messages.csv|html|json +图片导出: //image/. """ +import base64 import sqlite3 import glob import hashlib @@ -8,6 +10,7 @@ import os import json import csv import re +import struct import sys import xml.etree.ElementTree as ET from datetime import datetime @@ -23,7 +26,310 @@ from config import load_config _cfg = load_config() MSG_DB_DIR = os.path.join(_cfg["decrypted_dir"], "message") CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db") -OUTPUT_DIR = os.path.join(_cfg["wechat_base_dir"], "export") +OUTPUT_DIR = _cfg["output_base_dir"] + +# 图片相关配置 +WECHAT_BASE_DIR = _cfg.get("wechat_base_dir", "") +ATTACH_DIR = os.path.join(WECHAT_BASE_DIR, "msg", "attach") if WECHAT_BASE_DIR else "" +MSGATTACH_DIR = _cfg.get("msgattach_dir", "") # WeChat Files/FileStorage/MsgAttach +IMAGE_AES_KEY = _cfg.get("image_aes_key") +IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88) +MSG_RESOURCE_DB = os.path.join(_cfg["decrypted_dir"], "message", "message_resource.db") + +_CONTACT_FILTER = None +_filter_raw = os.environ.get("WECHAT_EXPORT_CONTACTS", "").strip() +if _filter_raw: + _CONTACT_FILTER = set(_filter_raw.split(",")) + print(f"联系人筛选: {len(_CONTACT_FILTER)} 个") + +_EXPORT_FORMATS = None +_formats_raw = os.environ.get("WECHAT_EXPORT_FORMATS", "").strip() +if _formats_raw: + _EXPORT_FORMATS = set(_formats_raw.lower().split(",")) + print(f"导出格式: {', '.join(sorted(_EXPORT_FORMATS))}") + +_EXPORT_IMAGES = os.environ.get("WECHAT_EXPORT_IMAGES", "1").strip() == "1" + + +# ─── 图片解密辅助 ─────────────────────────────────────────────────────────────── + +def _extract_md5_from_packed_info(blob): + """从 message_resource.db 的 packed_info 中提取文件 MD5""" + if not blob or not isinstance(blob, bytes): + return None + marker = b'\x12\x22\x0a\x20' + idx = blob.find(marker) + if idx >= 0 and idx + len(marker) + 32 <= len(blob): + md5_bytes = blob[idx + len(marker): idx + len(marker) + 32] + try: + md5_str = md5_bytes.decode('ascii') + int(md5_str, 16) + return md5_str + except (UnicodeDecodeError, ValueError): + pass + hex_chars = set(b'0123456789abcdef') + i = 0 + while i <= len(blob) - 32: + if blob[i] in hex_chars: + candidate = blob[i:i+32] + if all(b in hex_chars for b in candidate): + try: + return candidate.decode('ascii') + except UnicodeDecodeError: + pass + i += 32 + else: + i += 1 + return None + + +def _load_resource_md5_map(): + """加载 message_resource.db 的 (chat_username, local_id) -> file_md5 映射""" + md5_map = {} + if not os.path.exists(MSG_RESOURCE_DB): + return md5_map + try: + conn = sqlite3.connect(MSG_RESOURCE_DB) + # chat_id -> username + chat_id_map = {} + for row in conn.execute("SELECT rowid, user_name FROM ChatName2Id"): + chat_id_map[row[0]] = row[1] + for row in conn.execute( + "SELECT chat_id, message_local_id, packed_info FROM MessageResourceInfo" + ): + cid, lid, blob = row + md5 = _extract_md5_from_packed_info(blob) + if md5: + uname = chat_id_map.get(cid, "") + if uname: + md5_map[(uname, lid)] = md5 + conn.close() + print(f"图片资源映射: {len(md5_map)} 条") + except Exception as e: + print(f"读取 message_resource.db 失败: {e}") + return md5_map + + +def _find_dat_file(username_hash, file_md5): + """在 attach / MsgAttach 目录下查找 .dat 文件,优先高清版""" + search_patterns = [] + # xwechat_files 的 msg/attach 目录: //Img/*.dat + if ATTACH_DIR and os.path.isdir(ATTACH_DIR): + search_base = os.path.join(ATTACH_DIR, username_hash) + if os.path.isdir(search_base): + search_patterns.append(os.path.join(search_base, "*", "Img", f"{file_md5}*.dat")) + # WeChat Files 的 MsgAttach 目录: /Image//*.dat + if MSGATTACH_DIR and os.path.isdir(MSGATTACH_DIR): + search_base = os.path.join(MSGATTACH_DIR, username_hash) + if os.path.isdir(search_base): + search_patterns.append(os.path.join(search_base, "Image", "*", f"{file_md5}*.dat")) + + files = [] + for pat in search_patterns: + files.extend(glob.glob(pat)) + if not files: + return None + # 优先: 无后缀(原图) > _W(原图) > _h(高清) > _t/_t_W(缩略图) + # 先过滤掉缩略图 + non_thumb = [f for f in files if '_t.' not in os.path.basename(f) and '_t_' not in os.path.basename(f)] + candidates = non_thumb if non_thumb else files + selected = candidates[0] + for f in candidates: + fname = os.path.basename(f) + # 精确匹配原图(无后缀) + if fname == f"{file_md5}.dat": + return f + for f in candidates: + fname = os.path.basename(f) + if fname == f"{file_md5}_W.dat": + return f + for f in candidates: + if '_h.' in os.path.basename(f) or '_h_' in os.path.basename(f): + return f + return selected + + +def _detect_image_format(header): + """根据解密后的文件头检测图片格式""" + if header[:3] == bytes([0xFF, 0xD8, 0xFF]): + return 'jpg' + if header[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): + return 'png' + if header[:3] == b'GIF': + return 'gif' + if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WEBP': + return 'webp' + if header[:4] == b'wxgf': + return 'hevc' + return 'bin' + + +# V2 格式常量 +_V2_MAGIC_FULL = b'\x07\x08V2\x08\x07' +_V1_MAGIC_FULL = b'\x07\x08V1\x08\x07' +_IMAGE_MAGICS = { + 'jpg': [0xFF, 0xD8, 0xFF], + 'png': [0x89, 0x50, 0x4E, 0x47], + 'gif': [0x47, 0x49, 0x46, 0x38], + 'webp': [0x52, 0x49, 0x46, 0x46], +} + + +def _decrypt_dat_to_bytes(dat_path): + """解密 .dat 文件,返回 (bytes, format) 或 (None, None)""" + with open(dat_path, 'rb') as f: + data = f.read() + if len(data) < 15: + return None, None + head6 = data[:6] + + # V2 / V1 格式 + if head6 in (_V2_MAGIC_FULL, _V1_MAGIC_FULL): + aes_key = None + if head6 == _V1_MAGIC_FULL: + aes_key = b'cfcd208495d565ef' + elif IMAGE_AES_KEY: + aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16] + if not aes_key or len(aes_key) < 16: + return None, None + try: + from Crypto.Cipher import AES as _AES + from Crypto.Util import Padding + aes_size, xor_size = struct.unpack_from(' len(data): + return None, None + cipher = _AES.new(aes_key[:16], _AES.MODE_ECB) + dec_aes = Padding.unpad(cipher.decrypt(data[offset:offset+aligned]), _AES.block_size) + offset += aligned + raw_end = len(data) - xor_size + raw_data = data[offset:raw_end] if offset < raw_end else b'' + xor_data = data[raw_end:] + xor_key = IMAGE_XOR_KEY if isinstance(IMAGE_XOR_KEY, int) else 0x88 + dec_xor = bytes(b ^ xor_key for b in xor_data) + result = dec_aes + raw_data + dec_xor + fmt = _detect_image_format(result[:16]) + return result, fmt + except Exception: + return None, None + + # 旧 XOR 格式 + for fmt_name, magic in _IMAGE_MAGICS.items(): + key = data[0] ^ magic[0] + match = all(i < len(data) and (data[i] ^ key) == magic[i] for i in range(len(magic))) + if match: + result = bytes(b ^ key for b in data) + fmt = _detect_image_format(result[:16]) + return result, fmt + + return None, None + + +_resource_md5_map = _load_resource_md5_map() if _EXPORT_IMAGES else {} + + +def decode_chat_images(chat_username, _messages_unused, out_dir): + """直接扫描 attach 目录下该联系人的全部图片并解密 + 按月份分目录输出到 out_dir/image// + 跳过 _t 缩略图,优先 _h 高清版 + 返回 {file_md5: relative_path} 用于 HTML 嵌入 + """ + image_map = {} + username_hash = hashlib.md5(chat_username.encode()).hexdigest() + + # 收集所有来源目录: [(base_path, sub_structure), ...] + # xwechat: attach///Img/*.dat + # WeChat Files: MsgAttach//Image//*.dat + source_dirs = [] + if ATTACH_DIR: + p = os.path.join(ATTACH_DIR, username_hash) + if os.path.isdir(p): + source_dirs.append(("xwechat", p)) + if MSGATTACH_DIR: + p = os.path.join(MSGATTACH_DIR, username_hash) + if os.path.isdir(p): + source_dirs.append(("wechat", p)) + + if not source_dirs: + return image_map + + # 收集所有 dat 文件: {base_md5: (best_path, month)} + # 优先级: _h > 无后缀 > _W > 其他(跳过 _t) + file_candidates = {} # base_md5 -> (priority, dat_path, month) + + def _priority(fname): + """返回优先级数字,越小越好""" + base = fname.rsplit('.', 1)[0] + if base.endswith('_h'): + return 0 # 高清 + if '_' not in base[-3:]: + return 1 # 无后缀原图 + if base.endswith('_W'): + return 2 + return 9 # 其他 + + for src_type, base_path in source_dirs: + # xwechat: //Img/ — 直接列 base_path 得到月份 + # wechat: /Image// — 需要列 base_path/Image 得到月份 + if src_type == "xwechat": + scan_base = base_path + else: + scan_base = os.path.join(base_path, "Image") + try: + months = sorted(os.listdir(scan_base)) + except OSError: + continue + for month in months: + if src_type == "xwechat": + img_dir = os.path.join(base_path, month, "Img") + else: + img_dir = os.path.join(scan_base, month) + if not os.path.isdir(img_dir): + continue + try: + files = os.listdir(img_dir) + except OSError: + continue + for fname in files: + if not fname.endswith('.dat'): + continue + # 跳过缩略图 _t.dat 和 _t_W.dat + base_no_ext = fname.rsplit('.', 1)[0] + if '_t' in base_no_ext.split('_'): + continue + if base_no_ext.endswith('_t') or '_t_' in base_no_ext: + continue + # 提取 base md5 + base_md5 = base_no_ext.split('_')[0] + pri = _priority(fname) + existing = file_candidates.get(base_md5) + if not existing or pri < existing[0]: + file_candidates[base_md5] = (pri, os.path.join(img_dir, fname), month) + + if not file_candidates: + return image_map + + decoded_count = 0 + for base_md5, (pri, dat_path, month) in file_candidates.items(): + month_dir = os.path.join(out_dir, "image", month) + # 检查是否已解密 + existing = glob.glob(os.path.join(month_dir, f"{base_md5}.*")) + if existing: + rel = os.path.relpath(existing[0], out_dir).replace("\\", "/") + image_map[base_md5] = rel + continue + img_bytes, fmt = _decrypt_dat_to_bytes(dat_path) + if not img_bytes or fmt == 'bin': + continue + os.makedirs(month_dir, exist_ok=True) + out_path = os.path.join(month_dir, f"{base_md5}.{fmt}") + with open(out_path, 'wb') as f: + f.write(img_bytes) + image_map[base_md5] = f"image/{month}/{base_md5}.{fmt}" + decoded_count += 1 + + return image_map MSG_TYPES = { 1: "文本", @@ -127,6 +433,7 @@ body{{background:#ededed;font-family:"PingFang SC","Helvetica Neue",Arial,sans-s .bubble{{display:inline-block;padding:8px 12px;border-radius:6px;word-break:break-word;line-height:1.5;box-shadow:0 1px 2px rgba(0,0,0,.1);white-space:pre-wrap}} .received .bubble{{background:#fff;border-radius:0 6px 6px 6px}} .sent .bubble{{background:#95EC69;border-radius:6px 0 6px 6px}} +.bubble img{{max-width:100%;border-radius:4px;display:block;margin:2px 0}} .type-tag{{font-size:11px;color:#aaa;margin-top:2px}} @@ -142,7 +449,7 @@ body{{background:#ededed;font-family:"PingFang SC","Helvetica Neue",Arial,sans-s def _html_escape(s: str) -> str: return s.replace("&","&").replace("<","<").replace(">",">").replace('"','"') -def _write_html(path: str, title: str, is_group: bool, messages: list): +def _write_html(path: str, title: str, is_group: bool, messages: list, image_map: dict = None, out_dir: str = None): parts = [] last_date = None for m in messages: @@ -169,12 +476,33 @@ def _write_html(path: str, title: str, is_group: bool, messages: list): if m["type"] != 1: type_tag = f'
{m["type_name"]}
' + # 图片消息嵌入 + bubble_content = _html_escape(m["display_content"]) + if m["type"] == 3 and image_map and m["local_id"] in image_map: + rel_path = image_map[m["local_id"]] + if out_dir: + abs_img = os.path.join(out_dir, rel_path) + if os.path.exists(abs_img): + ext = os.path.splitext(abs_img)[1].lstrip('.').lower() + mime = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', + 'gif': 'image/gif', 'webp': 'image/webp'}.get(ext, 'image/jpeg') + try: + with open(abs_img, 'rb') as imgf: + b64 = base64.b64encode(imgf.read()).decode('ascii') + bubble_content = f'\"图片\"' + except Exception: + bubble_content = f'\"图片\"' + else: + bubble_content = f'\"图片\"' + else: + bubble_content = f'\"图片\"' + parts.append( f'
' f'
{initial}
' f'
' f'{sender_label}' - f'
{_html_escape(m["display_content"])}
' + f'
{bubble_content}
' f'{type_tag}' f'
{m["time_str"]}
' f'
' @@ -217,6 +545,10 @@ print(f"找到 {len(db_files)} 个消息数据库") total_chats = 0 total_msgs = 0 +# ── 阶段1: 收集所有联系人的消息 ────────────────────────────────────────────── +# chat_data[chat_username] -> { dname, is_group, db_messages: [(db_name, messages)] } +chat_data: dict[str, dict] = {} + for db_path in sorted(db_files): db_name = os.path.basename(db_path) conn = sqlite3.connect(db_path) @@ -244,6 +576,8 @@ for db_path in sorted(db_files): for table_name in all_tables: h = table_name[4:] # strip "Msg_" chat_username = hash_to_username.get(h, f"unknown_{h[:8]}") + if _CONTACT_FILTER and chat_username not in _CONTACT_FILTER: + continue dname = safe_dirname(display_name(chat_username)) is_group = chat_username.endswith("@chatroom") or chat_username.endswith("@openim") @@ -287,58 +621,93 @@ for db_path in sorted(db_files): "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) + if chat_username not in chat_data: + chat_data[chat_username] = { + "dname": dname, "is_group": is_group, "db_messages": [] + } + chat_data[chat_username]["db_messages"].append((db_name, messages)) - # ── .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") + conn.close() - # ── 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"]) +# ── 阶段2: 每个联系人解密图片一次,再写出文件 ──────────────────────────────── +total_chats = 0 +total_msgs = 0 + +for chat_username, cdata in chat_data.items(): + dname = cdata["dname"] + is_group = cdata["is_group"] + out_dir = os.path.join(OUTPUT_DIR, dname) + os.makedirs(out_dir, exist_ok=True) + + # ── .info 文件 ──────────────────────────────────────────────────────── + info_path = os.path.join(out_dir, ".info") + if not os.path.exists(info_path): + info = contact_map.get(chat_username, { + "username": chat_username, "alias": "", "remark": "", "nick_name": "" + }) + with open(info_path, "w", encoding="utf-8") as f: + f.write(f"username: {info['username']}\n") + f.write(f"alias: {info['alias']}\n") + f.write(f"nick_name: {info['nick_name']}\n") + f.write(f"remark: {info['remark']}\n") + f.write(f"is_group: {is_group}\n") + + # ── 解密图片(每个联系人只执行一次)──────────────────────────────────── + image_md5_map = {} + if _EXPORT_IMAGES: + image_md5_map = decode_chat_images(chat_username, None, out_dir) + if image_md5_map: + print(f" 图片解密: {len(image_md5_map)} 张 ({dname})") + + # ── 按 DB 写出消息文件 ──────────────────────────────────────────────── + for db_name, messages in cdata["db_messages"]: + # 建立 local_id -> rel_path 映射 + image_map = {} + if image_md5_map: for m in messages: - w.writerow([ - m["time_str"], m["sender"], m["type_name"], - m["display_content"], m["server_id"] - ]) + if m["type"] != 3: + continue + lid = m["local_id"] + file_md5 = _resource_md5_map.get((chat_username, lid)) + if file_md5 and file_md5 in image_md5_map: + image_map[lid] = image_md5_map[file_md5] - # ── 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) + # ── CSV ─────────────────────────────────────────────────────────── + if not _EXPORT_FORMATS or "csv" in _EXPORT_FORMATS: + csv_path = os.path.join(out_dir, f"{db_name}.csv") + with open(csv_path, "w", newline="", encoding="utf-8-sig") as f: + w = csv.writer(f) + w.writerow(["时间", "发送者", "消息类型", "内容", "图片路径", "server_id"]) + for m in messages: + img_path = image_map.get(m["local_id"], "") if m["type"] == 3 else "" + w.writerow([ + m["time_str"], m["sender"], m["type_name"], + m["display_content"], img_path, m["server_id"] + ]) - # ── HTML ────────────────────────────────────────────────────────────── - html_path = os.path.join(out_dir, f"{db_name}.html") - _write_html(html_path, dname, is_group, messages) + # ── JSON ────────────────────────────────────────────────────────── + if not _EXPORT_FORMATS or "json" in _EXPORT_FORMATS: + json_path = os.path.join(out_dir, f"{db_name}.json") + with open(json_path, "w", encoding="utf-8") as f: + json.dump({ + "chat_username": chat_username, + "display_name": dname, + "is_group": is_group, + "message_count": len(messages), + "messages": messages, + }, f, ensure_ascii=False, indent=2) + + # ── HTML ────────────────────────────────────────────────────────── + if not _EXPORT_FORMATS or "html" in _EXPORT_FORMATS: + html_path = os.path.join(out_dir, f"{db_name}.html") + _write_html(html_path, dname, is_group, messages, image_map=image_map, out_dir=out_dir) total_chats += 1 total_msgs += len(messages) print(f" [{db_name}] {dname}: {len(messages)} 条消息") - conn.close() - print(f"\n完成: {total_chats} 个会话, 共 {total_msgs} 条消息") print(f"输出目录: {os.path.abspath(OUTPUT_DIR)}") diff --git a/export_sns.py b/export_sns.py new file mode 100644 index 0000000..c24a178 --- /dev/null +++ b/export_sns.py @@ -0,0 +1,820 @@ +"""导出微信朋友圈动态(SnsTimeLine 表) + +输出目录: //SNS/.json +媒体文件: //SNS/_. +汇总文件: //SNS/timeline.json +时间线: //SNS/timeline.html +""" +import bisect +import os +import sys +import json +import sqlite3 +import struct +import re +import xml.etree.ElementTree as ET +from datetime import datetime +from urllib.request import urlopen, Request +from urllib.error import URLError + +if sys.platform == "win32": + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from config import load_config + +_cfg = load_config() +DECRYPTED_DIR = _cfg["decrypted_dir"] +SNS_DB_PATH = os.path.join(DECRYPTED_DIR, "sns", "sns.db") +CONTACT_DB_PATH = os.path.join(DECRYPTED_DIR, "contact", "contact.db") +OUTPUT_DIR = _cfg["output_base_dir"] + +# 图片缓存 / 解密相关配置 +IMAGE_AES_KEY = _cfg.get("image_aes_key") +IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88) +XWECHAT_CACHE_DIR = _cfg.get("xwechat_cache_dir", "") +SNS_CACHE_DIR = _cfg.get("sns_cache_dir", "") + +# 联系人筛选(与 export_messages.py 一致) +_CONTACT_FILTER = None +_filter_raw = os.environ.get("WECHAT_EXPORT_CONTACTS", "").strip() +if _filter_raw: + _CONTACT_FILTER = set(_filter_raw.split(",")) + print(f"朋友圈联系人筛选: {len(_CONTACT_FILTER)} 个") + +# ── 媒体下载 ───────────────────────────────────────────────────────────────── + +_DOWNLOAD_TIMEOUT = 10 # 秒 + +# ── 本地缓存图片解密 & 匹配 ────────────────────────────────────────────────── + +_V2_MAGIC = b'\x07\x08V2\x08\x07' +_V1_MAGIC = b'\x07\x08V1\x08\x07' +_IMAGE_MAGICS = { + 'jpg': [0xFF, 0xD8, 0xFF], + 'png': [0x89, 0x50, 0x4E, 0x47], + 'gif': [0x47, 0x49, 0x46, 0x38], + 'webp': [0x52, 0x49, 0x46, 0x46], +} +_TIME_WINDOW = 72 * 3600 # 72 小时 + + +def _decrypt_sns_dat(dat_path): + """解密 SNS 缓存 .dat 文件,返回 bytes 或 None""" + try: + with open(dat_path, 'rb') as f: + data = f.read() + except OSError: + return None + if len(data) < 15: + return None + + head6 = data[:6] + + # V2 / V1 格式(xwechat cache) + if head6 in (_V2_MAGIC, _V1_MAGIC): + aes_key = None + if head6 == _V1_MAGIC: + aes_key = b'cfcd208495d565ef' + elif IMAGE_AES_KEY: + aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16] + if not aes_key or len(aes_key) < 16: + return None + try: + from Crypto.Cipher import AES + from Crypto.Util import Padding + aes_size, xor_size = struct.unpack_from(' len(data): + return None + cipher = AES.new(aes_key[:16], AES.MODE_ECB) + dec_aes = Padding.unpad(cipher.decrypt(data[offset:offset + aligned]), AES.block_size) + offset += aligned + raw_end = len(data) - xor_size + raw_data = data[offset:raw_end] if offset < raw_end else b'' + xor_data = data[raw_end:] + xor_key = IMAGE_XOR_KEY if isinstance(IMAGE_XOR_KEY, int) else 0x88 + dec_xor = bytes(b ^ xor_key for b in xor_data) + return dec_aes + raw_data + dec_xor + except Exception: + return None + + # 旧 XOR 格式(FileStorage Sns Cache) + for magic in _IMAGE_MAGICS.values(): + key = data[0] ^ magic[0] + if all(i < len(data) and (data[i] ^ key) == magic[i] for i in range(len(magic))): + return bytes(b ^ key for b in data) + + return None + + +def _detect_format(header): + """检测解密后数据的图片格式,返回扩展名""" + if header[:3] == b'\xff\xd8\xff': + return 'jpg' + if header[:4] == b'\x89PNG': + return 'png' + if header[:3] == b'GIF': + return 'gif' + if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WEBP': + return 'webp' + return 'bin' + + +def _image_size_from_bytes(data): + """从解密后的图片数据提取 (width, height),失败返回 (0, 0)""" + if not data or len(data) < 24: + return 0, 0 + + # PNG: IHDR 位于字节 16-24 + if data[:4] == b'\x89PNG': + w = struct.unpack('>I', data[16:20])[0] + h = struct.unpack('>I', data[20:24])[0] + return w, h + + # JPEG: 查找 SOF 标记 + if data[:2] == b'\xff\xd8': + i = 2 + while i < len(data) - 9: + if data[i] != 0xFF: + break + marker = data[i + 1] + if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC): + h = struct.unpack('>H', data[i + 5:i + 7])[0] + w = struct.unpack('>H', data[i + 7:i + 9])[0] + return w, h + if i + 3 >= len(data): + break + seg_len = struct.unpack('>H', data[i + 2:i + 4])[0] + i += 2 + seg_len + return 0, 0 + + # WEBP VP8 + if data[:4] == b'RIFF' and len(data) >= 30 and data[8:12] == b'WEBP': + if data[12:16] == b'VP8 ': + w = struct.unpack('/YYYY-MM/Sns/Img/<2hex>/<30hex> + if XWECHAT_CACHE_DIR and os.path.isdir(XWECHAT_CACHE_DIR): + for month_dir in os.listdir(XWECHAT_CACHE_DIR): + sns_img = os.path.join(XWECHAT_CACHE_DIR, month_dir, "Sns", "Img") + if not os.path.isdir(sns_img): + continue + for sub in os.listdir(sns_img): + sub_path = os.path.join(sns_img, sub) + if not os.path.isdir(sub_path): + continue + for fname in os.listdir(sub_path): + fp = os.path.join(sub_path, fname) + if os.path.isfile(fp): + raw_paths.append(fp) + + # 2. FileStorage Sns Cache: /YYYY-MM/ + if SNS_CACHE_DIR and os.path.isdir(SNS_CACHE_DIR): + for month_dir in os.listdir(SNS_CACHE_DIR): + month_path = os.path.join(SNS_CACHE_DIR, month_dir) + if not os.path.isdir(month_path): + continue + for fname in os.listdir(month_path): + if fname.endswith('_t'): # 跳过缩略图 + continue + fp = os.path.join(month_path, fname) + if os.path.isfile(fp): + raw_paths.append(fp) + + if not raw_paths: + return [] + + print(f" 预读取 {len(raw_paths)} 个缓存文件元数据...") + + # 准备 AES key(避免在循环内重复构造) + aes_key = None + if IMAGE_AES_KEY: + aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16] + + entries = [] + for path in raw_paths: + try: + fsize = os.path.getsize(path) + mtime = os.path.getmtime(path) + if fsize < 15: + continue + + with open(path, 'rb') as f: + data = f.read(min(fsize, 4096)) + + head6 = data[:6] + dec_header = None + est_dec_size = fsize + + if head6 in (_V2_MAGIC, _V1_MAGIC): + # V2/V1: 解密 AES 部分获取文件头 + k = b'cfcd208495d565ef' if head6 == _V1_MAGIC else aes_key + if not k or len(k) < 16: + continue + try: + from Crypto.Cipher import AES as _AES + aes_size, xor_size = struct.unpack_from('= hi: + lo, hi = 0, len(index) + + used_paths = set() + + for media in media_list: + mtype = media.get("type", "") + if mtype not in ("2", ""): + results.append((None, None)) + continue + + want_w = int(media.get("width") or 0) + want_h = int(media.get("height") or 0) + want_size = int(media.get("total_size") or 0) + + candidates = [] # (score, path, fmt) + + for i in range(lo, hi): + mtime_i, path_i, dec_size_i, fmt_i, w_i, h_i = index[i] + if path_i in used_paths: + continue + + # 尺寸匹配 + if want_w > 0 and want_h > 0 and w_i > 0 and h_i > 0: + if w_i != want_w or h_i != want_h: + continue + + # 大小匹配 + if want_size > 0: + if dec_size_i > want_size * 3 or dec_size_i < want_size * 0.3: + continue + + size_diff = abs(dec_size_i - want_size) if want_size > 0 else 0 + time_diff = abs(mtime_i - create_time) + candidates.append((size_diff, time_diff, path_i, fmt_i)) + + if candidates: + candidates.sort(key=lambda x: (x[0], x[1])) + best = candidates[0] + used_paths.add(best[2]) + results.append((best[2], best[3])) + else: + results.append((None, None)) + + return results + +# ContentObject type 含义(已知) +_CONTENT_TYPES = { + 1: "图文", + 2: "纯文本", + 3: "链接", + 5: "视频链接", + 7: "位置", + 15: "视频", + 28: "短视频", + 30: "音乐", + 34: "笔记", + 42: "小程序", + 54: "直播", +} + + +def _try_download_media(url, save_path): + """尝试下载媒体文件,返回 True/False + + 微信朋友圈 shmmsns.qpic.cn 图片需要携带 Referer 和 User-Agent。 + 注意: URL 返回的数据可能是加密的(enc_idx=1 的情况), + 解密算法尚未公开,此时下载的文件无法直接查看。 + 如果下载失败返回 False,后续可替换为更复杂的下载逻辑。 + """ + if not url or not url.startswith("http"): + return False + try: + req = Request(url, headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://weixin.qq.com/", + }) + with urlopen(req, timeout=_DOWNLOAD_TIMEOUT) as resp: + if resp.status != 200: + return False + data = resp.read() + if len(data) < 100: + return False + # 检测格式 + if data[:3] == b'\xff\xd8\xff': + ext = '.jpg' + elif data[:4] == b'\x89PNG': + ext = '.png' + elif data[:4] == b'GIF8': + ext = '.gif' + elif data[:4] == b'RIFF' and data[8:12] == b'WEBP': + ext = '.webp' + else: + ext = '.bin' + if not os.path.splitext(save_path)[1]: + save_path += ext + with open(save_path, 'wb') as f: + f.write(data) + return True + except (URLError, OSError, Exception): + return False + + +def _parse_media_list(timeline_obj): + """解析 TimelineObject 中的 mediaList,返回 media 信息列表""" + medias = [] + for media_el in timeline_obj.findall('.//media'): + media_type = media_el.findtext('type', '') + sub_type = media_el.findtext('sub_type', '') + vid_duration = media_el.findtext('videoDuration', '0') + + thumb_el = media_el.find('thumb') + url_el = media_el.find('url') + size_el = media_el.find('size') + + info = { + "type": media_type, + "sub_type": sub_type, + "video_duration": vid_duration, + } + + if thumb_el is not None: + info["thumb_url"] = thumb_el.text or "" + info["thumb_key"] = thumb_el.get("key", "") + info["thumb_token"] = thumb_el.get("token", "") + + if url_el is not None: + info["url"] = url_el.text or "" + info["url_md5"] = url_el.get("md5", "") + info["url_key"] = url_el.get("key", "") + info["url_token"] = url_el.get("token", "") + + if size_el is not None: + info["width"] = size_el.get("width", "") + info["height"] = size_el.get("height", "") + info["total_size"] = size_el.get("totalSize", "") + + medias.append(info) + return medias + + +def _parse_timeline_xml(content_xml): + """解析 SnsTimeLine 的 Content XML,返回结构化数据""" + try: + root = ET.fromstring(content_xml) + except ET.ParseError: + return None + + tl = root.find('.//TimelineObject') + if tl is None: + return None + + create_time_str = tl.findtext('createTime', '0') + try: + create_time = int(create_time_str) + except ValueError: + create_time = 0 + + content_type = tl.findtext('.//ContentObject/type', '0') + try: + content_type_int = int(content_type) + except ValueError: + content_type_int = 0 + + # 解析位置 + loc_el = tl.find('.//location') + location = None + if loc_el is not None: + lat = loc_el.get('latitude', '0') + lon = loc_el.get('longitude', '0') + if lat != '0' or lon != '0': + location = { + "latitude": lat, + "longitude": lon, + "poi_name": loc_el.get("poiName", ""), + } + + return { + "id": tl.findtext('id', ''), + "username": tl.findtext('username', ''), + "create_time": create_time, + "create_time_str": datetime.fromtimestamp(create_time).strftime("%Y-%m-%d %H:%M:%S") if create_time else "", + "content_desc": tl.findtext('contentDesc', ''), + "content_type": content_type_int, + "content_type_name": _CONTENT_TYPES.get(content_type_int, f"未知({content_type_int})"), + "nickname": root.findtext('.//LocalExtraInfo/nickname', ''), + "is_private": tl.findtext('private', '0') == '1', + "location": location, + "media": _parse_media_list(tl), + } + + +def _load_comments(conn): + """加载 SnsMessage_tmp3 评论/点赞,按 feed_id 分组""" + comments = {} + try: + rows = conn.execute( + "SELECT feed_id, create_time, type, from_username, from_nickname," + " to_username, to_nickname, content" + " FROM SnsMessage_tmp3 ORDER BY create_time" + ).fetchall() + for feed_id, ctime, ctype, from_u, from_n, to_u, to_n, content in rows: + if feed_id not in comments: + comments[feed_id] = [] + comments[feed_id].append({ + "create_time": ctime, + "create_time_str": datetime.fromtimestamp(ctime).strftime("%Y-%m-%d %H:%M:%S") if ctime else "", + "type": ctype, # 1=点赞, 2=评论 + "type_name": "点赞" if ctype == 1 else "评论" if ctype == 2 else f"未知({ctype})", + "from_username": from_u or "", + "from_nickname": from_n or "", + "to_username": to_u or "", + "to_nickname": to_n or "", + "content": content or "", + }) + except Exception as e: + print(f"读取评论数据失败: {e}") + return comments + + +def _safe_dirname(name: str) -> str: + """清理文件夹名中的非法字符""" + for ch in r'\/:*?"<>|': + name = name.replace(ch, "_") + return name.strip() or "unknown" + + +def _load_contact_map(): + """从 contact.db 加载 {username: display_name}""" + cmap = {} + if not os.path.exists(CONTACT_DB_PATH): + return cmap + try: + conn = sqlite3.connect(CONTACT_DB_PATH) + for uname, remark, nick_name in conn.execute( + "SELECT username, remark, nick_name FROM contact" + ): + dname = remark or nick_name or uname + cmap[uname] = _safe_dirname(dname) + conn.close() + except Exception as e: + print(f"读取联系人数据库失败: {e}") + return cmap + + +def _timestamp_filename(unix_ts): + """Unix 时间戳 → yyyyMMddHHmmss000 文件名(毫秒部分为 000)""" + if not unix_ts: + return "00000000000000000" + dt = datetime.fromtimestamp(unix_ts) + return dt.strftime("%Y%m%d%H%M%S") + "000" + + +def _html_escape(text): + """简单 HTML 转义""" + return (text or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + + +def _generate_timeline_html(display_name, posts, sns_dir, image_files): + """生成朋友圈时间线 HTML + + Args: + display_name: 联系人显示名 + posts: 按时间倒序排列的动态列表 + sns_dir: SNS 输出目录 + image_files: {final_name: [(rel_path, ext), ...]} 每条动态的图片文件列表 + """ + html_path = os.path.join(sns_dir, "timeline.html") + parts = [f""" + + + + +{_html_escape(display_name)} - 朋友圈 + + + +

{_html_escape(display_name)} 的朋友圈

+
共 {len(posts)} 条动态
+"""] + + for post in posts: + final_name = post.get("_final_name", "") + time_str = _html_escape(post.get("create_time_str", "")) + type_name = _html_escape(post.get("content_type_name", "")) + text = _html_escape(post.get("content_desc", "")) + is_private = post.get("is_private", False) + + parts.append('
') + parts.append(f'
{time_str}{type_name}') + if is_private: + parts.append('仅自己可见') + parts.append('
') + + if text: + parts.append(f'
{text}
') + + # 图片 + imgs = image_files.get(final_name, []) + if imgs: + parts.append('
') + for rel_path, _ in imgs: + parts.append(f'') + parts.append('
') + + # 位置 + loc = post.get("location") + if loc and loc.get("poi_name"): + parts.append(f'
📍 {_html_escape(loc["poi_name"])}
') + + # 评论 + comments = post.get("comments", []) + if comments: + parts.append('
') + for c in comments: + if c.get("type") == 1: + parts.append(f'
❤️ {_html_escape(c["from_nickname"])}
') + else: + to_part = "" + if c.get("to_nickname"): + to_part = f' 回复 {_html_escape(c["to_nickname"])}' + parts.append(f'
{_html_escape(c["from_nickname"])}{to_part}: {_html_escape(c.get("content", ""))}
') + parts.append('
') + + parts.append('
') + + parts.append('') + + with open(html_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(parts)) + return html_path + + +def export_sns_timeline(): + """导出朋友圈动态主函数""" + if not os.path.exists(SNS_DB_PATH): + print(f"朋友圈数据库不存在: {SNS_DB_PATH}") + print("请先运行「解密数据库」") + return + + # 加载联系人 + contact_map = _load_contact_map() + print(f"联系人: {len(contact_map)} 条") + + conn = sqlite3.connect(SNS_DB_PATH) + + # 加载评论 + print("加载评论数据...") + comments_map = _load_comments(conn) + print(f"评论/点赞: {sum(len(v) for v in comments_map.values())} 条") + + # 读取所有动态 + print("读取朋友圈动态...") + rows = conn.execute( + "SELECT tid, user_name, content FROM SnsTimeLine WHERE content IS NOT NULL" + ).fetchall() + conn.close() + + print(f"共 {len(rows)} 条动态") + if not rows: + return + + # 是否尝试下载媒体 + try_download = os.environ.get("WECHAT_SNS_DOWNLOAD_MEDIA", "0").strip() == "1" + + # ── 构建缓存索引 ───────────────────────────────────────────────────── + print("扫描 SNS 图片缓存...") + cache_index = _build_sns_cache_index() + index_mtimes = [e[0] for e in cache_index] + print(f"缓存索引: {len(cache_index)} 个有效图片文件") + + # ── 按 user_name 分组 ───────────────────────────────────────────────── + user_posts: dict[str, list] = {} # user_name -> [post, ...] + user_nicknames: dict[str, str] = {} # user_name -> nickname (从 XML 提取) + skipped = 0 + + for tid, user_name, content_xml in rows: + if not content_xml: + continue + if _CONTACT_FILTER and user_name not in _CONTACT_FILTER: + skipped += 1 + continue + + post = _parse_timeline_xml(content_xml) + if not post: + continue + + post["tid"] = tid + post["db_user_name"] = user_name or "" + post["comments"] = comments_map.get(tid, []) + + key = user_name or "unknown" + if key not in user_posts: + user_posts[key] = [] + user_posts[key].append(post) + + # 记录 nickname(取第一个非空的) + nick = post.get("nickname", "") + if nick and key not in user_nicknames: + user_nicknames[key] = nick + + if skipped: + print(f"筛选跳过: {skipped} 条") + + # ── 按联系人输出 ────────────────────────────────────────────────────── + total_posts = 0 + cache_match_ok = 0 + cache_match_fail = 0 + media_download_ok = 0 + media_download_fail = 0 + + for user_name, posts in user_posts.items(): + dname = contact_map.get(user_name) or _safe_dirname( + user_nicknames.get(user_name) or user_name + ) + sns_dir = os.path.join(OUTPUT_DIR, dname, "SNS") + os.makedirs(sns_dir, exist_ok=True) + + # 用 set 处理同一秒多条动态的文件名冲突 + used_names = set() + # image_files: {final_name: [(rel_path, ext), ...]} 用于 HTML 生成 + image_files: dict[str, list] = {} + + posts.sort(key=lambda p: p.get("create_time", 0)) + + for post in posts: + ts_name = _timestamp_filename(post.get("create_time")) + # 处理冲突: 递增末尾毫秒 + final_name = ts_name + counter = 1 + while final_name in used_names: + final_name = ts_name[:-3] + f"{counter:03d}" + counter += 1 + used_names.add(final_name) + post["_final_name"] = final_name + + # ── 缓存图片匹配 ───────────────────────────────────────── + media_list = post.get("media", []) + if media_list and cache_index: + matches = _match_cache_images( + post.get("create_time", 0), media_list, + cache_index, index_mtimes, + ) + for i, (matched_path, fmt) in enumerate(matches): + if matched_path is not None: + dec_bytes = _decrypt_sns_dat(matched_path) + if dec_bytes: + ext = _detect_format(dec_bytes[:16]) + img_name = f"{final_name}_{i}.{ext}" + img_path = os.path.join(sns_dir, img_name) + with open(img_path, 'wb') as f: + f.write(dec_bytes) + if final_name not in image_files: + image_files[final_name] = [] + image_files[final_name].append((img_name, ext)) + cache_match_ok += 1 + continue + cache_match_fail += 1 + else: + cache_match_fail += 1 + + # ── 网络下载(仅对缓存未匹配的媒体尝试) ───────────────── + if try_download and media_list: + existing_imgs = image_files.get(final_name, []) + existing_indices = {int(p.rsplit('_', 1)[1].split('.')[0]) for p, _ in existing_imgs} if existing_imgs else set() + for i, media in enumerate(media_list): + if i in existing_indices: + continue + media_url = media.get("url", "") or media.get("thumb_url", "") + if not media_url: + continue + save_name = os.path.join(sns_dir, f"{final_name}_{i}") + if _try_download_media(media_url, save_name): + # 下载成功后更新 image_files + for cand_ext in ('.jpg', '.png', '.gif', '.webp', '.bin'): + if os.path.exists(save_name + cand_ext): + if final_name not in image_files: + image_files[final_name] = [] + image_files[final_name].append((f"{final_name}_{i}{cand_ext}", cand_ext[1:])) + break + media_download_ok += 1 + else: + media_download_fail += 1 + + # 保存 JSON(去掉内部字段) + post_out = {k: v for k, v in post.items() if not k.startswith("_")} + post_file = os.path.join(sns_dir, f"{final_name}.json") + with open(post_file, 'w', encoding='utf-8') as f: + json.dump(post_out, f, ensure_ascii=False, indent=2) + + total_posts += 1 + + # 每个联系人的汇总 JSON + posts.sort(key=lambda p: p.get("create_time", 0), reverse=True) + summary_posts = [{k: v for k, v in p.items() if not k.startswith("_")} for p in posts] + summary_path = os.path.join(sns_dir, "timeline.json") + with open(summary_path, 'w', encoding='utf-8') as f: + json.dump({ + "user_name": user_name, + "display_name": dname, + "export_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "total_posts": len(posts), + "posts": summary_posts, + }, f, ensure_ascii=False, indent=2) + + # 生成 HTML 时间线 + _generate_timeline_html(dname, posts, sns_dir, image_files) + + print(f" {dname}: {len(posts)} 条动态") + + print(f"\n完成: {len(user_posts)} 个联系人, 共 {total_posts} 条动态") + if cache_index: + print(f"缓存匹配: 成功 {cache_match_ok}, 失败 {cache_match_fail}") + if try_download: + print(f"媒体下载: 成功 {media_download_ok}, 失败 {media_download_fail}") + print(f"输出目录: {os.path.abspath(OUTPUT_DIR)}") + + +if __name__ == "__main__": + export_sns_timeline() diff --git a/find_image_key.py b/find_image_key.py index afb9ad1..b034660 100644 --- a/find_image_key.py +++ b/find_image_key.py @@ -333,9 +333,15 @@ def verify_and_decrypt(attach_dir, aes_key_str, xor_key): def main(): - config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json') - with open(config_path, encoding="utf-8") as f: - config = json.load(f) + from config import _config_file_path, load_config + config_path = _config_file_path() + # 加载完整配置用于逻辑,读取原始 JSON 用于保存 + config = load_config() + try: + with open(config_path, encoding="utf-8") as f: + config_raw = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + config_raw = {} db_dir = config['db_dir'] base_dir = os.path.dirname(db_dir) @@ -392,8 +398,11 @@ def main(): config['image_aes_key'] = aes_key if xor_key is not None: config['image_xor_key'] = xor_key + config_raw['image_aes_key'] = aes_key + if xor_key is not None: + config_raw['image_xor_key'] = xor_key with open(config_path, 'w', encoding="utf-8") as f: - json.dump(config, f, indent=2, ensure_ascii=False) + json.dump(config_raw, f, indent=2, ensure_ascii=False) print(f"Saved to {config_path}", flush=True) print("\n=== Verify decrypt ===", flush=True) diff --git a/find_image_key_monitor.py b/find_image_key_monitor.py index 437a47f..fb68cee 100644 --- a/find_image_key_monitor.py +++ b/find_image_key_monitor.py @@ -226,9 +226,14 @@ def verify_and_decrypt(attach_dir, aes_key_str, xor_key): def main(): - config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json') - with open(config_path, encoding="utf-8") as f: - config = json.load(f) + from config import _config_file_path, load_config + config_path = _config_file_path() + config = load_config() + try: + with open(config_path, encoding="utf-8") as f: + config_raw = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + config_raw = {} db_dir = config['db_dir'] base_dir = os.path.dirname(db_dir) @@ -292,8 +297,11 @@ def main(): config['image_aes_key'] = aes_key if xor_key is not None: config['image_xor_key'] = xor_key + config_raw['image_aes_key'] = aes_key + if xor_key is not None: + config_raw['image_xor_key'] = xor_key with open(config_path, 'w', encoding="utf-8") as f: - json.dump(config, f, indent=2, ensure_ascii=False) + json.dump(config_raw, f, indent=2, ensure_ascii=False) print(f"Saved to {config_path}", flush=True) verify_and_decrypt(attach_dir, aes_key, xor_key) diff --git a/voice_to_mp3.py b/voice_to_mp3.py index e8d7ebe..b113806 100644 --- a/voice_to_mp3.py +++ b/voice_to_mp3.py @@ -15,7 +15,13 @@ from config import load_config _cfg = load_config() DB_PATH = os.path.join(_cfg["decrypted_dir"], "message", "media_0.db") CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db") -OUTPUT_DIR = os.path.join(_cfg["wechat_base_dir"], "voice") +OUTPUT_DIR = _cfg["output_base_dir"] + +_CONTACT_FILTER = None +_filter_raw = os.environ.get("WECHAT_EXPORT_CONTACTS", "").strip() +if _filter_raw: + _CONTACT_FILTER = set(_filter_raw.split(",")) + print(f"联系人筛选: {len(_CONTACT_FILTER)} 个") def silk_to_mp3(voice_data, output_path): """将微信 SILK 语音数据转换为 MP3""" @@ -92,15 +98,17 @@ success = 0 fail = 0 for chat_name_id, create_time, local_id, voice_data in rows: user_name = name_map.get(chat_name_id, f"unknown_{chat_name_id}") + if _CONTACT_FILTER and user_name not in _CONTACT_FILTER: + continue dname = safe_dirname(display_name(user_name)) dt = datetime.fromtimestamp(create_time) filename = dt.strftime("%Y%m%d_%H%M%S") + f"_{local_id}.mp3" - user_dir = os.path.join(OUTPUT_DIR, dname) + user_dir = os.path.join(OUTPUT_DIR, dname, "voice") os.makedirs(user_dir, exist_ok=True) - # 写入 .info 文件(只写一次) - info_path = os.path.join(user_dir, ".info") + # 写入 .info 文件(只写一次,写到联系人根目录) + info_path = os.path.join(OUTPUT_DIR, dname, ".info") if not os.path.exists(info_path): info = contact_map.get(user_name, {"username": user_name, "alias": "", "remark": "", "nick_name": ""}) with open(info_path, "w", encoding="utf-8") as f: From ecc1cfca64542e19b2f796269c136b2e04c5d124 Mon Sep 17 00:00:00 2001 From: xincheng Date: Sun, 17 May 2026 06:24:08 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E7=9A=84=E8=A7=A3=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 +- EXE_USAGE.md | 39 +- README.md | 41 +- WeChatDecrypt.spec | 2 +- app_gui.py | 275 ++++++++++++- build.bat | 4 + config.example.json | 2 + config.py | 7 +- decrypt_wxwork_db.py | 176 +++++++++ export_wxwork_messages.py | 745 ++++++++++++++++++++++++++++++++++++ find_wxwork_keys.py | 189 ++++++++- tests/test_wxwork_crypto.py | 48 +++ wxwork_crypto.py | 132 +++++++ 13 files changed, 1635 insertions(+), 34 deletions(-) create mode 100644 decrypt_wxwork_db.py create mode 100644 export_wxwork_messages.py create mode 100644 tests/test_wxwork_crypto.py create mode 100644 wxwork_crypto.py diff --git a/.gitignore b/.gitignore index 3537ab4..088c647 100644 --- a/.gitignore +++ b/.gitignore @@ -384,6 +384,9 @@ data/ export/ build/ dist/ -decrypted/ -all_keys.json -config.json \ No newline at end of file +decrypted/ +all_keys.json +wxwork_decrypted/ +wxwork_export/ +wxwork_keys.json +config.json diff --git a/EXE_USAGE.md b/EXE_USAGE.md index d72f7c0..df4c55e 100644 --- a/EXE_USAGE.md +++ b/EXE_USAGE.md @@ -2,17 +2,21 @@ ## 快速开始 -1. **启动微信**并登录账号 +1. **启动微信**并登录账号;如果要解密企业微信,也请先启动企业微信 2. 双击 `WeChatDecrypt.exe` 打开工具箱 -3. 按顺序点击三个按钮: - - **① 解密数据库** → 从微信进程提取密钥并解密数据库到 `decrypted/` 目录 - - **② 导出消息** → 将聊天记录导出为 CSV / HTML / JSON 到 `export/` 目录 - - **③ 转换音频** → 将语音消息从 SILK 格式转为 MP3 到 `data/` 目录 +3. 根据需要点击按钮: + - **① 微信解密** → 从微信进程提取密钥并解密数据库到 `decrypted/` 目录 + - **② 图片密钥** → 从微信进程提取新版图片 AES 密钥 + - **③ 导出数据** → 将聊天记录导出为 CSV / HTML / JSON 到 `export/` 目录 + - **④ 朋友圈图片** → 解密朋友圈缓存图片 + - **⑤ 企业微信解密** → 从企业微信进程提取密钥并解密数据库到 `wxwork_decrypted/` 目录 + - **⑥ 企业微信导出** → 选择某个人或群,导出 CSV / HTML / JSON 到 `wxwork_export/` 目录 ## 前置要求 - Windows 10 / 11 -- 微信 PC 版已登录(解密时需要微信进程运行) +- 微信 PC 版已登录(解密微信时需要微信进程运行) +- 企业微信 PC 版已登录(解密企业微信时需要企业微信进程运行) - [FFmpeg](https://ffmpeg.org/download.html) 已安装并加入 PATH(转换音频需要) ### 检查 FFmpeg @@ -31,7 +35,14 @@ ffmpeg -version WeChatDecrypt.exe config.json ← 首次运行自动生成的配置文件 decrypted/ ← ① 解密后的数据库文件 -export/ ← ② 导出的聊天记录 +wxwork_decrypted/ ← ⑤ 解密后的企业微信数据库文件 +wxwork_export/ ← ⑥ 导出的企业微信聊天记录 + 群名_R_123/ + .info + messages.csv + messages.html + messages.json +export/ ← ③ 导出的聊天记录 张三/ .info ← 联系人信息(username/alias/remark/nick_name) message_0.db.csv ← CSV 格式(Excel 可直接打开) @@ -39,7 +50,7 @@ export/ ← ② 导出的聊天记录 message_0.db.json← JSON 格式(程序处理用) 李四/ ... -data/ ← ③ 语音 MP3 文件 +data/ ← 导出时选择“同时转换语音为 MP3”后的输出 张三/ .info 20250101_120000_1.mp3 @@ -70,7 +81,11 @@ data/ ← ③ 语音 MP3 文件 "db_dir": "D:\\xwechat_files\\wxid_xxx\\db_storage", "keys_file": "all_keys.json", "decrypted_dir": "decrypted", - "wechat_process": "Weixin.exe" + "wechat_process": "Weixin.exe", + "wxwork_db_dir": "C:\\Users\\<用户>\\Documents\\WXWork\\\\Data", + "wxwork_keys_file": "wxwork_keys.json", + "wxwork_decrypted_dir": "wxwork_decrypted", + "wxwork_export_dir": "wxwork_export" } ``` @@ -85,6 +100,12 @@ A: 请确保微信 PC 版已启动并登录,然后重试。 **Q: 解密失败 / 密钥提取失败** A: 检查 `config.json` 中的 `db_dir` 是否与当前登录的微信账号匹配。切换账号后需要删除 `all_keys.json` 重新提取。 +**Q: 企业微信解密失败 / 找不到企业微信数据目录** +A: 确认企业微信 PC 版已启动并登录。若自动检测失败,请在 `config.json` 中设置 `wxwork_db_dir`,路径通常类似 `C:\Users\<用户>\Documents\WXWork\\Data`。切换企业微信账号后删除 `wxwork_keys.json` 重新提取。 + +**Q: 企业微信导出为空 / 找不到会话** +A: 先执行"⑤ 企业微信解密",确认 `wxwork_decrypted/message.db` 和 `wxwork_decrypted/session.db` 存在,然后再执行"⑥ 企业微信导出"。 + **Q: 转换音频没有输出** A: 确认已安装 FFmpeg 并加入系统 PATH。确认已先执行"① 解密数据库"。 diff --git a/README.md b/README.md index 81a5961..38567b0 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,9 @@ python find_image_key.py | `find_all_keys_windows.py` | Windows 版内存扫描提 key | | `find_all_keys_linux.py` | Linux 版内存扫描提 key | | `decrypt_db.py` | 全量解密所有数据库 | +| `find_wxwork_keys.py` | 企业微信 Windows 版内存扫描提 key | +| `decrypt_wxwork_db.py` | 企业微信 wxSQLite3 AES-128 数据库解密 | +| `export_wxwork_messages.py` | 企业微信聊天记录导出(按个人/群筛选,CSV / HTML / JSON) | | `mcp_server.py` | MCP Server,让 Claude AI 查询微信数据 | | `monitor_web.py` | 实时消息监听 (Web UI + SSE + 图片预览) | | `monitor.py` | 实时消息监听 (命令行) | @@ -197,11 +200,13 @@ python find_image_key.py ### GUI 工具箱 & 单 exe 打包 -提供 tkinter 图形界面 (`app_gui.py`),集成三个核心功能: +提供 tkinter 图形界面 (`app_gui.py`),集成核心功能: 1. **解密数据库** — 调用 `main.py decrypt` 2. **导出消息** — 调用 `export_messages.py`,输出 CSV / HTML / JSON 3. **转换音频** — 调用 `voice_to_mp3.py`,SILK_V3 → MP3 +4. **企业微信解密** — 调用 `find_wxwork_keys.py` + `decrypt_wxwork_db.py` +5. **企业微信导出** — 调用 `export_wxwork_messages.py`,按个人/群导出 CSV / HTML / JSON #### 直接运行 @@ -241,6 +246,40 @@ build.bat V2 文件结构: `[6B signature] [4B aes_size LE] [4B xor_size LE] [1B padding]` + `[AES-ECB encrypted] [raw unencrypted] [XOR encrypted]` +### 企业微信数据库解密 (实验) + +企业微信 Windows 5.x 的本地数据库不是普通微信 SQLCipher 4 格式,而是 wxSQLite3 AES-128-CBC: + +- 16 字节 raw key +- 每页按 page index + `sAlT` 派生 AES key +- 每页 IV 由 page index 派生 +- 无 SQLCipher HMAC / reserve 区 + +提取并解密: + +```bash +python find_wxwork_keys.py +python decrypt_wxwork_db.py +python export_wxwork_messages.py +``` + +如果自动提取失败但你已有 raw key,也可以直接传入 32 位 hex key: + +```bash +python decrypt_wxwork_db.py --key 00112233445566778899aabbccddeeff +``` + +配置项: + +```json +{ + "wxwork_db_dir": "C:\\Users\\<用户>\\Documents\\WXWork\\\\Data", + "wxwork_keys_file": "wxwork_keys.json", + "wxwork_decrypted_dir": "wxwork_decrypted", + "wxwork_export_dir": "wxwork_export" +} +``` + ### 数据库结构 解密后包含约 26 个数据库: diff --git a/WeChatDecrypt.spec b/WeChatDecrypt.spec index 96ab499..30fa867 100644 --- a/WeChatDecrypt.spec +++ b/WeChatDecrypt.spec @@ -1,7 +1,7 @@ # -*- 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', '.'), ('decrypt_sns.py', '.'), ('export_sns.py', '.'), ('monitor.py', '.'), ('monitor_web.py', '.'), ('mcp_server.py', '.'), ('config.example.json', '.')] +datas = [('main.py', '.'), ('config.py', '.'), ('decrypt_db.py', '.'), ('export_messages.py', '.'), ('voice_to_mp3.py', '.'), ('find_all_keys.py', '.'), ('find_all_keys_windows.py', '.'), ('find_all_keys_linux.py', '.'), ('find_wxwork_keys.py', '.'), ('decrypt_wxwork_db.py', '.'), ('export_wxwork_messages.py', '.'), ('wxwork_crypto.py', '.'), ('key_scan_common.py', '.'), ('key_utils.py', '.'), ('decode_image.py', '.'), ('find_image_key.py', '.'), ('find_image_key_monitor.py', '.'), ('decrypt_sns.py', '.'), ('export_sns.py', '.'), ('monitor.py', '.'), ('monitor_web.py', '.'), ('mcp_server.py', '.'), ('config.example.json', '.')] binaries = [] hiddenimports = [] tmp_ret = collect_all('pilk') diff --git a/app_gui.py b/app_gui.py index 4b00fe2..3a0a961 100644 --- a/app_gui.py +++ b/app_gui.py @@ -30,6 +30,8 @@ if False: # noqa: never executed, only for PyInstaller dependency detection import zstandard # noqa: F401 import pilk # noqa: F401 import Crypto, Crypto.Cipher, Crypto.Cipher.AES, Crypto.Util.Padding # noqa: F401 + import wxwork_crypto # noqa: F401 + import export_wxwork_messages # noqa: F401 def _run_subtask(task: str): @@ -59,6 +61,9 @@ def _run_subtask(task: str): "find_image_key": "find_image_key.py", "decrypt_sns": "decrypt_sns.py", "export_sns": "export_sns.py", + "find_wxwork_keys": "find_wxwork_keys.py", + "decrypt_wxwork": "decrypt_wxwork_db.py", + "export_wxwork": "export_wxwork_messages.py", } script = mapping.get(task) if not script: @@ -78,6 +83,12 @@ def _run_subtask(task: str): sys.argv = ["main.py", "decrypt"] elif task == "find_image_key": sys.argv = ["find_image_key.py"] + elif task == "find_wxwork_keys": + sys.argv = ["find_wxwork_keys.py"] + elif task == "decrypt_wxwork": + sys.argv = ["decrypt_wxwork_db.py"] + elif task == "export_wxwork": + sys.argv = ["export_wxwork_messages.py"] else: sys.argv = [script] @@ -360,11 +371,140 @@ class ExportOptionsDialog(tk.Toplevel): self.destroy() +class WxworkExportOptionsDialog(tk.Toplevel): + def __init__(self, parent, conversations): + """conversations: [{conversation_id, display_name, kind, message_count, last_time}, ...]""" + super().__init__(parent) + self.title("企业微信导出选项") + self.geometry("560x620") + self.transient(parent) + self.grab_set() + self.result = None + self.configure(bg="#f0f0f0") + self._conversations = conversations + self._vars = {} + + fmt_frame = ttk.LabelFrame(self, text="导出格式", padding=6) + fmt_frame.pack(fill="x", padx=12, pady=(10, 4)) + + self._fmt_csv = tk.BooleanVar(value=True) + self._fmt_html = tk.BooleanVar(value=False) + self._fmt_json = tk.BooleanVar(value=False) + + ttk.Checkbutton(fmt_frame, text="CSV(默认)", variable=self._fmt_csv).pack(side="left", padx=10) + ttk.Checkbutton(fmt_frame, text="HTML", variable=self._fmt_html).pack(side="left", padx=10) + ttk.Checkbutton(fmt_frame, text="JSON", variable=self._fmt_json).pack(side="left", padx=10) + + top = ttk.Frame(self) + top.pack(fill="x", padx=12, pady=(4, 4)) + ttk.Label(top, text=f"共 {len(conversations)} 个企业微信会话", + font=("Microsoft YaHei UI", 10)).pack(side="left") + + self._all_selected = True + self._toggle_btn = ttk.Button(top, text="取消全选", command=self._toggle_all) + self._toggle_btn.pack(side="right") + + search_frame = ttk.Frame(self) + search_frame.pack(fill="x", padx=12, pady=(0, 4)) + self._search_var = tk.StringVar() + self._search_var.trace_add("write", lambda *_: self._filter_list()) + ttk.Entry(search_frame, textvariable=self._search_var, + font=("Microsoft YaHei UI", 10)).pack(fill="x") + + container = ttk.Frame(self) + container.pack(fill="both", expand=True, padx=12, pady=4) + + self._canvas = tk.Canvas(container, bg="#ffffff", highlightthickness=0) + scrollbar = ttk.Scrollbar(container, orient="vertical", command=self._canvas.yview) + self._inner = ttk.Frame(self._canvas) + self._inner.bind("", + lambda e: self._canvas.configure(scrollregion=self._canvas.bbox("all"))) + self._canvas.create_window((0, 0), window=self._inner, anchor="nw") + self._canvas.configure(yscrollcommand=scrollbar.set) + self._canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + self._canvas.bind("", lambda e: self._bind_mousewheel()) + self._canvas.bind("", lambda e: self._unbind_mousewheel()) + + self._cb_widgets = [] + for conv in conversations: + cid = conv["conversation_id"] + var = tk.BooleanVar(value=True) + self._vars[cid] = var + last_time = self._format_time(conv.get("last_time")) + suffix = f" · {last_time}" if last_time else "" + label = ( + f"[{conv.get('kind', '会话')}] {conv.get('display_name') or cid}" + f" · {conv.get('message_count', 0)} 条{suffix}" + ) + cb = ttk.Checkbutton(self._inner, text=label, variable=var) + cb.pack(anchor="w", padx=6, pady=1) + self._cb_widgets.append((cid, label.lower(), cb)) + + bottom = ttk.Frame(self) + bottom.pack(fill="x", padx=12, pady=(4, 10)) + ttk.Button(bottom, text="确定", command=self._on_ok).pack(side="right", padx=4) + ttk.Button(bottom, text="取消", command=self._on_cancel).pack(side="right", padx=4) + + def _format_time(self, value): + if not value: + return "" + try: + from datetime import datetime + return datetime.fromtimestamp(int(value)).strftime("%Y-%m-%d") + except Exception: + return "" + + def _bind_mousewheel(self): + self._canvas.bind_all("", + lambda e: self._canvas.yview_scroll(-1 * (e.delta // 120), "units")) + + def _unbind_mousewheel(self): + self._canvas.unbind_all("") + + def _toggle_all(self): + self._all_selected = not self._all_selected + for var in self._vars.values(): + var.set(self._all_selected) + self._toggle_btn.configure(text="取消全选" if self._all_selected else "全选") + + def _filter_list(self): + keyword = self._search_var.get().strip().lower() + for _cid, label, cb in self._cb_widgets: + if not keyword or keyword in label: + cb.pack(anchor="w", padx=6, pady=1) + else: + cb.pack_forget() + + def _on_ok(self): + formats = [] + if self._fmt_csv.get(): + formats.append("csv") + if self._fmt_html.get(): + formats.append("html") + if self._fmt_json.get(): + formats.append("json") + if not formats: + from tkinter import messagebox + messagebox.showwarning("提示", "请至少选择一种导出格式", parent=self) + return + self.result = { + "conversations": [cid for cid, var in self._vars.items() if var.get()], + "formats": formats, + } + self.destroy() + + def _on_cancel(self): + self.result = None + self.destroy() + + class App(tk.Tk): def __init__(self): super().__init__() self.title("WeChat Decrypt 工具箱") - self.geometry("750x520") + self.geometry("820x600") self.resizable(True, True) self.configure(bg="#f0f0f0") self._running = False @@ -375,6 +515,8 @@ class App(tk.Tk): self._include_images = True self._include_sns = False self._include_sns_media = False + self._selected_wxwork_conversations = None + self._wxwork_export_formats = None self._build_ui() @@ -394,13 +536,13 @@ class App(tk.Tk): btn_frame.pack(fill="x", padx=20, pady=(4, 4)) self.btn_decrypt = ttk.Button( - btn_frame, text="① 解密数据库", style="Big.TButton", + btn_frame, text="① 微信解密", style="Big.TButton", command=lambda: self._run_task("decrypt") ) self.btn_decrypt.pack(side="left", expand=True, fill="x", padx=4) self.btn_imgkey = ttk.Button( - btn_frame, text="② 查找图片密钥", style="Big.TButton", + btn_frame, text="② 图片密钥", style="Big.TButton", command=lambda: self._run_task("find_image_key") ) self.btn_imgkey.pack(side="left", expand=True, fill="x", padx=4) @@ -417,17 +559,34 @@ class App(tk.Tk): ) self.btn_sns.pack(side="left", expand=True, fill="x", padx=4) + wxwork_frame = ttk.Frame(self) + wxwork_frame.pack(fill="x", padx=20, pady=(0, 6)) + + self.btn_wxwork = ttk.Button( + wxwork_frame, text="⑤ 企业微信解密", style="Big.TButton", + command=lambda: self._run_task("wxwork_decrypt") + ) + self.btn_wxwork.pack(side="left", expand=True, fill="x", padx=4) + + self.btn_wxwork_export = ttk.Button( + wxwork_frame, text="⑥ 企业微信导出", style="Big.TButton", + command=lambda: self._run_task("wxwork_export") + ) + self.btn_wxwork_export.pack(side="left", expand=True, fill="x", padx=4) + # 提示信息 tips_frame = ttk.LabelFrame(self, text="使用提示", padding=6) tips_frame.pack(fill="x", padx=20, pady=(0, 4)) tips_text = ( - "• 解密数据库:需要微信正在运行中,会自动提取密钥并解密\n" + "• 微信解密:需要微信正在运行中,会自动提取密钥并解密\n" "• 查找图片密钥:先在微信中打开 2-3 张图片查看,然后立即运行\n" "• 导出数据:选择联系人和格式,可同时导出消息/图片/语音\n" - "• 朋友圈图片:解密朋友圈缓存图片(_t缩略图自动跳过)" + "• 朋友圈图片:解密朋友圈缓存图片(_t缩略图自动跳过)\n" + "• 企业微信解密:需要企业微信正在运行中,输出到 wxwork_decrypted/\n" + "• 企业微信导出:选择某个人或群,输出 CSV / HTML / JSON 到 wxwork_export/" ) ttk.Label(tips_frame, text=tips_text, font=("Microsoft YaHei UI", 9), - wraplength=680, justify="left").pack(anchor="w") + wraplength=760, justify="left").pack(anchor="w") # 进度条 self.progress = ttk.Progressbar(self, mode="indeterminate") @@ -468,6 +627,8 @@ class App(tk.Tk): self.btn_imgkey.configure(state=state) self.btn_export.configure(state=state) self.btn_sns.configure(state=state) + self.btn_wxwork.configure(state=state) + self.btn_wxwork_export.configure(state=state) # ── 任务调度 ─────────────────────────────────────────────────────────── def _run_task(self, task: str): @@ -479,6 +640,8 @@ class App(tk.Tk): self._include_voice = False self._include_sns = False self._include_sns_media = False + self._selected_wxwork_conversations = None + self._wxwork_export_formats = None self._clear_log() self._set_buttons(False) @@ -496,6 +659,14 @@ class App(tk.Tk): self.progress.start(15) self.status_var.set("正在解密朋友圈图片...") threading.Thread(target=self._exec_task, args=(task,), daemon=True).start() + elif task == "wxwork_decrypt": + self.progress.start(15) + self.status_var.set("正在解密企业微信数据库...") + threading.Thread(target=self._exec_wxwork_decrypt, daemon=True).start() + elif task == "wxwork_export": + self.progress.start(15) + self.status_var.set("正在扫描企业微信会话...") + threading.Thread(target=self._discover_wxwork_and_select, daemon=True).start() else: self.progress.start(15) labels = {"decrypt": "解密数据库"} @@ -555,6 +726,51 @@ class App(tk.Tk): self.status_var.set(f"正在{action}...({n_sel}/{len(contacts)} 个联系人)") threading.Thread(target=self._exec_combined, daemon=True).start() + def _discover_wxwork_and_select(self): + """后台扫描企业微信会话,然后在主线程弹出选择对话框""" + try: + from export_wxwork_messages import discover_conversations + conversations = discover_conversations() + except Exception as e: + self.after(0, self._log, f"扫描企业微信会话失败: {e}\n") + self.after(0, self._on_task_done) + return + + if not conversations: + self.after(0, self._log, "未找到任何企业微信会话,请先运行「企业微信解密」\n") + self.after(0, self._on_task_done) + return + + self.after(0, self._show_wxwork_dialog, conversations) + + def _show_wxwork_dialog(self, conversations): + self.progress.stop() + self.status_var.set(f"请选择企业微信导出选项 ({len(conversations)} 个会话)") + + dlg = WxworkExportOptionsDialog(self, conversations) + self.wait_window(dlg) + + if dlg.result is None: + self._on_task_done() + return + + if not dlg.result["conversations"]: + self._log("未选择任何企业微信会话\n") + self._on_task_done() + return + + self._selected_wxwork_conversations = dlg.result["conversations"] + self._wxwork_export_formats = dlg.result["formats"] + + self._clear_log() + self.progress.start(15) + n_sel = len(dlg.result["conversations"]) + self.status_var.set( + f"正在导出企业微信 {'/'.join(f.upper() for f in self._wxwork_export_formats)}..." + f"({n_sel}/{len(conversations)} 个会话)" + ) + threading.Thread(target=self._exec_wxwork_export, daemon=True).start() + # ── 子进程执行 ───────────────────────────────────────────────────────── def _run_subprocess(self, task: str) -> int: """运行子进程,返回退出码""" @@ -572,6 +788,10 @@ class App(tk.Tk): env["WECHAT_EXPORT_IMAGES"] = "1" if getattr(self, '_include_images', True) else "0" if getattr(self, '_include_sns_media', False): env["WECHAT_SNS_DOWNLOAD_MEDIA"] = "1" + if self._selected_wxwork_conversations: + env["WXWORK_EXPORT_CONVERSATIONS"] = ",".join(self._selected_wxwork_conversations) + if self._wxwork_export_formats: + env["WXWORK_EXPORT_FORMATS"] = ",".join(self._wxwork_export_formats) proc = subprocess.Popen( cmd, @@ -631,6 +851,49 @@ class App(tk.Tk): self._include_sns_media = False self.after(0, self._on_task_done) + def _exec_wxwork_decrypt(self): + """执行企业微信 key 提取 + 数据库解密。""" + try: + self.after(0, self._log, "━━━ 开始提取企业微信密钥 ━━━\n\n") + rc = self._run_subprocess("find_wxwork_keys") + if rc != 0: + self.after(0, self._log, f"\n❌ 企业微信密钥提取失败 (返回码 {rc})\n") + self.after(0, self.status_var.set, f"企业微信密钥提取失败 (返回码 {rc})") + return + + self.after(0, self._log, "\n\n━━━ 开始解密企业微信数据库 ━━━\n\n") + rc = self._run_subprocess("decrypt_wxwork") + if rc != 0: + self.after(0, self._log, f"\n❌ 企业微信数据库解密失败 (返回码 {rc})\n") + self.after(0, self.status_var.set, f"企业微信解密失败 (返回码 {rc})") + return + + self.after(0, self._log, "\n✅ 企业微信解密完成!输出目录: wxwork_decrypted\n") + self.after(0, self.status_var.set, "企业微信解密完成") + except Exception as e: + self.after(0, self._log, f"\n❌ 异常: {e}\n") + self.after(0, self.status_var.set, "异常") + finally: + self.after(0, self._on_task_done) + + def _exec_wxwork_export(self): + """执行企业微信消息导出。""" + try: + rc = self._run_subprocess("export_wxwork") + if rc != 0: + self.after(0, self._log, f"\n❌ 企业微信导出失败 (返回码 {rc})\n") + self.after(0, self.status_var.set, f"企业微信导出失败 (返回码 {rc})") + return + self.after(0, self._log, "\n✅ 企业微信导出完成!输出目录: wxwork_export\n") + self.after(0, self.status_var.set, "企业微信导出完成") + except Exception as e: + self.after(0, self._log, f"\n❌ 异常: {e}\n") + self.after(0, self.status_var.set, "异常") + finally: + self._selected_wxwork_conversations = None + self._wxwork_export_formats = None + self.after(0, self._on_task_done) + def _exec_task(self, task: str): """执行单一任务(解密)""" try: diff --git a/build.bat b/build.bat index d0b43eb..25a3c07 100644 --- a/build.bat +++ b/build.bat @@ -24,6 +24,10 @@ pyinstaller --noconfirm --onefile --console --name "WeChatDecrypt" ^ --add-data "find_all_keys.py;." ^ --add-data "find_all_keys_windows.py;." ^ --add-data "find_all_keys_linux.py;." ^ + --add-data "find_wxwork_keys.py;." ^ + --add-data "decrypt_wxwork_db.py;." ^ + --add-data "export_wxwork_messages.py;." ^ + --add-data "wxwork_crypto.py;." ^ --add-data "key_scan_common.py;." ^ --add-data "key_utils.py;." ^ --add-data "decode_image.py;." ^ diff --git a/config.example.json b/config.example.json index df34721..6a9e592 100644 --- a/config.example.json +++ b/config.example.json @@ -5,5 +5,7 @@ "wechat_process": "Weixin.exe", "wxwork_db_dir": "", "wxwork_keys_file": "wxwork_keys.json", + "wxwork_decrypted_dir": "wxwork_decrypted", + "wxwork_export_dir": "wxwork_export", "wxwork_process": "WXWork.exe" } diff --git a/config.py b/config.py index 7398c2f..c738ee2 100644 --- a/config.py +++ b/config.py @@ -43,6 +43,8 @@ _DEFAULT = { "wechat_process": _DEFAULT_PROCESS, "wxwork_db_dir": "", "wxwork_keys_file": "wxwork_keys.json", + "wxwork_decrypted_dir": "wxwork_decrypted", + "wxwork_export_dir": "wxwork_export", "wxwork_process": "WXWork.exe", } @@ -216,7 +218,10 @@ def load_config(): # 将相对路径转为绝对路径 base = _app_base_dir() - for key in ("keys_file", "decrypted_dir", "decoded_image_dir", "wxwork_keys_file"): + for key in ( + "keys_file", "decrypted_dir", "decoded_image_dir", + "wxwork_keys_file", "wxwork_decrypted_dir", "wxwork_export_dir", + ): if key in cfg and cfg[key] and not os.path.isabs(cfg[key]): cfg[key] = os.path.join(base, cfg[key]) diff --git a/decrypt_wxwork_db.py b/decrypt_wxwork_db.py new file mode 100644 index 0000000..3894d4b --- /dev/null +++ b/decrypt_wxwork_db.py @@ -0,0 +1,176 @@ +""" +Decrypt WXWork databases encrypted with wxSQLite3 AES-128-CBC. + +This handles the database page format. A 16-byte raw key is still required, +either from wxwork_keys.json or via --key. +""" +import argparse +import json +import os +import shutil +import sys + +from key_utils import get_key_info, strip_key_metadata +from wxwork_crypto import ( + decrypt_wxwork_database, + is_plain_sqlite_page, + is_wxsqlite3_aes128_page1, + verify_sqlite_file, + verify_wxsqlite3_aes128_key, +) + + +def _app_paths(): + from config import _app_base_dir, _config_file_path + + return _app_base_dir(), _config_file_path() + + +def _load_config(): + base, config_file = _app_paths() + cfg = {} + if os.path.exists(config_file): + with open(config_file, encoding="utf-8") as f: + cfg = json.load(f) + + db_dir = cfg.get("wxwork_db_dir", "") + if not db_dir or not os.path.isdir(db_dir): + from find_wxwork_keys import auto_detect_wxwork_db_dir + + detected = auto_detect_wxwork_db_dir() + if detected: + db_dir = detected + else: + raise RuntimeError("wxwork_db_dir is not configured") + + keys_file = cfg.get("wxwork_keys_file", "wxwork_keys.json") + if not os.path.isabs(keys_file): + keys_file = os.path.join(base, keys_file) + + out_dir = cfg.get("wxwork_decrypted_dir", "wxwork_decrypted") + if not os.path.isabs(out_dir): + out_dir = os.path.join(base, out_dir) + + return { + "db_dir": db_dir, + "keys_file": keys_file, + "out_dir": out_dir, + "global_key": cfg.get("wxwork_db_key", ""), + } + + +def _parse_key_hex(value): + value = (value or "").strip() + if value.startswith("x'") and value.endswith("'"): + value = value[2:-1] + if len(value) != 32: + raise ValueError("WXWork wxSQLite3 AES-128 key must be 32 hex chars") + return bytes.fromhex(value) + + +def _load_keys(keys_file): + if not os.path.exists(keys_file): + return {} + with open(keys_file, encoding="utf-8") as f: + return strip_key_metadata(json.load(f)) + + +def _iter_db_files(db_dir): + for root, dirs, files in os.walk(db_dir): + dirs[:] = [d for d in dirs if d not in ("-journal",)] + for name in files: + if not name.endswith(".db") or name.endswith("-wal") or name.endswith("-shm"): + continue + path = os.path.join(root, name) + rel = os.path.relpath(path, db_dir) + yield rel, path + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Decrypt WXWork wxSQLite3 AES-128 databases") + parser.add_argument("--key", help="16-byte raw key as 32 hex chars") + args = parser.parse_args(argv) + + cfg = _load_config() + db_dir = cfg["db_dir"] + out_dir = cfg["out_dir"] + keys_file = cfg["keys_file"] + keys = _load_keys(keys_file) + + global_key = None + key_arg = args.key or cfg.get("global_key") + if key_arg: + global_key = _parse_key_hex(key_arg) + + print("=" * 60) + print(" WXWork Database Decryptor") + print("=" * 60) + print(f"DB dir: {db_dir}") + print(f"Output: {out_dir}") + if keys: + print(f"Loaded {len(keys)} per-DB keys from {keys_file}") + elif global_key: + print("Using global key from argument/config") + else: + print(f"No key available. Run find_wxwork_keys.py or pass --key.") + return 1 + + os.makedirs(out_dir, exist_ok=True) + + success = 0 + copied = 0 + failed = 0 + for rel, path in sorted(_iter_db_files(db_dir)): + out_path = os.path.join(out_dir, rel) + with open(path, "rb") as f: + page1 = f.read(4096) + + if is_plain_sqlite_page(page1): + os.makedirs(os.path.dirname(out_path), exist_ok=True) + shutil.copy2(path, out_path) + copied += 1 + print(f"COPY: {rel} (plain SQLite)") + continue + + if not is_wxsqlite3_aes128_page1(page1): + failed += 1 + print(f"SKIP: {rel} (unknown encrypted format)") + continue + + key = global_key + key_info = get_key_info(keys, rel) if keys else None + if key_info: + try: + key = _parse_key_hex(key_info["enc_key"]) + except (KeyError, ValueError) as exc: + failed += 1 + print(f"FAIL: {rel} (bad key entry: {exc})") + continue + + if key is None: + failed += 1 + print(f"SKIP: {rel} (no key)") + continue + + if not verify_wxsqlite3_aes128_key(key, page1): + failed += 1 + print(f"FAIL: {rel} (key validation failed)") + continue + + try: + decrypt_wxwork_database(path, out_path, key) + tables = verify_sqlite_file(out_path) + success += 1 + table_preview = ", ".join(tables[:5]) + suffix = f" tables: {table_preview}" if table_preview else " no tables" + print(f"OK: {rel} ({suffix})") + except Exception as exc: + failed += 1 + print(f"FAIL: {rel} ({exc})") + + print(f"\nResult: {success} decrypted, {copied} copied, {failed} failed") + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/export_wxwork_messages.py b/export_wxwork_messages.py new file mode 100644 index 0000000..3065f43 --- /dev/null +++ b/export_wxwork_messages.py @@ -0,0 +1,745 @@ +"""导出企业微信消息记录到 CSV / HTML / JSON。 + +输入目录默认来自 wxwork_decrypted_dir,输出到 wxwork_export_dir。 +可用环境变量: + WXWORK_EXPORT_CONVERSATIONS=conversation_id1,conversation_id2 + WXWORK_EXPORT_FORMATS=csv,html,json +""" +import argparse +import csv +import json +import os +import re +import sqlite3 +import sys +from collections import defaultdict +from datetime import datetime +from html import escape + + +if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + +MSG_TYPES = { + 0: "文本/混合", + 2: "文本", + 4: "图片", + 7: "语音", + 15: "图片/文件", + 38: "应用消息", + 40: "通话/音视频", + 503: "状态", + 1011: "会议通知", +} + +_MESSAGE_TABLES = ("message_table", "message_small_table", "kf_message_tableV1") + + +def _app_paths(): + from config import _app_base_dir, _config_file_path + + return _app_base_dir(), _config_file_path() + + +def _load_config(): + base, config_file = _app_paths() + cfg = {} + if os.path.exists(config_file): + with open(config_file, encoding="utf-8") as f: + cfg = json.load(f) + + decrypted_dir = cfg.get("wxwork_decrypted_dir", "wxwork_decrypted") + if not os.path.isabs(decrypted_dir): + decrypted_dir = os.path.join(base, decrypted_dir) + + output_dir = cfg.get("wxwork_export_dir", "wxwork_export") + if not os.path.isabs(output_dir): + output_dir = os.path.join(base, output_dir) + + db_dir = cfg.get("wxwork_db_dir", "") + return { + "base": base, + "decrypted_dir": decrypted_dir, + "output_dir": output_dir, + "self_id": _infer_self_id(db_dir), + } + + +def _infer_self_id(db_dir): + if not db_dir: + return None + parts = os.path.normpath(db_dir).split(os.sep) + for part in reversed(parts): + if part.isdigit() and len(part) >= 10: + return int(part) + return None + + +def _safe_dirname(name): + name = re.sub(r'[\\/:*?"<>|\r\n\t]+', "_", str(name)) + name = re.sub(r"\s+", " ", name).strip(" .") + return (name or "unknown")[:120] + + +def _table_exists(conn, table): + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (table,), + ).fetchone() + return row is not None + + +def _open_db(path): + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + return conn + + +def _load_user_map(decrypted_dir): + user_db = os.path.join(decrypted_dir, "user.db") + users = {} + if not os.path.exists(user_db): + return users + + conn = _open_db(user_db) + try: + if _table_exists(conn, "user_table"): + for row in conn.execute( + "SELECT id, name, real_name, account, external_corp_name, external_job " + "FROM user_table" + ): + name = row["real_name"] or row["name"] or row["account"] or "" + if row["external_corp_name"] and row["external_corp_name"] not in name: + name = f"{name} ({row['external_corp_name']})" if name else row["external_corp_name"] + if name: + users[int(row["id"])] = name + + if _table_exists(conn, "external_user_relation_v3"): + for row in conn.execute( + "SELECT user_id, remarks, real_remarks, corp_remark FROM external_user_relation_v3" + ): + name = row["real_remarks"] or row["remarks"] or row["corp_remark"] or "" + if name: + users[int(row["user_id"])] = name + finally: + conn.close() + return users + + +def _load_group_member_names(decrypted_dir): + session_db = os.path.join(decrypted_dir, "session.db") + members = defaultdict(dict) + if not os.path.exists(session_db): + return members + + conn = _open_db(session_db) + try: + if _table_exists(conn, "conversation_user_table"): + for row in conn.execute( + "SELECT conversation_id, user_id, nick_name FROM conversation_user_table" + ): + if row["nick_name"]: + members[row["conversation_id"]][int(row["user_id"])] = row["nick_name"] + + if _table_exists(conn, "conversation_member_nickname_table"): + # 该表使用 room_id,需要用 conversation_table.con_numeric_id 转成会话 ID。 + room_map = {} + if _table_exists(conn, "conversation_table"): + for row in conn.execute("SELECT con_numeric_id, id FROM conversation_table"): + room_map[int(row["con_numeric_id"])] = row["id"] + for row in conn.execute( + "SELECT room_id, userid, nickname FROM conversation_member_nickname_table" + ): + cid = room_map.get(int(row["room_id"])) + if cid and row["nickname"]: + members[cid][int(row["userid"])] = row["nickname"] + finally: + conn.close() + return members + + +def _conversation_kind(conversation_id): + if conversation_id.startswith("R:"): + return "群聊" + if conversation_id.startswith("S:"): + return "单聊" + if conversation_id.startswith("M:"): + return "微信联系人" + if conversation_id.startswith("O:"): + return "应用/公众号" + if conversation_id.startswith("Y:"): + return "系统会话" + return "其他" + + +def _name_from_conversation_id(conversation_id, user_map, self_id): + if conversation_id.startswith("S:"): + ids = [] + for value in conversation_id[2:].split("_"): + if value.isdigit(): + ids.append(int(value)) + other_ids = [uid for uid in ids if self_id is None or uid != self_id] + for uid in other_ids or ids: + if uid in user_map: + return user_map[uid] + + if ":" in conversation_id: + tail = conversation_id.split(":", 1)[1] + if tail.isdigit() and int(tail) in user_map: + return user_map[int(tail)] + + return conversation_id + + +def _load_message_counts(decrypted_dir): + msg_db = os.path.join(decrypted_dir, "message.db") + counts = defaultdict(int) + last_times = defaultdict(int) + if not os.path.exists(msg_db): + return counts, last_times + + conn = _open_db(msg_db) + try: + for table in _MESSAGE_TABLES: + if not _table_exists(conn, table): + continue + for row in conn.execute( + f'SELECT conversation_id, COUNT(*) AS c, MAX(send_time) AS t ' + f'FROM "{table}" GROUP BY conversation_id' + ): + cid = row["conversation_id"] + if not cid: + continue + counts[cid] += int(row["c"] or 0) + last_times[cid] = max(last_times[cid], int(row["t"] or 0)) + finally: + conn.close() + return counts, last_times + + +def discover_conversations(decrypted_dir=None): + cfg = _load_config() + if decrypted_dir is None: + decrypted_dir = cfg["decrypted_dir"] + if not os.path.isdir(decrypted_dir): + raise FileNotFoundError(f"企业微信解密目录不存在: {decrypted_dir}") + + user_map = _load_user_map(decrypted_dir) + counts, message_last_times = _load_message_counts(decrypted_dir) + session_db = os.path.join(decrypted_dir, "session.db") + conversations = {} + + if os.path.exists(session_db): + conn = _open_db(session_db) + try: + if _table_exists(conn, "conversation_table"): + for row in conn.execute( + "SELECT id, name, roomname_remark, last_message_time, last_message_id " + "FROM conversation_table" + ): + cid = row["id"] + if not cid: + continue + raw_name = row["roomname_remark"] or row["name"] or "" + display = raw_name or _name_from_conversation_id( + cid, user_map, cfg["self_id"] + ) + last_time = max( + int(row["last_message_time"] or 0), + message_last_times.get(cid, 0), + ) + conversations[cid] = { + "conversation_id": cid, + "display_name": display, + "kind": _conversation_kind(cid), + "message_count": counts.get(cid, 0), + "last_time": last_time, + "last_message_id": int(row["last_message_id"] or 0), + } + finally: + conn.close() + + for cid, count in counts.items(): + if cid in conversations: + conversations[cid]["message_count"] = count + conversations[cid]["last_time"] = max( + conversations[cid]["last_time"], message_last_times.get(cid, 0) + ) + continue + conversations[cid] = { + "conversation_id": cid, + "display_name": _name_from_conversation_id(cid, user_map, cfg["self_id"]), + "kind": _conversation_kind(cid), + "message_count": count, + "last_time": message_last_times.get(cid, 0), + "last_message_id": 0, + } + + result = [c for c in conversations.values() if c["message_count"] > 0] + result.sort(key=lambda c: (c["last_time"], c["message_count"]), reverse=True) + return result + + +def _read_varint(data, pos): + value = 0 + shift = 0 + while pos < len(data) and shift < 64: + b = data[pos] + pos += 1 + value |= (b & 0x7F) << shift + if not (b & 0x80): + return value, pos + shift += 7 + raise ValueError("bad varint") + + +def _clean_text(text): + text = "".join( + ch if ch in "\n\t" or (ch.isprintable() and ch not in "\x0b\x0c") else " " + for ch in text + ) + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def _looks_like_plain_text(data, text): + if not text: + return False + control = sum(1 for b in data if b < 32 and b not in (9, 10, 13)) + if control / max(len(data), 1) > 0.08: + return False + printable = sum(1 for ch in text if ch.isprintable() or ch in "\n\t") + return printable / max(len(text), 1) > 0.9 + + +def _decode_text_segment(segment): + if not segment or b"\x00" in segment: + return None + try: + text = segment.decode("utf-8") + except UnicodeDecodeError: + return None + text = _clean_text(text) + if len(text) < 2: + return None + if re.fullmatch(r"[0-9a-fA-F]{32,}", text): + return None + printable = sum(1 for ch in text if ch.isprintable() or ch in "\n\t") + if printable / max(len(text), 1) < 0.9: + return None + return text + + +def _parse_protobuf_strings(data, depth=0): + if depth > 4 or not data: + return [] + pos = 0 + out = [] + fields = 0 + try: + while pos < len(data): + tag, pos = _read_varint(data, pos) + if tag == 0: + return [] + wire = tag & 7 + fields += 1 + if wire == 0: + _, pos = _read_varint(data, pos) + elif wire == 1: + pos += 8 + elif wire == 5: + pos += 4 + elif wire == 2: + length, pos = _read_varint(data, pos) + if length < 0 or pos + length > len(data): + return [] + segment = data[pos:pos + length] + pos += length + text = _decode_text_segment(segment) + if text: + out.append(text) + else: + out.extend(_parse_protobuf_strings(segment, depth + 1)) + else: + return [] + if pos > len(data): + return [] + except Exception: + return [] + return out if fields else [] + + +def _dedupe_texts(values): + seen = set() + out = [] + for value in values: + value = _clean_text(value) + if not value or value in seen: + continue + seen.add(value) + out.append(value) + return out + + +def decode_content(raw): + if raw is None: + return "" + if isinstance(raw, str): + return _clean_text(raw) + data = bytes(raw) + if not data: + return "" + + try: + plain = data.decode("utf-8") + if _looks_like_plain_text(data, plain): + return _clean_text(plain) + except UnicodeDecodeError: + pass + + texts = _dedupe_texts(_parse_protobuf_strings(data)) + if texts: + return "\n".join(texts[:12]) + + for enc in ("utf-8", "gbk", "utf-16le"): + try: + text = _clean_text(data.decode(enc, errors="replace")) + if text and "\ufffd" not in text[:20]: + return text[:2000] + except Exception: + continue + return f"[二进制内容 {len(data)} 字节]" + + +def _format_time(ts): + try: + ts = int(ts or 0) + except (TypeError, ValueError): + ts = 0 + if ts <= 0: + return "" + if ts > 20_000_000_000: + ts = ts / 1000 + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") + + +def _message_type_name(content_type): + return MSG_TYPES.get(int(content_type or 0), f"未知({content_type})") + + +def _display_message_content(content_type, content, extra_content, local_extra_content): + text = content or extra_content or local_extra_content + if text: + return text + return f"[{_message_type_name(content_type)}]" + + +def _build_message(row, conv_map, user_map, member_names, self_id): + cid = row["conversation_id"] + sender_id = int(row["sender_id"] or 0) + sender = member_names.get(cid, {}).get(sender_id) or user_map.get(sender_id) + if self_id is not None and sender_id == self_id: + sender = "我" + if not sender: + sender = str(sender_id) if sender_id else "系统" + + content = decode_content(row["content"]) + extra_content = decode_content(row["extra_content"]) + local_extra_content = decode_content(row["local_extra_content"]) + content_type = int(row["content_type"] or 0) + conv = conv_map.get(cid, {}) + + return { + "source_table": row["source_table"], + "message_id": int(row["message_id"] or 0), + "server_id": int(row["server_id"] or 0), + "sequence": int(row["sequence"] or 0), + "conversation_id": cid, + "conversation": conv.get("display_name") or cid, + "conversation_kind": conv.get("kind") or _conversation_kind(cid), + "sender_id": sender_id, + "sender": sender, + "content_type": content_type, + "type_name": _message_type_name(content_type), + "send_time": int(row["send_time"] or 0), + "time": _format_time(row["send_time"]), + "flag": int(row["flag"] or 0), + "content": content, + "extra_content": extra_content, + "local_extra_content": local_extra_content, + "display_content": _display_message_content( + content_type, content, extra_content, local_extra_content + ), + "is_sent": self_id is not None and sender_id == self_id, + } + + +def _iter_message_rows(message_db, selected_ids=None): + selected_ids = set(selected_ids or []) + conn = _open_db(message_db) + try: + for table in _MESSAGE_TABLES: + if not _table_exists(conn, table): + continue + where = "" + params = [] + if selected_ids: + placeholders = ",".join("?" for _ in selected_ids) + where = f"WHERE conversation_id IN ({placeholders})" + params = list(selected_ids) + sql = ( + f'SELECT "{table}" AS source_table, message_id, server_id, sequence, ' + f"sender_id, conversation_id, content_type, send_time, flag, " + f"content, extra_content, local_extra_content " + f'FROM "{table}" {where} ' + f"ORDER BY send_time, sequence, message_id" + ) + yield from conn.execute(sql, params) + finally: + conn.close() + + +HTML_TEMPLATE = """ + + + + +{title} + + + +
{title}
{meta}
+
+{body} +
+ + +""" + + +def _write_html(path, conv, messages): + parts = [] + last_day = None + is_group = conv.get("kind") == "群聊" + for msg in messages: + day = msg["time"][:10] if msg["time"] else "" + if day and day != last_day: + parts.append(f'
{escape(day)}
') + last_day = day + + side = "sent" if msg["is_sent"] else "received" + sender_label = "" + if is_group or not msg["is_sent"]: + sender_label = f'
{escape(msg["sender"])}
' + initial = escape((msg["sender"] or "?")[0].upper()) + content = escape(msg["display_content"] or "") + type_line = escape(f'{msg["type_name"]} · {msg["time"]}') + parts.append( + f'
' + f'
{initial}
' + f'
{sender_label}' + f'
{content}
' + f'
{type_line}
' + f'
' + ) + + meta = f'{conv.get("kind", "")} · {len(messages)} 条消息 · {conv["conversation_id"]}' + with open(path, "w", encoding="utf-8") as f: + f.write( + HTML_TEMPLATE.format( + title=escape(conv["display_name"]), + meta=escape(meta), + body="\n".join(parts), + ) + ) + + +def _write_csv(path, messages): + with open(path, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow([ + "时间", "会话", "会话ID", "发送者", "发送者ID", "消息类型", + "内容", "message_id", "server_id", "sequence", "flag", + ]) + for msg in messages: + writer.writerow([ + msg["time"], + msg["conversation"], + msg["conversation_id"], + msg["sender"], + msg["sender_id"], + msg["type_name"], + msg["display_content"], + msg["message_id"], + msg["server_id"], + msg["sequence"], + msg["flag"], + ]) + + +def _write_json(path, conv, messages): + with open(path, "w", encoding="utf-8") as f: + json.dump( + { + "conversation": conv, + "message_count": len(messages), + "messages": messages, + }, + f, + ensure_ascii=False, + indent=2, + ) + + +def _selected_from_env(): + raw = os.environ.get("WXWORK_EXPORT_CONVERSATIONS", "").strip() + if not raw: + return None + return {item.strip() for item in raw.split(",") if item.strip()} + + +def _formats_from_env(): + raw = os.environ.get("WXWORK_EXPORT_FORMATS", "").strip() + if not raw: + return {"csv"} + formats = {item.strip().lower() for item in raw.split(",") if item.strip()} + valid = {"csv", "html", "json"} + return formats & valid or {"csv"} + + +def export_messages(selected_ids=None, formats=None): + cfg = _load_config() + decrypted_dir = cfg["decrypted_dir"] + output_dir = cfg["output_dir"] + message_db = os.path.join(decrypted_dir, "message.db") + + if not os.path.isdir(decrypted_dir): + raise FileNotFoundError(f"企业微信解密目录不存在: {decrypted_dir}") + if not os.path.exists(message_db): + raise FileNotFoundError(f"企业微信消息库不存在: {message_db}") + + formats = formats or _formats_from_env() + selected_ids = selected_ids if selected_ids is not None else _selected_from_env() + conversations = discover_conversations(decrypted_dir) + conv_map = {conv["conversation_id"]: conv for conv in conversations} + if selected_ids: + missing = sorted(selected_ids - set(conv_map)) + if missing: + print(f"提示: {len(missing)} 个选择的会话没有消息或不存在") + + user_map = _load_user_map(decrypted_dir) + member_names = _load_group_member_names(decrypted_dir) + grouped = defaultdict(list) + seen = set() + + for row in _iter_message_rows(message_db, selected_ids): + key = (row["conversation_id"], row["message_id"], row["server_id"], row["sequence"]) + if key in seen: + continue + seen.add(key) + msg = _build_message(row, conv_map, user_map, member_names, cfg["self_id"]) + grouped[msg["conversation_id"]].append(msg) + + os.makedirs(output_dir, exist_ok=True) + + total_conversations = 0 + total_messages = 0 + for cid, messages in sorted( + grouped.items(), + key=lambda item: (conv_map.get(item[0], {}).get("last_time", 0), len(item[1])), + reverse=True, + ): + conv = conv_map.get(cid) or { + "conversation_id": cid, + "display_name": cid, + "kind": _conversation_kind(cid), + "message_count": len(messages), + "last_time": messages[-1]["send_time"] if messages else 0, + } + folder = _safe_dirname(f'{conv["display_name"]}_{cid}') + out_dir = os.path.join(output_dir, folder) + os.makedirs(out_dir, exist_ok=True) + + info_path = os.path.join(out_dir, ".info") + with open(info_path, "w", encoding="utf-8") as f: + f.write(f"conversation_id: {cid}\n") + f.write(f"display_name: {conv['display_name']}\n") + f.write(f"kind: {conv.get('kind', '')}\n") + f.write(f"message_count: {len(messages)}\n") + + if "csv" in formats: + _write_csv(os.path.join(out_dir, "messages.csv"), messages) + if "html" in formats: + _write_html(os.path.join(out_dir, "messages.html"), conv, messages) + if "json" in formats: + _write_json(os.path.join(out_dir, "messages.json"), conv, messages) + + total_conversations += 1 + total_messages += len(messages) + print(f" {conv['display_name']} ({cid}): {len(messages)} 条") + + print(f"\n完成: {total_conversations} 个会话, 共 {total_messages} 条消息") + print(f"输出目录: {os.path.abspath(output_dir)}") + return { + "conversation_count": total_conversations, + "message_count": total_messages, + "output_dir": os.path.abspath(output_dir), + } + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Export WXWork messages") + parser.add_argument("--list", action="store_true", help="list conversations and exit") + parser.add_argument( + "--conversation", + action="append", + help="conversation ID to export; can be passed multiple times", + ) + parser.add_argument("--formats", help="comma separated formats: csv,html,json") + args = parser.parse_args(argv) + + if args.list: + conversations = discover_conversations() + print(f"发现 {len(conversations)} 个有消息的企业微信会话") + for conv in conversations: + last_time = _format_time(conv["last_time"]) + print( + f"{conv['conversation_id']}\t{conv['message_count']}\t" + f"{last_time}\t{conv['kind']}\t{conv['display_name']}" + ) + return 0 + + selected = set(args.conversation) if args.conversation else None + formats = None + if args.formats: + formats = {item.strip().lower() for item in args.formats.split(",") if item.strip()} + export_messages(selected_ids=selected, formats=formats) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: + print(f"导出失败: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/find_wxwork_keys.py b/find_wxwork_keys.py index c99c4db..78d3a81 100644 --- a/find_wxwork_keys.py +++ b/find_wxwork_keys.py @@ -1,14 +1,13 @@ """ 从企业微信(WXWork)进程内存中提取所有数据库的缓存raw key -企业微信使用 WCDB/SQLCipher 加密,但与个人微信参数不同: -- 个人微信: AES-256-CBC, 32字节密钥, HMAC-SHA512 -- 企业微信: AES-128-CBC, 16字节密钥, 每页根据page index重新派生IV/key - -密钥在内存中的缓存格式仍为 x'',但 key 为16字节(32 hex chars) +企业微信的本地数据库与个人微信不同。实测 Windows 版使用 wxSQLite3 +AES-128-CBC 页面加密:16 字节 raw key,每页按 page index 派生 AES key +和 IV;页面没有 SQLCipher HMAC/reserve 区。 """ import ctypes import ctypes.wintypes as wt +import bisect import functools import hashlib import hmac as hmac_mod @@ -21,6 +20,11 @@ import sys import time from key_scan_common import collect_db_files +from wxwork_crypto import ( + is_plain_sqlite_page, + is_wxsqlite3_aes128_page1, + verify_wxsqlite3_aes128_key, +) print = functools.partial(print, flush=True) @@ -67,12 +71,11 @@ def enum_regions(h): # ── 常量 ───────────────────────────────────────────────────────────── WXWORK_PROCESS = "WXWork.exe" -SQLITE_HEADER_HEX = b"SQLite format 3\x00".hex() PAGE_SZ = 4096 SALT_SZ = 16 -# 企业微信可能的加密参数组合 (按可能性排序) +# 旧版本/其他平台可能回落到 SQLCipher 参数,保留作兼容验证。 # (key_sz, hmac_hash_name, hmac_sz, pbkdf2_iter, reserve_sz) VERIFY_CONFIGS = [ # WCDB optimized cipher with AES-128, HMAC-SHA512 (最可能) @@ -89,6 +92,9 @@ VERIFY_CONFIGS = [ def verify_enc_key_wxwork(enc_key, db_page1): """尝试多种参数组合验证密钥,返回 (成功?, 使用的配置描述)""" + if len(enc_key) == 16 and verify_wxsqlite3_aes128_key(enc_key, db_page1): + return True, "wxSQLite3 AES-128-CBC, per-page MD5 key/IV, no HMAC" + key_sz = len(enc_key) for cfg_key_sz, hmac_hash, hmac_sz, iterations, reserve_sz in VERIFY_CONFIGS: if key_sz != cfg_key_sz: @@ -185,16 +191,20 @@ def auto_detect_wxwork_db_dir(): def filter_encrypted_dbs(db_files, salt_to_dbs): - """过滤掉未加密的数据库(salt 等于 SQLite header 的)""" + """过滤掉未加密的数据库。""" filtered_files = [ - entry for entry in db_files if entry[3] != SQLITE_HEADER_HEX + entry for entry in db_files if not is_plain_sqlite_page(entry[4]) ] filtered_salts = { - s: dbs for s, dbs in salt_to_dbs.items() if s != SQLITE_HEADER_HEX + s: dbs for s, dbs in salt_to_dbs.items() + if any(entry[3] == s and not is_plain_sqlite_page(entry[4]) for entry in db_files) } removed = len(db_files) - len(filtered_files) if removed: print(f"[*] 跳过 {removed} 个未加密数据库") + wxsqlite3_count = sum(1 for entry in filtered_files if is_wxsqlite3_aes128_page1(entry[4])) + if wxsqlite3_count: + print(f"[*] 检测到 {wxsqlite3_count} 个 wxSQLite3 AES-128 格式数据库") return filtered_files, filtered_salts @@ -282,6 +292,137 @@ def scan_memory_for_wxwork_keys(data, hex_re, db_files, salt_to_dbs, key_map, return matches +def _find_region(memory_regions, starts, addr, length=4): + idx = bisect.bisect_right(starts, addr) - 1 + if idx < 0: + return None + base, end, data = memory_regions[idx] + if base <= addr and addr + length <= end: + return base, end, data + return None + + +def _read_u32(memory_regions, starts, addr): + region = _find_region(memory_regions, starts, addr, 4) + if not region: + return None + base, _end, data = region + return struct.unpack_from("= 0 and off < max_off: + if time.time() - t0 > max_seconds: + print_fn( + f"[WARN] 结构体扫描超时: checked={checked}, " + f"ptr_hits={ptr_hits}, chain_hits={chain_hits}, key_tests={key_tests}" + ) + return key_tests + + # The AES-128 decrypt branch checks two non-zero flags at +0 and +4. + flag0, flag4 = struct.unpack_from("= 6: + key_tests += 1 + if _record_candidate_key( + enc_key, db_files, salt_to_dbs, key_map, + remaining_salts, pid, cipher_addr, + f"wxSQLite3 AES-128-CBC, page_size={page_size}", + print_fn, + ): + if not remaining_salts: + return key_tests + + checked += 1 + off += 4 + + print_fn( + f"[*] 结构体扫描完成: checked={checked}, ptr_hits={ptr_hits}, " + f"chain_hits={chain_hits}, key_tests={key_tests}" + ) + return key_tests + + def cross_verify_wxwork_keys(db_files, salt_to_dbs, key_map, print_fn): """用已找到的 key 交叉验证未匹配的 salt。""" missing_salts = set(salt_to_dbs.keys()) - set(key_map.keys()) @@ -395,11 +536,17 @@ def main(): # 2. 打开所有企业微信进程 pids = get_wxwork_pids() - # 宽松正则:匹配 32+ hex chars (16字节key起) + # Some versions do not keep the key as SQL literal x'...'. Bare ASCII + # hex scanning is much slower, so keep it behind an explicit switch. hex_re = re.compile(b"x'([0-9a-fA-F]{32,192})'") + scan_bare_hex = "--scan-bare-hex" in sys.argv + bare_hex_re = re.compile(b"(?= 512 + and page_size <= 65536 + and (page_size & (page_size - 1)) == 0 + and header[5] == 0x40 + and header[6] == 0x20 + and header[7] == 0x20 + ) + + +def is_wxsqlite3_aes128_page1(page): + return not is_plain_sqlite_page(page) and has_wxsqlite3_plain_header_fragment(page) + + +def _decrypt_aes128_cbc(raw_key, page_no, data): + page_key = derive_wxsqlite3_aes128_page_key(raw_key, page_no) + iv = generate_initial_vector(page_no) + return AES.new(page_key, AES.MODE_CBC, iv).decrypt(data) + + +def decrypt_wxsqlite3_aes128_page(raw_key, page_data, page_no): + """Decrypt one wxSQLite3 AES-128-CBC page to a normal SQLite page.""" + if len(page_data) != PAGE_SZ: + raise ValueError(f"page must be exactly {PAGE_SZ} bytes") + + data = bytearray(page_data) + if page_no == 1 and has_wxsqlite3_plain_header_fragment(data): + db_header_fragment = bytes(data[16:24]) + data[16:24] = data[8:16] + decrypted_tail = _decrypt_aes128_cbc(raw_key, page_no, bytes(data[16:])) + data[16:] = decrypted_tail + if bytes(data[16:24]) != db_header_fragment: + raise ValueError("wxSQLite3 AES-128 key validation failed") + data[:16] = SQLITE_HDR + return bytes(data) + + return _decrypt_aes128_cbc(raw_key, page_no, bytes(data)) + + +def looks_like_sqlite_page1(page): + if page[: len(SQLITE_HDR)] != SQLITE_HDR: + return False + if len(page) < 108: + return False + btree_page_type = page[100] + return btree_page_type in (0x02, 0x05, 0x0A, 0x0D) + + +def verify_wxsqlite3_aes128_key(raw_key, page1): + if len(raw_key) != 16 or len(page1) < PAGE_SZ: + return False + try: + decrypted = decrypt_wxsqlite3_aes128_page(raw_key, page1[:PAGE_SZ], 1) + except (ValueError, KeyError): + return False + return looks_like_sqlite_page1(decrypted) + + +def decrypt_wxwork_database(db_path, out_path, raw_key): + size = os.path.getsize(db_path) + total_pages = (size + PAGE_SZ - 1) // PAGE_SZ + os.makedirs(os.path.dirname(out_path), exist_ok=True) + + with open(db_path, "rb") as fin, open(out_path, "wb") as fout: + for page_no in range(1, total_pages + 1): + page = fin.read(PAGE_SZ) + if not page: + break + if len(page) < PAGE_SZ: + page += b"\x00" * (PAGE_SZ - len(page)) + fout.write(decrypt_wxsqlite3_aes128_page(raw_key, page, page_no)) + + +def verify_sqlite_file(path): + conn = sqlite3.connect(path) + try: + return [row[0] for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).fetchall()] + finally: + conn.close() From 16940a8771d160a995a8234c1a70f6025b2a00c5 Mon Sep 17 00:00:00 2001 From: xincheng Date: Sun, 17 May 2026 06:50:11 +0800 Subject: [PATCH 5/6] update --- app_gui.py | 2 ++ config.py | 6 +++++- find_wxwork_keys.py | 38 +++++++++++++++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/app_gui.py b/app_gui.py index 3a0a961..090fad3 100644 --- a/app_gui.py +++ b/app_gui.py @@ -780,6 +780,8 @@ class App(tk.Tk): env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" env["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR + env["WECHAT_DECRYPT_GUI"] = "1" + env["WECHAT_DECRYPT_NONINTERACTIVE"] = "1" if self._selected_contacts: env["WECHAT_EXPORT_CONTACTS"] = ",".join(self._selected_contacts) diff --git a/config.py b/config.py index c738ee2..00cc3b0 100644 --- a/config.py +++ b/config.py @@ -54,7 +54,11 @@ def _choose_candidate(candidates): if len(candidates) == 1: return candidates[0] if len(candidates) > 1: - if not sys.stdin.isatty(): + if ( + os.environ.get("WECHAT_DECRYPT_NONINTERACTIVE") == "1" + or os.environ.get("WECHAT_DECRYPT_GUI") == "1" + or not sys.stdin.isatty() + ): return candidates[0] print("[!] 检测到多个微信数据目录(请选择当前正在运行的微信账号):") for i, c in enumerate(candidates, 1): diff --git a/find_wxwork_keys.py b/find_wxwork_keys.py index 78d3a81..cc98cf1 100644 --- a/find_wxwork_keys.py +++ b/find_wxwork_keys.py @@ -140,6 +140,35 @@ def get_wxwork_pids(): # ── WXWork 数据目录自动检测 ────────────────────────────────────────── +def _wxwork_data_dir_mtime(data_dir): + """返回企业微信 Data 目录最近活跃时间,用于多账号自动选择。""" + latest = 0 + for root, dirs, files in os.walk(data_dir): + dirs[:] = [d for d in dirs if d not in ("-journal",)] + for name in files: + if not name.endswith((".db", ".db-wal", ".db-shm")): + continue + path = os.path.join(root, name) + try: + latest = max(latest, os.path.getmtime(path)) + except OSError: + pass + try: + latest = max(latest, os.path.getmtime(data_dir)) + except OSError: + pass + return latest + + +def _is_noninteractive_mode(): + return ( + os.environ.get("WECHAT_DECRYPT_NONINTERACTIVE") == "1" + or os.environ.get("WXWORK_AUTO_SELECT_DB") == "1" + or os.environ.get("WECHAT_DECRYPT_GUI") == "1" + or not sys.stdin.isatty() + ) + + def auto_detect_wxwork_db_dir(): """扫描 %USERPROFILE%\\Documents\\WXWork\\*\\Data 寻找包含加密DB的目录""" docs = os.path.join(os.environ.get("USERPROFILE", ""), "Documents", "WXWork") @@ -168,11 +197,18 @@ def auto_detect_wxwork_db_dir(): if not candidates: return None + candidates.sort(key=_wxwork_data_dir_mtime, reverse=True) if len(candidates) == 1: return candidates[0] - if not sys.stdin.isatty(): + if _is_noninteractive_mode(): + selected = candidates[0] + print("[!] 检测到多个企业微信数据目录,非交互模式下自动选择最近活跃目录:") + for i, c in enumerate(candidates, 1): + marker = " *" if c == selected else " " + print(f" {marker} {i}. {c}") return candidates[0] + print("[!] 检测到多个企业微信数据目录:") for i, c in enumerate(candidates, 1): print(f" {i}. {c}") From 26ecaac11e242aa76d684f38fdfd5f8a494be23b Mon Sep 17 00:00:00 2001 From: xincheng Date: Sun, 17 May 2026 07:12:26 +0800 Subject: [PATCH 6/6] fix argparse --- main.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 9862af6..60bd251 100644 --- a/main.py +++ b/main.py @@ -261,6 +261,16 @@ def print_usage(): print(" python main.py status 显示当前状态和磁盘用量") +def _call_with_argv(func, argv): + """调用子命令 main() 时临时隔离 sys.argv,避免 argparse 读到外层命令。""" + old_argv = sys.argv[:] + try: + sys.argv = argv + return func() + finally: + sys.argv = old_argv + + def main(): print("=" * 60) print(" WeChat Decrypt") @@ -301,19 +311,19 @@ def main(): print("[*] 开始解密全部数据库...") print() from decrypt_db import main as decrypt_all - decrypt_all() + _call_with_argv(decrypt_all, ["decrypt_db.py", *sys.argv[2:]]) elif cmd in ("export", "all"): print("[*] 开始解密全部数据库...") print() from decrypt_db import main as decrypt_all - decrypt_all() + _call_with_argv(decrypt_all, ["decrypt_db.py"]) print() print("[*] 开始批量导出聊天记录...") print() from export_all_chats import main as export_all try: - export_all() + _call_with_argv(export_all, ["export_all_chats.py"]) except SystemExit: pass