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,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("&","&").replace("<","<").replace(">",">").replace('"','"')
|
||||
|
||||
def _write_html(path: str, title: str, is_group: bool, messages: list):
|
||||
def _write_html(path: str, title: str, is_group: bool, messages: list, image_map: dict = None, out_dir: str = None):
|
||||
parts = []
|
||||
last_date = None
|
||||
for m in messages:
|
||||
@@ -169,12 +476,33 @@ def _write_html(path: str, title: str, is_group: bool, messages: list):
|
||||
if m["type"] != 1:
|
||||
type_tag = f'<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)}")
|
||||
|
||||
Reference in New Issue
Block a user