Add SNS/image export and GUI contact export

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>
This commit is contained in:
xincheng
2026-04-06 23:57:51 +08:00
parent ebbea8c895
commit b9f3161f84
11 changed files with 2207 additions and 95 deletions

View File

@@ -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("<Configure>",
lambda e: self._canvas.configure(scrollregion=self._canvas.bbox("all")))
self._canvas.create_window((0, 0), window=self._inner, anchor="nw")
self._canvas.configure(yscrollcommand=scrollbar.set)
self._canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# 绑定鼠标滚轮
self._canvas.bind("<Enter>", lambda e: self._bind_mousewheel())
self._canvas.bind("<Leave>", lambda e: self._unbind_mousewheel())
# 创建 Checkbutton 列表
self._cb_widgets = []
for username, dname in contacts:
var = tk.BooleanVar(value=True)
self._vars[username] = var
label = f"{dname} ({username})" if dname != username else username
cb = ttk.Checkbutton(self._inner, text=label, variable=var)
cb.pack(anchor="w", padx=6, pady=1)
self._cb_widgets.append((username, dname, cb))
# 底部按钮
bottom = ttk.Frame(self)
bottom.pack(fill="x", padx=12, pady=(4, 10))
ttk.Button(bottom, text="确定", command=self._on_ok).pack(side="right", padx=4)
ttk.Button(bottom, text="取消", command=self._on_cancel).pack(side="right", padx=4)
def _bind_mousewheel(self):
self._canvas.bind_all("<MouseWheel>",
lambda e: self._canvas.yview_scroll(-1 * (e.delta // 120), "units"))
def _unbind_mousewheel(self):
self._canvas.unbind_all("<MouseWheel>")
def _toggle_all(self):
self._all_selected = not self._all_selected
for var in self._vars.values():
var.set(self._all_selected)
self._toggle_btn.configure(text="取消全选" if self._all_selected else "全选")
def _filter_list(self):
keyword = self._search_var.get().strip().lower()
for username, dname, cb in self._cb_widgets:
if not keyword or keyword in dname.lower() or keyword in username.lower():
cb.pack(anchor="w", padx=6, pady=1)
else:
cb.pack_forget()
def _on_ok(self):
formats = []
if self._fmt_csv.get():
formats.append("csv")
if self._fmt_html.get():
formats.append("html")
if self._fmt_json.get():
formats.append("json")
if not formats and not self._voice_var.get() and not self._sns_var.get():
from tkinter import messagebox
messagebox.showwarning("提示", "请至少选择一种导出格式、朋友圈导出或语音转换", parent=self)
return
self.result = {
"contacts": [u for u, var in self._vars.items() if var.get()],
"formats": formats,
"include_voice": self._voice_var.get(),
"include_images": self._image_var.get(),
"include_sns": self._sns_var.get(),
"include_sns_media": self._sns_media_var.get(),
}
self.destroy()
def _on_cancel(self):
self.result = None
self.destroy()
class 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__":