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

@@ -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')

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__":

181
batch_decrypt_images.py Normal file
View File

@@ -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('<LL', data, 6)
aligned = aes_size - ~(~aes_size % 16)
offset = 15
if offset + aligned > 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()

View File

@@ -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;." ^

View File

@@ -229,8 +229,34 @@ def load_config():
else:
cfg["wechat_base_dir"] = db_dir
# 输出目录:<app_dir>/wechat_files/<wxid>/
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

272
decrypt_sns.py Normal file
View File

@@ -0,0 +1,272 @@
"""解密微信朋友圈图片缓存
来源1: WeChat Files/FileStorage/Sns/Cache/<YYYY-MM>/<hash>[_t|_d]
来源2: xwechat_files/cache/<YYYY-MM>/Sns/Img/<hex>/<hash>
输出目录: <output_base_dir>/朋友圈图片/<YYYY-MM>/
_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('<LL', data, 6)
aligned = aes_size - ~(~aes_size % 16)
offset = 15
if offset + aligned > 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/<YYYY-MM>/Sns/Img/<hex>/ 下的所有文件
返回 {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/<YYYY-MM>/ ──
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/<YYYY-MM>/Sns/Img/<hex>/ ──
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()

View File

@@ -1,6 +1,8 @@
"""导出微信消息记录到 CSV / HTML / JSON
目录结构: <wechat_base_dir>/export/<display_name>/messages.csv|html|json
目录结构: <output_base_dir>/<display_name>/messages.csv|html|json
图片导出: <output_base_dir>/<display_name>/image/<md5>.<ext>
"""
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 目录: <hash>/<YYYY-MM>/Img/<md5>*.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 目录: <hash>/Image/<YYYY-MM>/<md5>*.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('<LL', data, 6)
aligned = aes_size - ~(~aes_size % 16)
offset = 15
if offset + aligned > 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/<YYYY-MM>/
跳过 _t 缩略图,优先 _h 高清版
返回 {file_md5: relative_path} 用于 HTML 嵌入
"""
image_map = {}
username_hash = hashlib.md5(chat_username.encode()).hexdigest()
# 收集所有来源目录: [(base_path, sub_structure), ...]
# xwechat: attach/<hash>/<YYYY-MM>/Img/<md5>*.dat
# WeChat Files: MsgAttach/<hash>/Image/<YYYY-MM>/<md5>*.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: <hash>/<YYYY-MM>/Img/ — 直接列 base_path 得到月份
# wechat: <hash>/Image/<YYYY-MM>/ — 需要列 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}}
</style>
</head>
@@ -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("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace('"','&quot;')
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'<div class="type-tag">{m["type_name"]}</div>'
# 图片消息嵌入
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'<img src=\"data:{mime};base64,{b64}\" alt=\"图片\">'
except Exception:
bubble_content = f'<img src=\"{_html_escape(rel_path)}\" alt=\"图片\">'
else:
bubble_content = f'<img src=\"{_html_escape(rel_path)}\" alt=\"图片\">'
else:
bubble_content = f'<img src=\"{_html_escape(rel_path)}\" alt=\"图片\">'
parts.append(
f'<div class="msg {side}">'
f'<div class="avatar">{initial}</div>'
f'<div class="msg-body">'
f'{sender_label}'
f'<div class="bubble">{_html_escape(m["display_content"])}</div>'
f'<div class="bubble">{bubble_content}</div>'
f'{type_tag}'
f'<div class="type-tag">{m["time_str"]}</div>'
f'</div></div>'
@@ -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)}")

820
export_sns.py Normal file
View File

