Add SNS/image export and GUI contact export

Introduce SNS (朋友圈) and batch image tooling and enhance the GUI export workflow. Added new scripts: decrypt_sns.py (decrypt WeChat SNS cache) and batch_decrypt_images.py (bulk .dat image decrypt). Update build scripts and PyInstaller spec to include the new files. app_gui.py: add contact discovery, contact selection/export options dialog, new buttons (find image key, SNS), orchestrate combined export/voice/SNS tasks via subprocesses and env flags, and auto-run export after decryption. config.py: add output_base_dir, auto-detect WeChat Files path, and expose msgattach/xwechat cache dirs. export_messages.py: switch to output_base_dir, add image resource lookup and .dat locating/decryption helpers, and support environment-driven contact/format/image filters. Misc: update build.bat and packaging datas to include the new modules.

Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
xincheng
2026-04-06 23:57:51 +08:00
parent ebbea8c895
commit b9f3161f84
11 changed files with 2207 additions and 95 deletions

View File

@@ -1,6 +1,8 @@
"""导出微信消息记录到 CSV / HTML / JSON
目录结构: <wechat_base_dir>/export/<display_name>/messages.csv|html|json
目录结构: <output_base_dir>/<display_name>/messages.csv|html|json
图片导出: <output_base_dir>/<display_name>/image/<md5>.<ext>
"""
import base64
import sqlite3
import glob
import hashlib
@@ -8,6 +10,7 @@ import os
import json
import csv
import re
import struct
import sys
import xml.etree.ElementTree as ET
from datetime import datetime
@@ -23,7 +26,310 @@ from config import load_config
_cfg = load_config()
MSG_DB_DIR = os.path.join(_cfg["decrypted_dir"], "message")
CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db")
OUTPUT_DIR = os.path.join(_cfg["wechat_base_dir"], "export")
OUTPUT_DIR = _cfg["output_base_dir"]
# 图片相关配置
WECHAT_BASE_DIR = _cfg.get("wechat_base_dir", "")
ATTACH_DIR = os.path.join(WECHAT_BASE_DIR, "msg", "attach") if WECHAT_BASE_DIR else ""
MSGATTACH_DIR = _cfg.get("msgattach_dir", "") # WeChat Files/FileStorage/MsgAttach
IMAGE_AES_KEY = _cfg.get("image_aes_key")
IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88)
MSG_RESOURCE_DB = os.path.join(_cfg["decrypted_dir"], "message", "message_resource.db")
_CONTACT_FILTER = None
_filter_raw = os.environ.get("WECHAT_EXPORT_CONTACTS", "").strip()
if _filter_raw:
_CONTACT_FILTER = set(_filter_raw.split(","))
print(f"联系人筛选: {len(_CONTACT_FILTER)}")
_EXPORT_FORMATS = None
_formats_raw = os.environ.get("WECHAT_EXPORT_FORMATS", "").strip()
if _formats_raw:
_EXPORT_FORMATS = set(_formats_raw.lower().split(","))
print(f"导出格式: {', '.join(sorted(_EXPORT_FORMATS))}")
_EXPORT_IMAGES = os.environ.get("WECHAT_EXPORT_IMAGES", "1").strip() == "1"
# ─── 图片解密辅助 ───────────────────────────────────────────────────────────────
def _extract_md5_from_packed_info(blob):
"""从 message_resource.db 的 packed_info 中提取文件 MD5"""
if not blob or not isinstance(blob, bytes):
return None
marker = b'\x12\x22\x0a\x20'
idx = blob.find(marker)
if idx >= 0 and idx + len(marker) + 32 <= len(blob):
md5_bytes = blob[idx + len(marker): idx + len(marker) + 32]
try:
md5_str = md5_bytes.decode('ascii')
int(md5_str, 16)
return md5_str
except (UnicodeDecodeError, ValueError):
pass
hex_chars = set(b'0123456789abcdef')
i = 0
while i <= len(blob) - 32:
if blob[i] in hex_chars:
candidate = blob[i:i+32]
if all(b in hex_chars for b in candidate):
try:
return candidate.decode('ascii')
except UnicodeDecodeError:
pass
i += 32
else:
i += 1
return None
def _load_resource_md5_map():
"""加载 message_resource.db 的 (chat_username, local_id) -> file_md5 映射"""
md5_map = {}
if not os.path.exists(MSG_RESOURCE_DB):
return md5_map
try:
conn = sqlite3.connect(MSG_RESOURCE_DB)
# chat_id -> username
chat_id_map = {}
for row in conn.execute("SELECT rowid, user_name FROM ChatName2Id"):
chat_id_map[row[0]] = row[1]
for row in conn.execute(
"SELECT chat_id, message_local_id, packed_info FROM MessageResourceInfo"
):
cid, lid, blob = row
md5 = _extract_md5_from_packed_info(blob)
if md5:
uname = chat_id_map.get(cid, "")
if uname:
md5_map[(uname, lid)] = md5
conn.close()
print(f"图片资源映射: {len(md5_map)}")
except Exception as e:
print(f"读取 message_resource.db 失败: {e}")
return md5_map
def _find_dat_file(username_hash, file_md5):
"""在 attach / MsgAttach 目录下查找 .dat 文件,优先高清版"""
search_patterns = []
# xwechat_files 的 msg/attach 目录: <hash>/<YYYY-MM>/Img/<md5>*.dat
if ATTACH_DIR and os.path.isdir(ATTACH_DIR):
search_base = os.path.join(ATTACH_DIR, username_hash)
if os.path.isdir(search_base):
search_patterns.append(os.path.join(search_base, "*", "Img", f"{file_md5}*.dat"))
# WeChat Files 的 MsgAttach 目录: <hash>/Image/<YYYY-MM>/<md5>*.dat
if MSGATTACH_DIR and os.path.isdir(MSGATTACH_DIR):
search_base = os.path.join(MSGATTACH_DIR, username_hash)
if os.path.isdir(search_base):
search_patterns.append(os.path.join(search_base, "Image", "*", f"{file_md5}*.dat"))
files = []
for pat in search_patterns:
files.extend(glob.glob(pat))
if not files:
return None
# 优先: 无后缀(原图) > _W(原图) > _h(高清) > _t/_t_W(缩略图)
# 先过滤掉缩略图
non_thumb = [f for f in files if '_t.' not in os.path.basename(f) and '_t_' not in os.path.basename(f)]
candidates = non_thumb if non_thumb else files
selected = candidates[0]
for f in candidates:
fname = os.path.basename(f)
# 精确匹配原图(无后缀)
if fname == f"{file_md5}.dat":
return f
for f in candidates:
fname = os.path.basename(f)
if fname == f"{file_md5}_W.dat":
return f
for f in candidates:
if '_h.' in os.path.basename(f) or '_h_' in os.path.basename(f):
return f
return selected
def _detect_image_format(header):
"""根据解密后的文件头检测图片格式"""
if header[:3] == bytes([0xFF, 0xD8, 0xFF]):
return 'jpg'
if header[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
return 'png'
if header[:3] == b'GIF':
return 'gif'
if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WEBP':
return 'webp'
if header[:4] == b'wxgf':
return 'hevc'
return 'bin'
# V2 格式常量
_V2_MAGIC_FULL = b'\x07\x08V2\x08\x07'
_V1_MAGIC_FULL = b'\x07\x08V1\x08\x07'
_IMAGE_MAGICS = {
'jpg': [0xFF, 0xD8, 0xFF],
'png': [0x89, 0x50, 0x4E, 0x47],
'gif': [0x47, 0x49, 0x46, 0x38],
'webp': [0x52, 0x49, 0x46, 0x46],
}
def _decrypt_dat_to_bytes(dat_path):
"""解密 .dat 文件,返回 (bytes, format) 或 (None, None)"""
with open(dat_path, 'rb') as f:
data = f.read()
if len(data) < 15:
return None, None
head6 = data[:6]
# V2 / V1 格式
if head6 in (_V2_MAGIC_FULL, _V1_MAGIC_FULL):
aes_key = None
if head6 == _V1_MAGIC_FULL:
aes_key = b'cfcd208495d565ef'
elif IMAGE_AES_KEY:
aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16]
if not aes_key or len(aes_key) < 16:
return None, None
try:
from Crypto.Cipher import AES as _AES
from Crypto.Util import Padding
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
aligned = aes_size - ~(~aes_size % 16)
offset = 15
if offset + aligned > len(data):
return None, None
cipher = _AES.new(aes_key[:16], _AES.MODE_ECB)
dec_aes = Padding.unpad(cipher.decrypt(data[offset:offset+aligned]), _AES.block_size)
offset += aligned
raw_end = len(data) - xor_size
raw_data = data[offset:raw_end] if offset < raw_end else b''
xor_data = data[raw_end:]
xor_key = IMAGE_XOR_KEY if isinstance(IMAGE_XOR_KEY, int) else 0x88
dec_xor = bytes(b ^ xor_key for b in xor_data)
result = dec_aes + raw_data + dec_xor
fmt = _detect_image_format(result[:16])
return result, fmt
except Exception:
return None, None
# 旧 XOR 格式
for fmt_name, magic in _IMAGE_MAGICS.items():
key = data[0] ^ magic[0]
match = all(i < len(data) and (data[i] ^ key) == magic[i] for i in range(len(magic)))
if match:
result = bytes(b ^ key for b in data)
fmt = _detect_image_format(result[:16])
return result, fmt
return None, None
_resource_md5_map = _load_resource_md5_map() if _EXPORT_IMAGES else {}
def decode_chat_images(chat_username, _messages_unused, out_dir):
"""直接扫描 attach 目录下该联系人的全部图片并解密
按月份分目录输出到 out_dir/image/<YYYY-MM>/
跳过 _t 缩略图,优先 _h 高清版
返回 {file_md5: relative_path} 用于 HTML 嵌入
"""
image_map = {}
username_hash = hashlib.md5(chat_username.encode()).hexdigest()
# 收集所有来源目录: [(base_path, sub_structure), ...]
# xwechat: attach/<hash>/<YYYY-MM>/Img/<md5>*.dat
# WeChat Files: MsgAttach/<hash>/Image/<YYYY-MM>/<md5>*.dat
source_dirs = []
if ATTACH_DIR:
p = os.path.join(ATTACH_DIR, username_hash)
if os.path.isdir(p):
source_dirs.append(("xwechat", p))
if MSGATTACH_DIR:
p = os.path.join(MSGATTACH_DIR, username_hash)
if os.path.isdir(p):
source_dirs.append(("wechat", p))
if not source_dirs:
return image_map
# 收集所有 dat 文件: {base_md5: (best_path, month)}
# 优先级: _h > 无后缀 > _W > 其他(跳过 _t
file_candidates = {} # base_md5 -> (priority, dat_path, month)
def _priority(fname):
"""返回优先级数字,越小越好"""
base = fname.rsplit('.', 1)[0]
if base.endswith('_h'):
return 0 # 高清
if '_' not in base[-3:]:
return 1 # 无后缀原图
if base.endswith('_W'):
return 2
return 9 # 其他
for src_type, base_path in source_dirs:
# xwechat: <hash>/<YYYY-MM>/Img/ — 直接列 base_path 得到月份
# wechat: <hash>/Image/<YYYY-MM>/ — 需要列 base_path/Image 得到月份
if src_type == "xwechat":
scan_base = base_path
else:
scan_base = os.path.join(base_path, "Image")
try:
months = sorted(os.listdir(scan_base))
except OSError:
continue
for month in months:
if src_type == "xwechat":
img_dir = os.path.join(base_path, month, "Img")
else:
img_dir = os.path.join(scan_base, month)
if not os.path.isdir(img_dir):
continue
try:
files = os.listdir(img_dir)
except OSError:
continue
for fname in files:
if not fname.endswith('.dat'):
continue
# 跳过缩略图 _t.dat 和 _t_W.dat
base_no_ext = fname.rsplit('.', 1)[0]
if '_t' in base_no_ext.split('_'):
continue
if base_no_ext.endswith('_t') or '_t_' in base_no_ext:
continue
# 提取 base md5
base_md5 = base_no_ext.split('_')[0]
pri = _priority(fname)
existing = file_candidates.get(base_md5)
if not existing or pri < existing[0]:
file_candidates[base_md5] = (pri, os.path.join(img_dir, fname), month)
if not file_candidates:
return image_map
decoded_count = 0
for base_md5, (pri, dat_path, month) in file_candidates.items():
month_dir = os.path.join(out_dir, "image", month)
# 检查是否已解密
existing = glob.glob(os.path.join(month_dir, f"{base_md5}.*"))
if existing:
rel = os.path.relpath(existing[0], out_dir).replace("\\", "/")
image_map[base_md5] = rel
continue
img_bytes, fmt = _decrypt_dat_to_bytes(dat_path)
if not img_bytes or fmt == 'bin':
continue
os.makedirs(month_dir, exist_ok=True)
out_path = os.path.join(month_dir, f"{base_md5}.{fmt}")
with open(out_path, 'wb') as f:
f.write(img_bytes)
image_map[base_md5] = f"image/{month}/{base_md5}.{fmt}"
decoded_count += 1
return image_map
MSG_TYPES = {
1: "文本",
@@ -127,6 +433,7 @@ body{{background:#ededed;font-family:"PingFang SC","Helvetica Neue",Arial,sans-s
.bubble{{display:inline-block;padding:8px 12px;border-radius:6px;word-break:break-word;line-height:1.5;box-shadow:0 1px 2px rgba(0,0,0,.1);white-space:pre-wrap}}
.received .bubble{{background:#fff;border-radius:0 6px 6px 6px}}
.sent .bubble{{background:#95EC69;border-radius:6px 0 6px 6px}}
.bubble img{{max-width:100%;border-radius:4px;display:block;margin:2px 0}}
.type-tag{{font-size:11px;color:#aaa;margin-top:2px}}
</style>
</head>
@@ -142,7 +449,7 @@ body{{background:#ededed;font-family:"PingFang SC","Helvetica Neue",Arial,sans-s
def _html_escape(s: str) -> str:
return s.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace('"','&quot;')
def _write_html(path: str, title: str, is_group: bool, messages: list):
def _write_html(path: str, title: str, is_group: bool, messages: list, image_map: dict = None, out_dir: str = None):
parts = []
last_date = None
for m in messages:
@@ -169,12 +476,33 @@ def _write_html(path: str, title: str, is_group: bool, messages: list):
if m["type"] != 1:
type_tag = f'<div class="type-tag">{m["type_name"]}</div>'
# 图片消息嵌入
bubble_content = _html_escape(m["display_content"])
if m["type"] == 3 and image_map and m["local_id"] in image_map:
rel_path = image_map[m["local_id"]]
if out_dir:
abs_img = os.path.join(out_dir, rel_path)
if os.path.exists(abs_img):
ext = os.path.splitext(abs_img)[1].lstrip('.').lower()
mime = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
'gif': 'image/gif', 'webp': 'image/webp'}.get(ext, 'image/jpeg')
try:
with open(abs_img, 'rb') as imgf:
b64 = base64.b64encode(imgf.read()).decode('ascii')
bubble_content = f'<img src=\"data:{mime};base64,{b64}\" alt=\"图片\">'
except Exception:
bubble_content = f'<img src=\"{_html_escape(rel_path)}\" alt=\"图片\">'
else:
bubble_content = f'<img src=\"{_html_escape(rel_path)}\" alt=\"图片\">'
else:
bubble_content = f'<img src=\"{_html_escape(rel_path)}\" alt=\"图片\">'
parts.append(
f'<div class="msg {side}">'
f'<div class="avatar">{initial}</div>'
f'<div class="msg-body">'
f'{sender_label}'
f'<div class="bubble">{_html_escape(m["display_content"])}</div>'
f'<div class="bubble">{bubble_content}</div>'
f'{type_tag}'
f'<div class="type-tag">{m["time_str"]}</div>'
f'</div></div>'
@@ -217,6 +545,10 @@ print(f"找到 {len(db_files)} 个消息数据库")
total_chats = 0
total_msgs = 0
# ── 阶段1: 收集所有联系人的消息 ──────────────────────────────────────────────
# chat_data[chat_username] -> { dname, is_group, db_messages: [(db_name, messages)] }
chat_data: dict[str, dict] = {}
for db_path in sorted(db_files):
db_name = os.path.basename(db_path)
conn = sqlite3.connect(db_path)
@@ -244,6 +576,8 @@ for db_path in sorted(db_files):
for table_name in all_tables:
h = table_name[4:] # strip "Msg_"
chat_username = hash_to_username.get(h, f"unknown_{h[:8]}")
if _CONTACT_FILTER and chat_username not in _CONTACT_FILTER:
continue
dname = safe_dirname(display_name(chat_username))
is_group = chat_username.endswith("@chatroom") or chat_username.endswith("@openim")
@@ -287,58 +621,93 @@ for db_path in sorted(db_files):
"content": content,
"display_content": display_content,
"is_system": is_system,
# 1-on-1: sender==chat_partner -> received(left), else sent(right)
"is_received": (sender_uname == chat_username) if not is_group else True,
})
# ── 输出目录 ──────────────────────────────────────────────────────────
out_dir = os.path.join(OUTPUT_DIR, dname)
os.makedirs(out_dir, exist_ok=True)
if chat_username not in chat_data:
chat_data[chat_username] = {
"dname": dname, "is_group": is_group, "db_messages": []
}
chat_data[chat_username]["db_messages"].append((db_name, messages))
# ── .info 文件 ────────────────────────────────────────────────────────
info_path = os.path.join(out_dir, ".info")
if not os.path.exists(info_path):
info = contact_map.get(chat_username, {
"username": chat_username, "alias": "", "remark": "", "nick_name": ""
})
with open(info_path, "w", encoding="utf-8") as f:
f.write(f"username: {info['username']}\n")
f.write(f"alias: {info['alias']}\n")
f.write(f"nick_name: {info['nick_name']}\n")
f.write(f"remark: {info['remark']}\n")
f.write(f"is_group: {is_group}\n")
conn.close()
# ── CSV ───────────────────────────────────────────────────────────────
csv_path = os.path.join(out_dir, f"{db_name}.csv")
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
w.writerow(["时间", "发送者", "消息类型", "内容", "server_id"])
# ── 阶段2: 每个联系人解密图片一次,再写出文件 ────────────────────────────────
total_chats = 0
total_msgs = 0
for chat_username, cdata in chat_data.items():
dname = cdata["dname"]
is_group = cdata["is_group"]
out_dir = os.path.join(OUTPUT_DIR, dname)
os.makedirs(out_dir, exist_ok=True)
# ── .info 文件 ────────────────────────────────────────────────────────
info_path = os.path.join(out_dir, ".info")
if not os.path.exists(info_path):
info = contact_map.get(chat_username, {
"username": chat_username, "alias": "", "remark": "", "nick_name": ""
})
with open(info_path, "w", encoding="utf-8") as f:
f.write(f"username: {info['username']}\n")
f.write(f"alias: {info['alias']}\n")
f.write(f"nick_name: {info['nick_name']}\n")
f.write(f"remark: {info['remark']}\n")
f.write(f"is_group: {is_group}\n")
# ── 解密图片(每个联系人只执行一次)────────────────────────────────────
image_md5_map = {}
if _EXPORT_IMAGES:
image_md5_map = decode_chat_images(chat_username, None, out_dir)
if image_md5_map:
print(f" 图片解密: {len(image_md5_map)} 张 ({dname})")
# ── 按 DB 写出消息文件 ────────────────────────────────────────────────
for db_name, messages in cdata["db_messages"]:
# 建立 local_id -> rel_path 映射
image_map = {}
if image_md5_map:
for m in messages:
w.writerow([
m["time_str"], m["sender"], m["type_name"],
m["display_content"], m["server_id"]
])
if m["type"] != 3:
continue
lid = m["local_id"]
file_md5 = _resource_md5_map.get((chat_username, lid))
if file_md5 and file_md5 in image_md5_map:
image_map[lid] = image_md5_map[file_md5]
# ── JSON ──────────────────────────────────────────────────────────────
json_path = os.path.join(out_dir, f"{db_name}.json")
with open(json_path, "w", encoding="utf-8") as f:
json.dump({
"chat_username": chat_username,
"display_name": dname,
"is_group": is_group,
"message_count": len(messages),
"messages": messages,
}, f, ensure_ascii=False, indent=2)
# ── CSV ───────────────────────────────────────────────────────────
if not _EXPORT_FORMATS or "csv" in _EXPORT_FORMATS:
csv_path = os.path.join(out_dir, f"{db_name}.csv")
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
w.writerow(["时间", "发送者", "消息类型", "内容", "图片路径", "server_id"])
for m in messages:
img_path = image_map.get(m["local_id"], "") if m["type"] == 3 else ""
w.writerow([
m["time_str"], m["sender"], m["type_name"],
m["display_content"], img_path, m["server_id"]
])
# ── HTML ──────────────────────────────────────────────────────────────
html_path = os.path.join(out_dir, f"{db_name}.html")
_write_html(html_path, dname, is_group, messages)
# ── JSON ──────────────────────────────────────────────────────────
if not _EXPORT_FORMATS or "json" in _EXPORT_FORMATS:
json_path = os.path.join(out_dir, f"{db_name}.json")
with open(json_path, "w", encoding="utf-8") as f:
json.dump({
"chat_username": chat_username,
"display_name": dname,
"is_group": is_group,
"message_count": len(messages),
"messages": messages,
}, f, ensure_ascii=False, indent=2)
# ── HTML ──────────────────────────────────────────────────────────
if not _EXPORT_FORMATS or "html" in _EXPORT_FORMATS:
html_path = os.path.join(out_dir, f"{db_name}.html")
_write_html(html_path, dname, is_group, messages, image_map=image_map, out_dir=out_dir)
total_chats += 1
total_msgs += len(messages)
print(f" [{db_name}] {dname}: {len(messages)} 条消息")
conn.close()
print(f"\n完成: {total_chats} 个会话, 共 {total_msgs} 条消息")
print(f"输出目录: {os.path.abspath(OUTPUT_DIR)}")