## 痛点 之前 Web UI 工具箱里 "③ 导出聊天" / "⑦ 企业微信导出" 一点就跑全量。 用户 3142 个个人微信会话 + 14 个企微会话, 全量导出几个 GB JSON 几小时, 还没法只导某几个群。 ## 设计 点击导出按钮 → 弹模态框选会话 + 格式 → 确认后才跑。 ``` ┌────────────────────────────────────────┐ │ 导出个人微信聊天 [×] │ ├────────────────────────────────────────┤ │ 🔍 按名字 / wxid 搜索... │ │ ┌──────────────────────────────────┐ │ │ │ ☐ [群] 交易所消息 2026-05-14│ │ │ │ ☐ [单聊] 张三 2026-05-13│ │ │ │ ☐ [公众号] xxx日报 2026-05-10│ │ │ │ ... │ │ │ └──────────────────────────────────┘ │ │ [全选] [清空] [选最近30天活跃] 已选 N│ │ │ │ 格式 (仅企微) ☑CSV ☐HTML ☐JSON │ │ │ │ [取消] [确认导出 →] │ └────────────────────────────────────────┘ ``` ## 实现拆解 ### Backend 1. **GET /api/sessions?source=wechat|wxwork** — 列会话 - wechat: 从 decrypted/session/session.db SessionTable 读, 拼合 contact.db 的 nick_name/remark, type 分群/单聊/公众号 - wxwork: 从 wxwork_decrypted/session.db conversation_table 读, id 前缀分 R/S/E/Y → 群/单聊/外部/其他 - 按 last_ts 降序 2. **TOOL_TASKS schema 加 build_steps + needs_modal** - 旧固定任务: `steps: [cmd, ...]` - 新动态任务: `build_steps: fn(users, formats) → [cmd, ...]` - export_all / wxwork_export 用 build_steps + needs_modal 标记 3. **_run_tool_task 接收 args**, 优先调 build_steps 生成 cmd 4. **POST /api/tool body 接收 args**: `{task, args: {users:[...], formats:[...]}}` ### CLI 脚本 - `export_all_chats.py` 加 `--users wxid1,wxid2` (alt: env WECHAT_EXPORT_USERS), 在加载完 sessions 后做白名单过滤, 空集报错退出 - `export_wxwork_messages.py` 原本就支持 `--conversation` (multi-arg) + `--formats csv,html,json`, 不动它, monitor_web 拼 argv 即可 ### Frontend - HTML 加 `#exportModal` 模态框骨架 (overlay + dialog + search + list + format checkboxes + footer buttons) - CSS .modal-* 一套 (用 design tokens, 跟整体暗色风格统一) - JS: - NEEDS_MODAL = {export_all: 'export_wechat', wxwork_export: 'export_wxwork'} - runTool 拦截这两个 task → openExportModal - openExportModal fetch /api/sessions → renderSessions (复选框列表) - filterSessions 实时搜索过滤 - selectAllSessions / selectRecentSessions(30) 批量选择 - confirmExport 收集 selected usernames + formats → runToolWithArgs - runTool 拆成 runTool (entry, 拦截/取消) + runToolWithArgs (实际跑) ## 实测验证 ``` $ curl -s "http://localhost:5678/api/sessions?source=wxwork" [{"username": "R:358645240322", "name": "交易所消息", "type": "群", "last_ts": 1778658597, ...}, {"username": "E:10223", "name": "E:10223", "type": "外部", ...}, ...] $ curl -s "http://localhost:5678/api/sessions?source=wechat" [{"username": "46222992238@chatroom", "name": "月下健人", "type": "群", "last_ts": 1779014016, ...}, ...] ``` API 返回正确, 个人微信 3142 个 / 企微 14 个会话, 按 last_ts 降序。 测试 185/185 通过。 ## 副带改进 - runToolWithArgs 拆出来后, fix 之前一个小 bug: error 路径里的 `b.textContent = b.dataset.origText` 改成 innerHTML 路径 (一致性) ## 仍未跟进 (follow-up) - export_all_chats 加 `--format csv,html` 多格式输出 (个人微信脚本 目前只支持 JSON, 模态框格式选项对它隐藏了, 看 #109 推进) - 个人微信导出 sessions 列表性能: 3142 个一次 fetch ~5MB JSON, 搜索 + render 在低端机可能卡, 可加分页 / 虚拟列表
437 lines
16 KiB
Python
437 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""批量导出所有微信聊天记录为 JSON 文件,可选附带语音转录。
|
||
|
||
此脚本将导出所有会话的聊天记录,输出格式与 export_chat.py 完全一致。
|
||
支持导出到指定目录,默认输出到 ./exported_chats 目录。
|
||
|
||
语音转录通过 mcp_server 的 backend 配置驱动(config.json 中设置
|
||
transcription_backend 为 whisper_cpp / openai / local)。未启用 backend
|
||
或缺少依赖时仅导出文本消息,不报错。
|
||
|
||
用法:
|
||
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
|
||
import json
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import sys
|
||
import time
|
||
from contextlib import closing
|
||
from datetime import datetime
|
||
|
||
import mcp_server
|
||
|
||
# 尝试导入 tqdm 作为进度条(可选)
|
||
try:
|
||
from tqdm import tqdm as _tqdm
|
||
except ImportError:
|
||
_tqdm = None
|
||
|
||
from chat_export_helpers import _extract_content, _msg_type_str, _resolve_sender
|
||
|
||
|
||
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, 0, f"Cannot resolve: {username}"
|
||
|
||
display_name = ctx["display_name"]
|
||
message_tables = ctx["message_tables"]
|
||
|
||
if not message_tables:
|
||
return False, 0, 0, "no tables"
|
||
|
||
# 构造输出路径
|
||
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:
|
||
new_rows.append((row, id_to_username))
|
||
except Exception as e:
|
||
return False, 0, 0, f"DB query error: {e}"
|
||
|
||
new_rows.sort(key=lambda pair: pair[0][2] or 0)
|
||
|
||
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(
|
||
local_id, local_type, content, ct, username, display_name
|
||
)
|
||
|
||
msg = {"local_id": local_id, "timestamp": create_time, "sender": sender}
|
||
effective_type = (extras or {}).get("type") or type_str
|
||
if effective_type != "text":
|
||
msg["type"] = effective_type
|
||
if rendered is not None:
|
||
msg["content"] = rendered
|
||
if extras:
|
||
for k, v in extras.items():
|
||
if k == "type":
|
||
continue
|
||
msg[k] = v
|
||
new_messages.append(msg)
|
||
|
||
# 合并消息
|
||
messages = existing_msgs + new_messages
|
||
new_count = len(new_messages)
|
||
|
||
if not messages:
|
||
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 voices_to_transcribe:
|
||
if msg.get("type") != "voice":
|
||
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"]
|
||
transcribed += 1
|
||
os.unlink(wav_path)
|
||
except Exception:
|
||
failed += 1
|
||
if transcribed or failed:
|
||
display = names.get(username, username)
|
||
voice_total = len(voices_to_transcribe)
|
||
print(
|
||
f" 转录: {transcribed}/{voice_total} 条语音"
|
||
+ (f" ({failed} 失败)" if failed else "")
|
||
)
|
||
|
||
# ── 写文件 ────────────────────────────────────────────────
|
||
output = {
|
||
"chat": display_name,
|
||
"username": username,
|
||
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"messages": messages,
|
||
}
|
||
if ctx["is_group"]:
|
||
output["is_group"] = True
|
||
|
||
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), new_count, None
|
||
|
||
|
||
_BACKEND_CACHE = None
|
||
|
||
|
||
def _resolve_backend():
|
||
"""解析转录 backend,结果缓存以避免重复检测。"""
|
||
global _BACKEND_CACHE
|
||
if _BACKEND_CACHE is None:
|
||
try:
|
||
_BACKEND_CACHE = mcp_server._resolve_active_backend()
|
||
except Exception:
|
||
_BACKEND_CACHE = "local"
|
||
return _BACKEND_CACHE
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="批量导出所有微信聊天记录为 JSON 文件,可选附带语音转录",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
示例:
|
||
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(
|
||
"output_dir",
|
||
nargs="?",
|
||
default=None,
|
||
help="输出目录路径 (默认: ./exported_chats)",
|
||
)
|
||
parser.add_argument(
|
||
"-t",
|
||
"--with-transcriptions",
|
||
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="预览模式:显示将导出的会话数和新消息数,不实际写入",
|
||
)
|
||
parser.add_argument(
|
||
"--users",
|
||
default=None,
|
||
help="只导出指定 username 的会话, 逗号分隔 (如 wxid_xxx,12345@chatroom). "
|
||
"为空时导出全部 (旧行为). 也可用 env WECHAT_EXPORT_USERS",
|
||
)
|
||
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()
|
||
print(f"语音转录: 启用 (backend={backend})")
|
||
except Exception as e:
|
||
print(f"语音转录: backend 解析失败: {e}", file=sys.stderr)
|
||
args.with_transcriptions = False
|
||
|
||
if not os.path.exists(mcp_server.DECRYPTED_DIR):
|
||
print(f"错误: 解密目录不存在: {mcp_server.DECRYPTED_DIR}", file=sys.stderr)
|
||
sys.exit(1)
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
|
||
session_db = os.path.join(mcp_server.DECRYPTED_DIR, "session", "session.db")
|
||
try:
|
||
with closing(sqlite3.connect(session_db)) as conn:
|
||
sessions = [u for u, _ in conn.execute(
|
||
"SELECT username, type FROM SessionTable"
|
||
)]
|
||
except sqlite3.Error as e:
|
||
print(f"会话数据库查询失败: {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
# username 白名单过滤 (--users 参数 / WECHAT_EXPORT_USERS 环境变量)
|
||
users_filter_raw = args.users or os.environ.get("WECHAT_EXPORT_USERS", "")
|
||
if users_filter_raw.strip():
|
||
wanted = {u.strip() for u in users_filter_raw.split(",") if u.strip()}
|
||
before = len(sessions)
|
||
sessions = [u for u in sessions if u in wanted]
|
||
print(f"按 --users 过滤: {before} → {len(sessions)} 会话")
|
||
if not sessions:
|
||
print(f"[!] 指定的 username 列表跟会话表没交集 (wanted={list(wanted)[:5]}...)",
|
||
file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
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, 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 += 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 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} - {label}"
|
||
+ (f" ETA {eta/60:.0f}分" if i > 1 else "")
|
||
)
|
||
else:
|
||
if "no tables" in str(reason) or "empty" in str(reason):
|
||
skip += 1
|
||
if not _tqdm:
|
||
if i <= 10 or i % 50 == 0:
|
||
print(f"[{i}/{len(sessions)}] {display} - 跳过({reason})")
|
||
else:
|
||
err += 1
|
||
if not _tqdm:
|
||
print(f"[{i}/{len(sessions)}] {display} - 失败: {reason}")
|
||
elif _tqdm:
|
||
_tqdm.write(f"失败: {display} - {reason}")
|
||
|
||
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}{extra} 耗时={elapsed/60:.1f}分"
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|