@@ -0,0 +1,820 @@
"""导出微信朋友圈动态SnsTimeLine 表)
输出目录: <output_base_dir>/<display_name>/SNS/<yyyyMMddHHmmss000>.json
媒体文件: <output_base_dir>/<display_name>/SNS/<yyyyMMddHHmmss000>_<n>.<ext>
汇总文件: <output_base_dir>/<display_name>/SNS/timeline.json
时间线: <output_base_dir>/<display_name>/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('<LL', data, 6)
aligned = aes_size + (16 - aes_size % 16) if aes_size % 16 else aes_size + 16
offset = 15
if offset + aligned > 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('<H', data[26:28])[0] & 0x3FFF
h = struct.unpack('<H', data[28:30])[0] & 0x3FFF
return w, h
return 0, 0
def _build_sns_cache_index():
"""扫描 SNS 缓存目录,预解密文件头提取元数据
返回按 mtime 排序的索引:
[(mtime, path, est_dec_size, fmt, width, height), ...]
"""
raw_paths = [] # 先收集所有路径
# 1. xwechat cache: <cache_dir>/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: <sns_cache_dir>/YYYY-MM/<hash>
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('<LL', data, 6)
aligned = aes_size + (16 - aes_size % 16) if aes_size % 16 else aes_size + 16
est_dec_size = fsize - 15 - (aligned - aes_size)
available = min(aligned, len(data) - 15)
# 按 16 字节块对齐ECB 可逐块解密)
usable = (available // 16) * 16
if usable < 16:
continue
cipher = _AES.new(k[:16], _AES.MODE_ECB)
dec_header = cipher.decrypt(data[15:15 + usable])
except Exception:
continue
else:
# XOR 格式
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))):
dec_header = bytes(b ^ key for b in data[:4096])
est_dec_size = fsize
break
if dec_header is None:
continue
fmt = _detect_format(dec_header[:16])
if fmt == 'bin':
continue
w, h = _image_size_from_bytes(dec_header)
entries.append((mtime, path, est_dec_size, fmt, w, h))
except OSError:
continue
entries.sort(key=lambda x: x[0])
return entries
def _match_cache_images(create_time, media_list, index, index_mtimes):
"""为一条动态的所有媒体项匹配本地缓存图片(无需解密,仅查元数据索引)
返回: [(matched_path, fmt), ...] 与 media_list 等长,未匹配为 (None, None)
"""
results = []
if not index or not media_list:
return [(None, None)] * len(media_list)
t_low = create_time - _TIME_WINDOW
t_high = create_time + _TIME_WINDOW
lo = bisect.bisect_left(index_mtimes, t_low)
hi = bisect.bisect_right(index_mtimes, t_high)
# 如果时间窗口为空xwechat cache mtime 异常),扩大到全部
if lo >= 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_html_escape(display_name)} - 朋友圈</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #f5f5f5; color: #333; }}
h1 {{ text-align: center; color: #07c160; border-bottom: 2px solid #07c160; padding-bottom: 10px; }}
.stats {{ text-align: center; color: #888; margin-bottom: 30px; font-size: 14px; }}
.post {{ background: #fff; border-radius: 10px; padding: 16px 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
.post-time {{ font-size: 12px; color: #999; margin-bottom: 8px; }}
.post-type {{ display: inline-block; font-size: 11px; background: #e8f5e9; color: #2e7d32; padding: 1px 6px; border-radius: 3px; margin-left: 8px; }}
.post-text {{ margin: 8px 0; white-space: pre-wrap; word-break: break-word; line-height: 1.6; }}
.post-images {{ display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }}
.post-images img {{ max-width: 240px; max-height: 240px; border-radius: 6px; object-fit: cover; cursor: pointer; }}
.post-images img:hover {{ opacity: 0.85; }}
.post-location {{ font-size: 12px; color: #1a73e8; margin: 4px 0; }}
.comments {{ margin-top: 10px; padding-top: 8px; border-top: 1px solid #f0f0f0; }}
.comment {{ font-size: 13px; color: #555; margin: 4px 0; line-height: 1.5; }}
.comment-name {{ color: #576b95; font-weight: 500; }}
.comment-like {{ color: #e64a19; }}
.private-tag {{ font-size: 11px; background: #fff3e0; color: #e65100; padding: 1px 6px; border-radius: 3px; margin-left: 6px; }}
</style>
</head>
<body>
<h1>{_html_escape(display_name)} 的朋友圈</h1>
<div class="stats">共 {len(posts)} 条动态</div>
"""]
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('<div class="post">')
parts.append(f'<div class="post-time">{time_str}<span class="post-type">{type_name}</span>')
if is_private:
parts.append('<span class="private-tag">仅自己可见</span>')
parts.append('</div>')
if text:
parts.append(f'<div class="post-text">{text}</div>')
# 图片
imgs = image_files.get(final_name, [])
if imgs:
parts.append('<div class="post-images">')
for rel_path, _ in imgs:
parts.append(f'<img src="{_html_escape(rel_path)}" loading="lazy" onclick="window.open(this.src)">')
parts.append('</div>')
# 位置
loc = post.get("location")
if loc and loc.get("poi_name"):
parts.append(f'<div class="post-location">📍 {_html_escape(loc["poi_name"])}</div>')
# 评论
comments = post.get("comments", [])
if comments:
parts.append('<div class="comments">')
for c in comments:
if c.get("type") == 1:
parts.append(f'<div class="comment comment-like">❤️ <span class="comment-name">{_html_escape(c["from_nickname"])}</span></div>')
else:
to_part = ""
if c.get("to_nickname"):
to_part = f' 回复 <span class="comment-name">{_html_escape(c["to_nickname"])}</span>'
parts.append(f'<div class="comment"><span class="comment-name">{_html_escape(c["from_nickname"])}</span>{to_part}: {_html_escape(c.get("content", ""))}</div>')
parts.append('</div>')
parts.append('</div>')
parts.append('</body></html>')
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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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: