feat(export): 增加微信聊天增量 JSON 导出 (#122)
新增 export_all_chats --delta-only 按时间窗口导出每个会话的增量 JSON. - --delta-only 写入 deltas/<run_id>/manifest.json + 有消息的会话文件, 便于后续按批次消费 - 沿用现有 CSV plan 选择逻辑, 可通过 --from-plan-csv 控制范围 - 空时间窗口会话直接跳过, 不再生成 message_count=0 的 delta 文件 - 增量 JSON 保留会话级 metadata, 不改变现有 CSV 导出默认流程 - manifest.json 只记录实际有消息的会话, 无法解析的留在 errors 中便于定位 测试: tests/test_export_all_chats_delta.py 7 个 case (run_id 时间戳形状 / 文件名安全化 / msg_uid 哈希组成 / 写窗口不覆盖全量 JSON / 空窗口跳过 / CLI 参数校验 / manifest 记录文件). 跟 #114 (CSV plan) 互补 — CSV plan 选 "哪些会话" 而 delta 选 "哪个时间窗口".
This commit is contained in:
@@ -30,6 +30,7 @@ import sys
|
||||
import time
|
||||
from contextlib import closing
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import mcp_server
|
||||
|
||||
@@ -59,6 +60,7 @@ PLAN_CSV_FIELDS = [
|
||||
|
||||
EXPORT_INDEX_FILE = "_export_index.json"
|
||||
EXPORT_INDEX_VERSION = 1
|
||||
DELTA_SCHEMA_VERSION = 1
|
||||
PLAN_MODE_BLACKLIST = "blacklist"
|
||||
PLAN_MODE_WHITELIST = "whitelist"
|
||||
_UNSAFE_FILENAME_RE = re.compile(r'[\\/:*?"<>|]')
|
||||
@@ -116,6 +118,33 @@ def _safe_export_filename_part(value):
|
||||
return cleaned or "unknown"
|
||||
|
||||
|
||||
def _delta_run_id(now_ts=None):
|
||||
ts = int(now_ts if now_ts is not None else time.time())
|
||||
return datetime.fromtimestamp(ts).strftime("%Y%m%dT%H%M%S")
|
||||
|
||||
|
||||
def _delta_filename(display_name, is_group, username):
|
||||
prefix = "group" if is_group else "single"
|
||||
label = _safe_export_filename_part(display_name or username or "unknown")
|
||||
user_part = _safe_export_filename_part(username)
|
||||
return f"{prefix}_{label}__{user_part}.delta.json"
|
||||
|
||||
|
||||
def _content_hash_for_uid(content):
|
||||
if content is None:
|
||||
return ""
|
||||
return hashlib.sha256(str(content).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _delta_msg_uid(username, db_path, local_id, timestamp, msg_type, content):
|
||||
db_name = os.path.basename(str(db_path or ""))
|
||||
payload = (
|
||||
f"{username}|{db_name}|{int(local_id)}|{int(timestamp)}|"
|
||||
f"{msg_type or 'text'}|{_content_hash_for_uid(content)}"
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _export_filename(display_name, is_group, username=None):
|
||||
prefix = "group" if is_group else "single"
|
||||
label = display_name or username or "unknown"
|
||||
@@ -1177,6 +1206,208 @@ def export_one(username, output_dir, names, transcribe=False,
|
||||
return True, len(messages), new_count, None
|
||||
|
||||
|
||||
def _transcribe_delta_voice_messages(username, messages):
|
||||
for msg in messages:
|
||||
if msg.get("type") != "voice" or msg.get("transcription"):
|
||||
continue
|
||||
lid = msg["local_id"]
|
||||
try:
|
||||
row = mcp_server._fetch_voice_row(username, lid)
|
||||
if row is None:
|
||||
continue
|
||||
voice_data, create_time = row
|
||||
wav_path, _ = mcp_server._silk_to_wav(
|
||||
voice_data, create_time, username, lid
|
||||
)
|
||||
backend = _resolve_backend()
|
||||
result = mcp_server._transcribe(wav_path, backend)
|
||||
if result and result.get("text"):
|
||||
msg["transcription"] = result["text"]
|
||||
os.unlink(wav_path)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def export_delta_one(username, delta_root, names, run_id, start_ts,
|
||||
end_ts=None, transcribe=False):
|
||||
if start_ts is None:
|
||||
raise ValueError("delta export requires start_ts")
|
||||
|
||||
ctx = mcp_server._resolve_chat_context(username)
|
||||
if ctx is None:
|
||||
return {
|
||||
"success": False,
|
||||
"username": username,
|
||||
"message_count": 0,
|
||||
"reason": f"Cannot resolve: {username}",
|
||||
}
|
||||
|
||||
display_name = ctx["display_name"]
|
||||
message_tables = ctx["message_tables"]
|
||||
if not message_tables:
|
||||
return {
|
||||
"success": False,
|
||||
"username": username,
|
||||
"message_count": 0,
|
||||
"reason": "no tables",
|
||||
}
|
||||
|
||||
rows_for_delta = []
|
||||
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)
|
||||
rows = mcp_server._query_messages(
|
||||
conn,
|
||||
table_name,
|
||||
start_ts=start_ts,
|
||||
end_ts=end_ts,
|
||||
limit=None,
|
||||
oldest_first=True,
|
||||
)
|
||||
for row in rows:
|
||||
rows_for_delta.append((row, table_info, id_to_username))
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"username": username,
|
||||
"message_count": 0,
|
||||
"reason": f"DB query error: {e}",
|
||||
}
|
||||
|
||||
rows_for_delta.sort(key=lambda item: item[0][2] or 0)
|
||||
messages = []
|
||||
for row, table_info, id_to_username in rows_for_delta:
|
||||
local_id, local_type, create_time, real_sender_id, content, ct = row
|
||||
raw_content = content
|
||||
sender = _resolve_sender(row, ctx, names, id_to_username)
|
||||
type_str = _msg_type_str(local_type)
|
||||
rendered, extras = _extract_content(
|
||||
local_id, local_type, raw_content, ct, username, display_name
|
||||
)
|
||||
effective_type = (extras or {}).get("type") or type_str
|
||||
msg = {
|
||||
"msg_uid": _delta_msg_uid(
|
||||
username=username,
|
||||
db_path=table_info.get("db_path"),
|
||||
local_id=local_id,
|
||||
timestamp=create_time,
|
||||
msg_type=effective_type,
|
||||
content=raw_content,
|
||||
),
|
||||
"local_id": local_id,
|
||||
"timestamp": create_time,
|
||||
"sender": sender,
|
||||
}
|
||||
if effective_type != "text":
|
||||
msg["type"] = effective_type
|
||||
if rendered is not None:
|
||||
msg["content"] = rendered
|
||||
if extras:
|
||||
for key, value in extras.items():
|
||||
if key != "type":
|
||||
msg[key] = value
|
||||
messages.append(msg)
|
||||
|
||||
if not messages:
|
||||
return {
|
||||
"success": True,
|
||||
"skipped": True,
|
||||
"username": username,
|
||||
"chat": display_name,
|
||||
"message_count": 0,
|
||||
"reason": "no messages in delta window",
|
||||
}
|
||||
|
||||
if transcribe:
|
||||
_transcribe_delta_voice_messages(username, messages)
|
||||
|
||||
delta_rel_path = os.path.join(
|
||||
"chats",
|
||||
_delta_filename(display_name, ctx["is_group"], username),
|
||||
)
|
||||
delta_path = os.path.join(delta_root, "deltas", run_id, delta_rel_path)
|
||||
output = {
|
||||
"schema_version": DELTA_SCHEMA_VERSION,
|
||||
"export_kind": "wechat_delta",
|
||||
"chat": display_name,
|
||||
"username": username,
|
||||
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"range": {
|
||||
"start": _date_from_message_ts(start_ts),
|
||||
"end": _date_from_message_ts(end_ts) if end_ts is not None else "",
|
||||
},
|
||||
}
|
||||
if messages:
|
||||
output["date_first_msg"] = _date_from_message_ts(
|
||||
messages[0].get("timestamp")
|
||||
)
|
||||
output["date_last_msg"] = _date_from_message_ts(
|
||||
messages[-1].get("timestamp")
|
||||
)
|
||||
if ctx["is_group"]:
|
||||
output["is_group"] = True
|
||||
else:
|
||||
output.update(_contact_metadata_for_export(username, ctx["is_group"]))
|
||||
output["message_count"] = len(messages)
|
||||
output["messages"] = messages
|
||||
|
||||
os.makedirs(os.path.dirname(delta_path), exist_ok=True)
|
||||
with open(delta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(output, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"username": username,
|
||||
"chat": display_name,
|
||||
"path": delta_rel_path.replace("\\", "/"),
|
||||
"message_count": len(messages),
|
||||
}
|
||||
|
||||
|
||||
def _write_delta_manifest(delta_root, run_id, start_ts, end_ts,
|
||||
chats_checked, results):
|
||||
files = []
|
||||
errors = []
|
||||
for result in results:
|
||||
if result.get("success") and result.get("message_count", 0) > 0:
|
||||
files.append({
|
||||
"username": result["username"],
|
||||
"chat": result.get("chat", result["username"]),
|
||||
"path": result["path"],
|
||||
"message_count": result.get("message_count", 0),
|
||||
})
|
||||
elif not result.get("success"):
|
||||
errors.append({
|
||||
"username": result.get("username", ""),
|
||||
"reason": result.get("reason", "unknown"),
|
||||
})
|
||||
manifest = {
|
||||
"schema_version": DELTA_SCHEMA_VERSION,
|
||||
"export_kind": "wechat_delta_run",
|
||||
"run_id": run_id,
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"range": {
|
||||
"start": _date_from_message_ts(start_ts),
|
||||
"end": _date_from_message_ts(end_ts) if end_ts is not None else "",
|
||||
},
|
||||
"chats_checked": chats_checked,
|
||||
"chats_with_messages": len(files),
|
||||
"messages_exported": sum(item["message_count"] for item in files),
|
||||
"files": files,
|
||||
"errors": errors,
|
||||
}
|
||||
manifest_path = Path(delta_root) / "deltas" / run_id / "manifest.json"
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return manifest_path
|
||||
|
||||
|
||||
_BACKEND_CACHE = None
|
||||
|
||||
|
||||
@@ -1253,6 +1484,11 @@ def main(argv=None):
|
||||
action="store_true",
|
||||
help="增量导出:只追加新消息到已有 JSON 文件",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delta-only",
|
||||
action="store_true",
|
||||
help="只导出 --start/--end 时间窗口内的增量 delta JSON,不读取或覆盖已有完整 JSON",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--start",
|
||||
default=None,
|
||||
@@ -1292,6 +1528,8 @@ def main(argv=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.delta_only and start_ts is None:
|
||||
parser.error("--delta-only requires --start to avoid accidental full export")
|
||||
|
||||
if args.with_transcriptions:
|
||||
try:
|
||||
@@ -1330,7 +1568,9 @@ def main(argv=None):
|
||||
|
||||
# 显示模式信息
|
||||
mode = ""
|
||||
if args.incremental:
|
||||
if args.delta_only:
|
||||
mode = "Delta 增量导出"
|
||||
elif args.incremental:
|
||||
mode = "增量模式"
|
||||
if start_ts:
|
||||
start_dt = datetime.fromtimestamp(start_ts).strftime("%Y-%m-%d %H:%M")
|
||||
@@ -1401,18 +1641,48 @@ def main(argv=None):
|
||||
total_new = 0
|
||||
|
||||
total_sessions = len(sessions)
|
||||
run_id = _delta_run_id()
|
||||
delta_results = []
|
||||
for i, username in enumerate(sessions, 1):
|
||||
display = names.get(username, username)
|
||||
chat_t0 = time.time()
|
||||
print(f"[{i}/{total_sessions}] 开始导出: {display} ({username})", flush=True)
|
||||
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 args.delta_only:
|
||||
result = export_delta_one(
|
||||
username=username,
|
||||
delta_root=output_dir,
|
||||
names=names,
|
||||
run_id=run_id,
|
||||
start_ts=start_ts,
|
||||
end_ts=end_ts,
|
||||
transcribe=args.with_transcriptions,
|
||||
)
|
||||
delta_results.append(result)
|
||||
success = result.get("success", False)
|
||||
total_msgs = result.get("message_count", 0)
|
||||
new_msgs = total_msgs
|
||||
reason = result.get("reason")
|
||||
else:
|
||||
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:
|
||||
elapsed = time.time() - t0
|
||||
chat_elapsed = time.time() - chat_t0
|
||||
eta = (elapsed / i) * (total_sessions - i) if i > 0 else 0
|
||||
if args.delta_only and result.get("skipped"):
|
||||
skip += 1
|
||||
print(
|
||||
f"[{i}/{total_sessions}] 跳过: {display} ({reason}) "
|
||||
f"(本会话 {chat_elapsed:.1f}s, ETA {eta/60:.1f}分)",
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
|
||||
ok += 1
|
||||
total += total_msgs
|
||||
total_new += new_msgs
|
||||
@@ -1420,9 +1690,6 @@ def main(argv=None):
|
||||
label = f"+{new_msgs} new" if args.incremental else f"{total_msgs} msgs"
|
||||
else:
|
||||
label = f"{total_msgs} msgs"
|
||||
elapsed = time.time() - t0
|
||||
chat_elapsed = time.time() - chat_t0
|
||||
eta = (elapsed / i) * (total_sessions - i) if i > 0 else 0
|
||||
print(
|
||||
f"[{i}/{total_sessions}] 完成导出: {display} - {label} "
|
||||
f"(本会话 {chat_elapsed:.1f}s, ETA {eta/60:.1f}分)",
|
||||
@@ -1450,6 +1717,17 @@ def main(argv=None):
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if args.delta_only:
|
||||
manifest_path = _write_delta_manifest(
|
||||
delta_root=output_dir,
|
||||
run_id=run_id,
|
||||
start_ts=start_ts,
|
||||
end_ts=end_ts,
|
||||
chats_checked=total_sessions,
|
||||
results=delta_results,
|
||||
)
|
||||
print(f"Delta manifest: {manifest_path}")
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print()
|
||||
print("=" * 60)
|
||||
|
||||
Reference in New Issue
Block a user