feat(export): add incremental mode, date range filter, and dry-run

Three new flags for export_all_chats.py:

- -i / --incremental: reads existing JSON, appends only new messages
  (deduplicates by local_id, preserves transcription field on merge)
- --start / --end: filter messages by date range (YYYY-MM-DD or timestamp)
  passes start_ts/end_ts directly to mcp_server._query_messages
- --dry-run: preview mode (shows counts without writing files)

Voice transcription in incremental mode only processes newly appended
voice messages — existing transcribed entries are untouched.
This commit is contained in:
Davy
2026-05-12 18:42:29 -07:00
committed by ylytdeng
parent e20682c3dd
commit de4cb092d9
2 changed files with 263 additions and 65 deletions

View File

@@ -9,6 +9,7 @@ import hashlib, struct, os, sys, json
import hmac as hmac_mod
from Crypto.Cipher import AES
import argparse
import functools
print = functools.partial(print, flush=True)
@@ -106,6 +107,21 @@ def decrypt_database(db_path, out_path, enc_key):
def main():
parser = argparse.ArgumentParser(
description="WeChat 4.0 数据库解密器"
)
parser.add_argument(
"-i", "--incremental",
action="store_true",
help="增量模式:仅当源 .db 更新于已解密文件时才重新解密",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="预览模式:显示将要解密的数据库列表",
)
args = parser.parse_args()
print("=" * 60)
print(" WeChat 4.0 数据库解密器")
print("=" * 60)
@@ -116,12 +132,15 @@ def main():
print("请先运行 python main.py decrypt 提取密钥并解密")
sys.exit(1)
with open(KEYS_FILE, encoding="utf-8") as f:
keys = json.load(f)
keys = strip_key_metadata(keys)
print(f"\n加载 {len(keys)} 个数据库密钥")
print(f"输出目录: {OUT_DIR}")
if args.incremental:
print(f"模式: 增量 (跳过未变更的数据库)")
os.makedirs(OUT_DIR, exist_ok=True)
# 收集所有DB文件
@@ -141,6 +160,7 @@ def main():
success = 0
failed = 0
skipped = 0
skipped_unmodified = 0
total_bytes = 0
for rel, path, sz in db_files:
@@ -150,11 +170,31 @@ def main():
skipped += 1
continue
enc_key = bytes.fromhex(key_info["enc_key"])
out_path = os.path.join(OUT_DIR, rel)
# 增量模式:检查 mtime
if args.incremental and os.path.exists(out_path):
src_mtime = os.path.getmtime(path)
dst_mtime = os.path.getmtime(out_path)
if src_mtime <= dst_mtime:
skipped_unmodified += 1
if args.dry_run:
print(f"SKIP: {rel} (未修改)")
continue
elif args.dry_run:
print(f"NEW: {rel} (源较新)")
elif not args.dry_run:
print(f"更新: {rel} ({sz/1024/1024:.1f}MB) ...", end=" ")
elif args.dry_run:
print(f"NEW: {rel} ({sz/1024/1024:.1f}MB)")
else:
print(f"解密: {rel} ({sz/1024/1024:.1f}MB) ...", end=" ")
if args.dry_run:
skipped_unmodified += 1
continue
enc_key = bytes.fromhex(key_info["enc_key"])
ok = decrypt_database(path, out_path, enc_key)
if ok:
# SQLite验证
@@ -186,8 +226,14 @@ def main():
except OSError:
pass
if args.dry_run:
print(f"\n{'='*60}")
print(f"结果: {success} 成功, {failed} 失败, {skipped} 跳过(无密钥), 共 {len(db_files)}")
print(f"预览: 需要解密 {skipped_unmodified}数据库")
return
print(f"\n{'='*60}")
inc_note = f" (跳过 {skipped_unmodified} 个未变更)" if skipped_unmodified else ""
print(f"结果: {success} 成功, {failed} 失败, {skipped} 跳过(无密钥){inc_note}, 共 {len(db_files)}")
print(f"解密数据量: {total_bytes/1024/1024/1024:.1f}GB")
print(f"解密文件在: {OUT_DIR}")

