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:
@@ -1,7 +1,7 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
from PyInstaller.utils.hooks import collect_all
|
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 = []
|
binaries = []
|
||||||
hiddenimports = []
|
hiddenimports = []
|
||||||
tmp_ret = collect_all('pilk')
|
tmp_ret = collect_all('pilk')
|
||||||
|
|||||||
493
app_gui.py
493
app_gui.py
@@ -3,6 +3,9 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
import sqlite3
|
||||||
|
import hashlib
|
||||||
|
import glob as globmod
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk, scrolledtext
|
from tkinter import ttk, scrolledtext
|
||||||
|
|
||||||
@@ -12,6 +15,7 @@ if getattr(sys, "frozen", False):
|
|||||||
else:
|
else:
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
os.chdir(BASE_DIR)
|
os.chdir(BASE_DIR)
|
||||||
|
os.environ["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
# ── 子任务入口(当以 --task 参数调用时直接执行对应脚本) ──────────────────────
|
# ── 子任务入口(当以 --task 参数调用时直接执行对应脚本) ──────────────────────
|
||||||
@@ -52,6 +56,9 @@ def _run_subtask(task: str):
|
|||||||
"decrypt": "main.py",
|
"decrypt": "main.py",
|
||||||
"export": "export_messages.py",
|
"export": "export_messages.py",
|
||||||
"voice": "voice_to_mp3.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)
|
script = mapping.get(task)
|
||||||
if not script:
|
if not script:
|
||||||
@@ -69,6 +76,8 @@ def _run_subtask(task: str):
|
|||||||
# 将 decrypt 命令传给 main.py
|
# 将 decrypt 命令传给 main.py
|
||||||
if task == "decrypt":
|
if task == "decrypt":
|
||||||
sys.argv = ["main.py", "decrypt"]
|
sys.argv = ["main.py", "decrypt"]
|
||||||
|
elif task == "find_image_key":
|
||||||
|
sys.argv = ["find_image_key.py"]
|
||||||
else:
|
else:
|
||||||
sys.argv = [script]
|
sys.argv = [script]
|
||||||
|
|
||||||
@@ -97,6 +106,260 @@ if sys.platform == "win32":
|
|||||||
pass
|
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):
|
class App(tk.Tk):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -105,6 +368,13 @@ class App(tk.Tk):
|
|||||||
self.resizable(True, True)
|
self.resizable(True, True)
|
||||||
self.configure(bg="#f0f0f0")
|
self.configure(bg="#f0f0f0")
|
||||||
self._running = False
|
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()
|
self._build_ui()
|
||||||
|
|
||||||
@@ -121,7 +391,7 @@ class App(tk.Tk):
|
|||||||
|
|
||||||
# 按钮区域
|
# 按钮区域
|
||||||
btn_frame = ttk.Frame(self)
|
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(
|
self.btn_decrypt = ttk.Button(
|
||||||
btn_frame, text="① 解密数据库", style="Big.TButton",
|
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_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(
|
self.btn_export = ttk.Button(
|
||||||
btn_frame, text="② 导出消息", style="Big.TButton",
|
btn_frame, text="③ 导出数据", style="Big.TButton",
|
||||||
command=lambda: self._run_task("export")
|
command=lambda: self._run_task("export")
|
||||||
)
|
)
|
||||||
self.btn_export.pack(side="left", expand=True, fill="x", padx=4)
|
self.btn_export.pack(side="left", expand=True, fill="x", padx=4)
|
||||||
|
|
||||||
self.btn_voice = ttk.Button(
|
self.btn_sns = ttk.Button(
|
||||||
btn_frame, text="③ 转换音频", style="Big.TButton",
|
btn_frame, text="④ 朋友圈图片", style="Big.TButton",
|
||||||
command=lambda: self._run_task("voice")
|
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")
|
self.progress = ttk.Progressbar(self, mode="indeterminate")
|
||||||
@@ -177,56 +465,181 @@ class App(tk.Tk):
|
|||||||
def _set_buttons(self, enabled: bool):
|
def _set_buttons(self, enabled: bool):
|
||||||
state = "normal" if enabled else "disabled"
|
state = "normal" if enabled else "disabled"
|
||||||
self.btn_decrypt.configure(state=state)
|
self.btn_decrypt.configure(state=state)
|
||||||
|
self.btn_imgkey.configure(state=state)
|
||||||
self.btn_export.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):
|
def _run_task(self, task: str):
|
||||||
if self._running:
|
if self._running:
|
||||||
return
|
return
|
||||||
self._running = True
|
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._clear_log()
|
||||||
self._set_buttons(False)
|
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)
|
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": "解密数据库",
|
def _run_subprocess(self, task: str) -> int:
|
||||||
"export": "导出消息记录",
|
"""运行子进程,返回退出码"""
|
||||||
"voice": "转换音频文件",
|
cmd = [sys.executable, "--task", task]
|
||||||
}
|
self.after(0, self._log, f">>> {' '.join(cmd)}\n\n")
|
||||||
self.status_var.set(f"正在{labels[task]}...")
|
|
||||||
|
|
||||||
thread = threading.Thread(target=self._exec_task, args=(task,), daemon=True)
|
env = os.environ.copy()
|
||||||
thread.start()
|
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):
|
def _exec_task(self, task: str):
|
||||||
|
"""执行单一任务(解密)"""
|
||||||
try:
|
try:
|
||||||
cmd = [sys.executable, "--task", task]
|
rc = self._run_subprocess(task)
|
||||||
|
|
||||||
self._log(f">>> {' '.join(cmd)}\n\n")
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["PYTHONIOENCODING"] = "utf-8"
|
|
||||||
env["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR
|
|
||||||
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
cwd=BASE_DIR,
|
|
||||||
env=env,
|
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
for raw in proc.stdout:
|
|
||||||
line = raw.decode("utf-8", errors="replace")
|
|
||||||
self.after(0, self._log, line)
|
|
||||||
|
|
||||||
proc.wait()
|
|
||||||
rc = proc.returncode
|
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
self.after(0, self._log, "\n✅ 完成!\n")
|
self.after(0, self._log, "\n✅ 完成!\n")
|
||||||
self.after(0, self.status_var.set, "完成")
|
self.after(0, self.status_var.set, "完成")
|
||||||
|
if task == "decrypt":
|
||||||
|
self._auto_export = True
|
||||||
else:
|
else:
|
||||||
self.after(0, self._log, f"\n❌ 进程退出,返回码: {rc}\n")
|
self.after(0, self._log, f"\n❌ 进程退出,返回码: {rc}\n")
|
||||||
self.after(0, self.status_var.set, f"失败 (返回码 {rc})")
|
self.after(0, self.status_var.set, f"失败 (返回码 {rc})")
|
||||||
@@ -240,6 +653,10 @@ class App(tk.Tk):
|
|||||||
self._running = False
|
self._running = False
|
||||||
self.progress.stop()
|
self.progress.stop()
|
||||||
self._set_buttons(True)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
181
batch_decrypt_images.py
Normal file
181
batch_decrypt_images.py
Normal 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()
|
||||||
@@ -29,6 +29,8 @@ pyinstaller --noconfirm --onefile --console --name "WeChatDecrypt" ^
|
|||||||
--add-data "decode_image.py;." ^
|
--add-data "decode_image.py;." ^
|
||||||
--add-data "find_image_key.py;." ^
|
--add-data "find_image_key.py;." ^
|
||||||
--add-data "find_image_key_monitor.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.py;." ^
|
||||||
--add-data "monitor_web.py;." ^
|
--add-data "monitor_web.py;." ^
|
||||||
--add-data "mcp_server.py;." ^
|
--add-data "mcp_server.py;." ^
|
||||||
|
|||||||
26
config.py
26
config.py
@@ -229,8 +229,34 @@ def load_config():
|
|||||||
else:
|
else:
|
||||||
cfg["wechat_base_dir"] = db_dir
|
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 默认值
|
# decoded_image_dir 默认值
|
||||||
if "decoded_image_dir" not in cfg:
|
if "decoded_image_dir" not in cfg:
|
||||||
cfg["decoded_image_dir"] = os.path.join(base, "decoded_images")
|
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
|
return cfg
|
||||||
|
|||||||
272
decrypt_sns.py
Normal file
272
decrypt_sns.py
Normal 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()
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"""导出微信消息记录到 CSV / HTML / JSON
|
"""导出微信消息记录到 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 sqlite3
|
||||||
import glob
|
import glob
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -8,6 +10,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
import csv
|
import csv
|
||||||
import re
|
import re
|
||||||
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -23,7 +26,310 @@ from config import load_config
|
|||||||
_cfg = load_config()
|
_cfg = load_config()
|
||||||
MSG_DB_DIR = os.path.join(_cfg["decrypted_dir"], "message")
|
MSG_DB_DIR = os.path.join(_cfg["decrypted_dir"], "message")
|
||||||
CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db")
|
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 = {
|
MSG_TYPES = {
|
||||||
1: "文本",
|
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}}
|
.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}}
|
.received .bubble{{background:#fff;border-radius:0 6px 6px 6px}}
|
||||||
.sent .bubble{{background:#95EC69;border-radius:6px 0 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}}
|
.type-tag{{font-size:11px;color:#aaa;margin-top:2px}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -142,7 +449,7 @@ body{{background:#ededed;font-family:"PingFang SC","Helvetica Neue",Arial,sans-s
|
|||||||
def _html_escape(s: str) -> str:
|
def _html_escape(s: str) -> str:
|
||||||
return s.replace("&","&").replace("<","<").replace(">",">").replace('"','"')
|
return s.replace("&","&").replace("<","<").replace(">",">").replace('"','"')
|
||||||
|
|
||||||
def _write_html(path: str, title: str, is_group: bool, messages: list):
|
def _write_html(path: str, title: str, is_group: bool, messages: list, image_map: dict = None, out_dir: str = None):
|
||||||
parts = []
|
parts = []
|
||||||
last_date = None
|
last_date = None
|
||||||
for m in messages:
|
for m in messages:
|
||||||
@@ -169,12 +476,33 @@ def _write_html(path: str, title: str, is_group: bool, messages: list):
|
|||||||
if m["type"] != 1:
|
if m["type"] != 1:
|
||||||
type_tag = f'<div class="type-tag">{m["type_name"]}</div>'
|
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(
|
parts.append(
|
||||||
f'<div class="msg {side}">'
|
f'<div class="msg {side}">'
|
||||||
f'<div class="avatar">{initial}</div>'
|
f'<div class="avatar">{initial}</div>'
|
||||||
f'<div class="msg-body">'
|
f'<div class="msg-body">'
|
||||||
f'{sender_label}'
|
f'{sender_label}'
|
||||||
f'<div class="bubble">{_html_escape(m["display_content"])}</div>'
|
f'<div class="bubble">{bubble_content}</div>'
|
||||||
f'{type_tag}'
|
f'{type_tag}'
|
||||||
f'<div class="type-tag">{m["time_str"]}</div>'
|
f'<div class="type-tag">{m["time_str"]}</div>'
|
||||||
f'</div></div>'
|
f'</div></div>'
|
||||||
@@ -217,6 +545,10 @@ print(f"找到 {len(db_files)} 个消息数据库")
|
|||||||
total_chats = 0
|
total_chats = 0
|
||||||
total_msgs = 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):
|
for db_path in sorted(db_files):
|
||||||
db_name = os.path.basename(db_path)
|
db_name = os.path.basename(db_path)
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
@@ -244,6 +576,8 @@ for db_path in sorted(db_files):
|
|||||||
for table_name in all_tables:
|
for table_name in all_tables:
|
||||||
h = table_name[4:] # strip "Msg_"
|
h = table_name[4:] # strip "Msg_"
|
||||||
chat_username = hash_to_username.get(h, f"unknown_{h[:8]}")
|
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))
|
dname = safe_dirname(display_name(chat_username))
|
||||||
is_group = chat_username.endswith("@chatroom") or chat_username.endswith("@openim")
|
is_group = chat_username.endswith("@chatroom") or chat_username.endswith("@openim")
|
||||||
|
|
||||||
@@ -287,58 +621,93 @@ for db_path in sorted(db_files):
|
|||||||
"content": content,
|
"content": content,
|
||||||
"display_content": display_content,
|
"display_content": display_content,
|
||||||
"is_system": is_system,
|
"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,
|
"is_received": (sender_uname == chat_username) if not is_group else True,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ── 输出目录 ──────────────────────────────────────────────────────────
|
if chat_username not in chat_data:
|
||||||
out_dir = os.path.join(OUTPUT_DIR, dname)
|
chat_data[chat_username] = {
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
"dname": dname, "is_group": is_group, "db_messages": []
|
||||||
|
}
|
||||||
|
chat_data[chat_username]["db_messages"].append((db_name, messages))
|
||||||
|
|
||||||
# ── .info 文件 ────────────────────────────────────────────────────────
|
conn.close()
|
||||||
info_path = os.path.join(out_dir, ".info")
|
|
||||||
if not os.path.exists(info_path):
|
|
||||||
info = contact_map.get(chat_username, {
|
|
||||||
"username": chat_username, "alias": "", "remark": "", "nick_name": ""
|
|
||||||
})
|
|
||||||
with open(info_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(f"username: {info['username']}\n")
|
|
||||||
f.write(f"alias: {info['alias']}\n")
|
|
||||||
f.write(f"nick_name: {info['nick_name']}\n")
|
|
||||||
f.write(f"remark: {info['remark']}\n")
|
|
||||||
f.write(f"is_group: {is_group}\n")
|
|
||||||
|
|
||||||
# ── CSV ───────────────────────────────────────────────────────────────
|
# ── 阶段2: 每个联系人解密图片一次,再写出文件 ────────────────────────────────
|
||||||
csv_path = os.path.join(out_dir, f"{db_name}.csv")
|
total_chats = 0
|
||||||
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
|
total_msgs = 0
|
||||||
w = csv.writer(f)
|
|
||||||
w.writerow(["时间", "发送者", "消息类型", "内容", "server_id"])
|
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:
|
for m in messages:
|
||||||
w.writerow([
|
if m["type"] != 3:
|
||||||
m["time_str"], m["sender"], m["type_name"],
|
continue
|
||||||
m["display_content"], m["server_id"]
|
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 ──────────────────────────────────────────────────────────────
|
# ── CSV ───────────────────────────────────────────────────────────
|
||||||
json_path = os.path.join(out_dir, f"{db_name}.json")
|
if not _EXPORT_FORMATS or "csv" in _EXPORT_FORMATS:
|
||||||
with open(json_path, "w", encoding="utf-8") as f:
|
csv_path = os.path.join(out_dir, f"{db_name}.csv")
|
||||||
json.dump({
|
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
|
||||||
"chat_username": chat_username,
|
w = csv.writer(f)
|
||||||
"display_name": dname,
|
w.writerow(["时间", "发送者", "消息类型", "内容", "图片路径", "server_id"])
|
||||||
"is_group": is_group,
|
for m in messages:
|
||||||
"message_count": len(messages),
|
img_path = image_map.get(m["local_id"], "") if m["type"] == 3 else ""
|
||||||
"messages": messages,
|
w.writerow([
|
||||||
}, f, ensure_ascii=False, indent=2)
|
m["time_str"], m["sender"], m["type_name"],
|
||||||
|
m["display_content"], img_path, m["server_id"]
|
||||||
|
])
|
||||||
|
|
||||||
# ── HTML ──────────────────────────────────────────────────────────────
|
# ── JSON ──────────────────────────────────────────────────────────
|
||||||
html_path = os.path.join(out_dir, f"{db_name}.html")
|
if not _EXPORT_FORMATS or "json" in _EXPORT_FORMATS:
|
||||||
_write_html(html_path, dname, is_group, messages)
|
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_chats += 1
|
||||||
total_msgs += len(messages)
|
total_msgs += len(messages)
|
||||||
print(f" [{db_name}] {dname}: {len(messages)} 条消息")
|
print(f" [{db_name}] {dname}: {len(messages)} 条消息")
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
print(f"\n完成: {total_chats} 个会话, 共 {total_msgs} 条消息")
|
print(f"\n完成: {total_chats} 个会话, 共 {total_msgs} 条消息")
|
||||||
print(f"输出目录: {os.path.abspath(OUTPUT_DIR)}")
|
print(f"输出目录: {os.path.abspath(OUTPUT_DIR)}")
|
||||||
|
|||||||
820
export_sns.py
Normal file
820
export_sns.py
Normal 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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_timeline_html(display_name, posts, sns_dir, image_files):
|
||||||
|
"""生成朋友圈时间线 HTML
|
||||||
|
|
||||||
|
Args:
|
||||||
|
display_name: 联系人显示名
|
||||||
|
posts: 按时间倒序排列的动态列表
|
||||||
|
sns_dir: SNS 输出目录
|
||||||
|
image_files: {final_name: [(rel_path, ext), ...]} 每条动态的图片文件列表
|
||||||
|
"""
|
||||||
|
html_path = os.path.join(sns_dir, "timeline.html")
|
||||||
|
parts = [f"""<!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()
|
||||||
@@ -333,9 +333,15 @@ def verify_and_decrypt(attach_dir, aes_key_str, xor_key):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
|
from config import _config_file_path, load_config
|
||||||
with open(config_path, encoding="utf-8") as f:
|
config_path = _config_file_path()
|
||||||
config = json.load(f)
|
# 加载完整配置用于逻辑,读取原始 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']
|
db_dir = config['db_dir']
|
||||||
base_dir = os.path.dirname(db_dir)
|
base_dir = os.path.dirname(db_dir)
|
||||||
@@ -392,8 +398,11 @@ def main():
|
|||||||
config['image_aes_key'] = aes_key
|
config['image_aes_key'] = aes_key
|
||||||
if xor_key is not None:
|
if xor_key is not None:
|
||||||
config['image_xor_key'] = xor_key
|
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:
|
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(f"Saved to {config_path}", flush=True)
|
||||||
|
|
||||||
print("\n=== Verify decrypt ===", flush=True)
|
print("\n=== Verify decrypt ===", flush=True)
|
||||||
|
|||||||
@@ -226,9 +226,14 @@ def verify_and_decrypt(attach_dir, aes_key_str, xor_key):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
|
from config import _config_file_path, load_config
|
||||||
with open(config_path, encoding="utf-8") as f:
|
config_path = _config_file_path()
|
||||||
config = json.load(f)
|
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']
|
db_dir = config['db_dir']
|
||||||
base_dir = os.path.dirname(db_dir)
|
base_dir = os.path.dirname(db_dir)
|
||||||
@@ -292,8 +297,11 @@ def main():
|
|||||||
config['image_aes_key'] = aes_key
|
config['image_aes_key'] = aes_key
|
||||||
if xor_key is not None:
|
if xor_key is not None:
|
||||||
config['image_xor_key'] = xor_key
|
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:
|
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(f"Saved to {config_path}", flush=True)
|
||||||
|
|
||||||
verify_and_decrypt(attach_dir, aes_key, xor_key)
|
verify_and_decrypt(attach_dir, aes_key, xor_key)
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ from config import load_config
|
|||||||
_cfg = load_config()
|
_cfg = load_config()
|
||||||
DB_PATH = os.path.join(_cfg["decrypted_dir"], "message", "media_0.db")
|
DB_PATH = os.path.join(_cfg["decrypted_dir"], "message", "media_0.db")
|
||||||
CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.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):
|
def silk_to_mp3(voice_data, output_path):
|
||||||
"""将微信 SILK 语音数据转换为 MP3"""
|
"""将微信 SILK 语音数据转换为 MP3"""
|
||||||
@@ -92,15 +98,17 @@ success = 0
|
|||||||
fail = 0
|
fail = 0
|
||||||
for chat_name_id, create_time, local_id, voice_data in rows:
|
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}")
|
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))
|
dname = safe_dirname(display_name(user_name))
|
||||||
dt = datetime.fromtimestamp(create_time)
|
dt = datetime.fromtimestamp(create_time)
|
||||||
filename = dt.strftime("%Y%m%d_%H%M%S") + f"_{local_id}.mp3"
|
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)
|
os.makedirs(user_dir, exist_ok=True)
|
||||||
|
|
||||||
# 写入 .info 文件(只写一次)
|
# 写入 .info 文件(只写一次,写到联系人根目录)
|
||||||
info_path = os.path.join(user_dir, ".info")
|
info_path = os.path.join(OUTPUT_DIR, dname, ".info")
|
||||||
if not os.path.exists(info_path):
|
if not os.path.exists(info_path):
|
||||||
info = contact_map.get(user_name, {"username": user_name, "alias": "", "remark": "", "nick_name": ""})
|
info = contact_map.get(user_name, {"username": user_name, "alias": "", "remark": "", "nick_name": ""})
|
||||||
with open(info_path, "w", encoding="utf-8") as f:
|
with open(info_path, "w", encoding="utf-8") as f:
|
||||||
|
|||||||
Reference in New Issue
Block a user