View File

@@ -9,13 +9,12 @@ transcription_backend 为 whisper_cpp / openai / local。未启用 backend
或缺少依赖时仅导出文本消息,不报错。
用法:
python3 export_all_chats.py [output_dir] # 仅导出
python3 export_all_chats.py --with-transcriptions # 导出 + 语音转录
示例:
python3 export_all_chats.py /path/to/output
python3 export_all_chats.py --with-transcriptions
python3 export_all_chats.py --with-transcriptions /path/to/output
python3 export_all_chats.py # 全量导出所有会话
python3 export_all_chats.py --with-transcriptions # 全量导出 + 转录语音
python3 export_all_chats.py -i # 增量(只导出最新消息)
python3 export_all_chats.py --start 2025-01-01 # 按日期范围
python3 export_all_chats.py --end 2025-01-31
python3 export_all_chats.py --start 2025-01-01 --end 2025-01-31 -t
"""
import argparse
@@ -39,42 +38,126 @@ except ImportError:
from chat_export_helpers import _extract_content, _msg_type_str, _resolve_sender
def export_one(username, output_dir, names, transcribe=False):
def _parse_timestamp(ts_str):
"""解析时间字符串返回 unix timestamp。
支持格式: '2025-01-01', '2025-01-01 14:30', '2025-01-01T14:30:00'
"""
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
try:
dt = datetime.strptime(ts_str.strip(), fmt)
return int(dt.timestamp())
except ValueError:
pass
try:
return int(ts_str)
except ValueError:
return None
def _get_last_message_ts(json_path):
"""读取已有 JSON 的最后一条消息时间戳"""
try:
with open(json_path, encoding="utf-8") as f:
data = json.load(f)
msgs = data.get("messages", [])
if msgs:
return msgs[-1].get("timestamp", 0)
except (json.JSONDecodeError, IOError, KeyError):
pass
return 0
def _get_existing_messages(json_path):
"""读取已有 JSON 的消息列表(增量合并用)"""
try:
with open(json_path, encoding="utf-8") as f:
data = json.load(f)
return data.get("messages", [])
except (json.JSONDecodeError, IOError, KeyError):
return []
def export_one(username, output_dir, names, transcribe=False,
start_ts=None, end_ts=None, incremental=False):
"""
导出单个会话。
返回: (成功标志, 消息数, 错误信息)
参数:
start_ts: 消息起始时间戳None = 全部)
end_ts: 消息结束时间戳None = 全部)
incremental: 增量模式(追加到已有消息,跳过重复)
返回: (成功标志, 总消息数, 新增消息数, 错误信息)
"""
ctx = mcp_server._resolve_chat_context(username)
if ctx is None:
return False, 0, f"Cannot resolve: {username}"
return False, 0, 0, f"Cannot resolve: {username}"
display_name = ctx["display_name"]
message_tables = ctx["message_tables"]
if not message_tables:
return False, 0, "no tables"
return False, 0, 0, "no tables"
all_rows = []
# 构造输出路径
prefix = "group" if ctx["is_group"] else "single"
safe = re.sub(r'[\\/:*?"<>|]', "_", f"{prefix}_{display_name}")
out_path = os.path.join(output_dir, f"{safe}.json")
# 增量模式:读取已有消息和最后时间戳
existing_msgs = []
last_ts = 0
if incremental and os.path.isfile(out_path):
existing_msgs = _get_existing_messages(out_path)
last_ts = _get_last_message_ts(out_path)
if last_ts and (start_ts is None or start_ts < last_ts):
start_ts = last_ts
# 如果提供了 start_ts/end_ts 但没有增量数据,仍需查询
if start_ts is not None and incremental and not existing_msgs:
# 无增量目标文件,退化为普通导出
incremental = False
new_rows = []
for table_info in message_tables:
db_path = table_info["db_path"]
table_name = table_info["table_name"]
try:
with closing(sqlite3.connect(db_path)) as conn:
id_to_username = mcp_server._load_name2id_maps(conn)
# 增量模式:只查 start_ts 之后的消息
if start_ts is not None or end_ts is not None:
rows = mcp_server._query_messages(
conn, table_name,
start_ts=start_ts, end_ts=end_ts,
limit=None, oldest_first=True,
)
else:
rows = mcp_server._query_messages(
conn, table_name, limit=None, oldest_first=True
)
for row in rows:
all_rows.append((row, id_to_username))
new_rows.append((row, id_to_username))
except Exception as e:
return False, 0, f"DB query error: {e}"
return False, 0, 0, f"DB query error: {e}"
all_rows.sort(key=lambda pair: pair[0][2] or 0)
new_rows.sort(key=lambda pair: pair[0][2] or 0)
messages = []
for row, id_to_username in all_rows:
local_ids_existing = {m.get("local_id") for m in existing_msgs}
# 构建已有消息的 local_id → message 映射(用于合并时保留 transcription
existing_by_lid = {m.get("local_id"): m for m in existing_msgs}
new_messages = []
for row, id_to_username in new_rows:
local_id, local_type, create_time, real_sender_id, content, ct = row
# 增量模式:跳过已存在的消息
if incremental and local_id in local_ids_existing:
continue
sender = _resolve_sender(row, ctx, names, id_to_username)
type_str = _msg_type_str(local_type)
rendered, extras = _extract_content(
@@ -92,16 +175,25 @@ def export_one(username, output_dir, names, transcribe=False):
if k == "type":
continue
msg[k] = v
messages.append(msg)
new_messages.append(msg)
# 合并消息
messages = existing_msgs + new_messages
new_count = len(new_messages)
if not messages:
return False, 0, "empty"
return False, 0, 0, "empty"
# ── 语音转录 ──────────────────────────────────────────────
if transcribe:
# 只需转录新消息中的语音
voices_to_transcribe = new_messages if incremental else [
m for m in messages
if m.get("type") == "voice" and not m.get("transcription")
]
transcribed = 0
failed = 0
for msg in messages:
for msg in voices_to_transcribe:
if msg.get("type") != "voice":
continue
lid = msg["local_id"]
@@ -123,7 +215,7 @@ def export_one(username, output_dir, names, transcribe=False):
failed += 1
if transcribed or failed:
display = names.get(username, username)
voice_total = sum(1 for m in messages if m.get("type") == "voice")
voice_total = len(voices_to_transcribe)
print(
f" 转录: {transcribed}/{voice_total} 条语音"
+ (f" ({failed} 失败)" if failed else "")
@@ -139,13 +231,11 @@ def export_one(username, output_dir, names, transcribe=False):
if ctx["is_group"]:
output["is_group"] = True
prefix = "group" if ctx["is_group"] else "single"
safe = re.sub(r'[\\/:*?"<>|]', "_", f"{prefix}_{display_name}")
out_path = os.path.join(output_dir, f"{safe}.json")
os.makedirs(os.path.dirname(out_path) if os.path.dirname(out_path) else ".", exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
return True, len(messages), None
return True, len(messages), new_count, None
_BACKEND_CACHE = None
@@ -168,9 +258,12 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python3 export_all_chats.py /path/to/output
python3 export_all_chats.py --with-transcriptions
python3 export_all_chats.py -t /path/to/output
python3 export_all_chats.py 全量导出所有会话
python3 export_all_chats.py -t 全量导出 + 转录语音
python3 export_all_chats.py -i 增量(追加新消息)
python3 export_all_chats.py --start 2025-01-01 按日期范围导出
python3 export_all_chats.py --end 2025-01-31 按日期范围导出
python3 export_all_chats.py --start 2025-01-01 --end 2025-01-31 -t
""",
)
parser.add_argument(
@@ -185,11 +278,43 @@ def main():
action="store_true",
help="导出时一并转录语音消息(依赖 config.json 配置的 backend",
)
parser.add_argument(
"-i",
"--incremental",
action="store_true",
help="增量导出:只追加新消息到已有 JSON 文件",
)
parser.add_argument(
"--start",
default=None,
help="起始日期 (如 2025-01-01 或 Unix 时间戳)",
)
parser.add_argument(
"--end",
default=None,
help="结束日期 (如 2025-01-31 或 Unix 时间戳)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="预览模式:显示将导出的会话数和新消息数,不实际写入",
)
args = parser.parse_args()
script_dir = os.path.dirname(os.path.abspath(__file__))
output_dir = args.output_dir or os.path.join(script_dir, "exported_chats")
start_ts = _parse_timestamp(args.start) if args.start else None
end_ts = _parse_timestamp(args.end) if args.end else None
if args.start and start_ts is None:
print(f"错误: 无法解析起始时间: {args.start}", file=sys.stderr)
print("支持格式: 2025-01-01, 2025-01-01 14:30, 2025-01-01T14:30:00", file=sys.stderr)
sys.exit(1)
if args.end and end_ts is None:
print(f"错误: 无法解析结束时间: {args.end}", file=sys.stderr)
print("支持格式: 2025-01-01, 2025-01-01 14:30, 2025-01-01T14:30:00", file=sys.stderr)
sys.exit(1)
if args.with_transcriptions:
try:
backend = _resolve_backend()
@@ -215,29 +340,55 @@ def main():
names = mcp_server.get_contact_names()
# 显示模式信息
mode = ""
if args.incremental:
mode = "增量模式"
if start_ts:
start_dt = datetime.fromtimestamp(start_ts).strftime("%Y-%m-%d %H:%M")
mode += f" 起始={start_dt}"
if end_ts:
end_dt = datetime.fromtimestamp(end_ts).strftime("%Y-%m-%d %H:%M")
mode += f" 结束={end_dt}"
if not mode:
mode = "全量模式"
if args.dry_run:
mode += " (预览)"
print(f"会话总数: {len(sessions)}")
print(f"联系人映射: {len(names)}")
print(f"输出目录: {output_dir}")
print(f"模式: {mode}")
print("=" * 60)
t0 = time.time()
ok, skip, err, total = 0, 0, 0, 0
total_new = 0
iterable = _tqdm(sessions, desc="导出进度") if _tqdm else sessions
for i, username in enumerate(iterable, 1):
display = names.get(username, username)
success, count, reason = export_one(
username, output_dir, names, transcribe=args.with_transcriptions
success, total_msgs, new_msgs, reason = export_one(
username, output_dir, names,
transcribe=args.with_transcriptions,
start_ts=start_ts,
end_ts=end_ts,
incremental=args.incremental,
)
if success:
ok += 1
total += count
total += total_msgs
total_new += new_msgs
if new_msgs > 0 or args.incremental:
label = f"+{new_msgs} new" if args.incremental else f"{total_msgs} msgs"
else:
label = f"{total_msgs} msgs"
if not _tqdm:
if i <= 10 or i % 100 == 0:
if i <= 10 or i % 100 == 0 or new_msgs > 0:
elapsed = time.time() - t0
eta = (elapsed / i) * (len(sessions) - i) if i > 0 else 0
print(
f"[{i}/{len(sessions)}] {display} - {count} 条消息"
f"[{i}/{len(sessions)}] {display} - {label}"
+ (f" ETA {eta/60:.0f}" if i > 1 else "")
)
else:
@@ -256,9 +407,10 @@ def main():
elapsed = time.time() - t0
print()
print("=" * 60)
extra = f" (新增 {total_new} 条)" if args.incremental and total_new > 0 else ""
print(
f"完成! 成功={ok} 跳过={skip} 失败={err} "
f"总消息={total} 耗时={elapsed/60:.0f}"
f"总消息={total}{extra} 耗时={elapsed/60:.1f}"
)