From edf2c0940ada3346fdbc75f6d6bb05d9ed411f14 Mon Sep 17 00:00:00 2001 From: btc-z Date: Fri, 24 Apr 2026 12:16:37 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E4=B8=8E=E8=AF=AD=E9=9F=B3=E8=BD=AC=E5=BD=95?= =?UTF-8?q?=20CLI=20=E8=84=9A=E6=9C=AC=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 新增聊天导出与语音转录 CLI 脚本 新增两个独立 CLI 脚本,用于将单个聊天导出为结构化 JSON、并批量 填充语音消息的 Whisper 转录。区别于 MCP 工具:这些脚本面向离线 导出/归档,适合一次性拉取大量消息,或在会话外喂给其他 LLM/索引 管线使用。 - export_chat.py:跨分片合并某个聊天的全部消息,按时间排序后输出 紧凑 JSON(type 为 text 时省略,is_group 仅群聊保留等)。复用 mcp_server 中的消息解析/发送者解析辅助函数。 - transcribe_chat.py:读入 export_chat.py 产出的 JSON,对所有尚 未转录的 voice 消息调用 Whisper,原地写回 transcription 字段。 幂等(已有 transcription 的消息跳过)、崩溃安全(每条写回一次 输出文件)。 - .gitignore:新增 *.json 通配,避免本地导出文件被误提交。 config.example.json 已被跟踪,不受影响。 修复:transcribe_chat.py 原先调用 _silk_to_wav 时缺少 local_id 参数(commit c149389 将 local_id 加入签名用于文件名唯一化), 本 PR 中已补齐。 * docs: 新增聊天导出 JSON 数据格式文档 新增 docs/chat_export_format.md,描述 export_chat.py 与 transcribe_chat.py 产出的 JSON schema:顶层字段、消息对象的必填/ 可选字段、默认值省略规则,以及加载与过滤的 Python 示例。 与现有 docs/macos-*.md 指南风格一致,避免在脚本 docstring 中堆叠 大段表格。export_chat.py 的 docstring 加一行指针指向本文档。 * docs: 聊天导出格式文档翻译为中文 与 docs/macos-*.md 既有指南保持一致的语言风格,将 docs/chat_export_format.md 翻译为中文。JSON 字段名、Python 代码示例等技术标识保持英文不变。 * fix: 回应 PR #57 review — 崩溃处理、幂等性、schema 补全 根据 review (#57) 的反馈: - export_chat.py: _resolve_chat_context 返回 None 时的崩溃改为友好 退出,并在 resolve 成功后打印 display_name (username),便于用户 核对 resolve_username 的模糊匹配结果。 - export_chat.py: _query_messages 的 limit=999999 改为 None,避免 超长历史被悄悄截断(_query_messages 对 None 会省略 LIMIT 子句)。 - export_chat.py: 输出 JSON 顶层新增 username 字段,让 transcribe_chat.py 可以跳过二次模糊匹配,避免同名联系人漂移。 - transcribe_chat.py: 优先读取 JSON 顶层的 username,旧导出文件 (无 username)回退到按 chat 名解析,保持向后兼容。 - transcribe_chat.py: 删除未使用的 import io / import wave,将循环 内的 import datetime 提至模块顶部。 - export_chat.py: _decode_sticker_desc 的 varint 单字节简化给出 注释说明局限,以及对 create_time 排序加 "or 0" 防御。 - export_chat.py / transcribe_chat.py: 模块 docstring 翻译为中文, 与 docs/macos-*.md 保持一致。 - docs/chat_export_format.md: 同步补充 username 字段说明。 - .gitignore: 将 *.json 收窄为 *_export*.json / *_transcribed*.json, 避免误屏蔽未来的 config/fixtures,同时匹配导出工具实际产出的 文件名。 --- .gitignore | 4 + docs/chat_export_format.md | 97 +++++++++++++++ export_chat.py | 249 +++++++++++++++++++++++++++++++++++++ transcribe_chat.py | 102 +++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 docs/chat_export_format.md create mode 100644 export_chat.py create mode 100644 transcribe_chat.py diff --git a/.gitignore b/.gitignore index b10a3d5..db41c35 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ decoded_images/ *.db-wal *.db.tmp_monitor +# Chat export/transcription output files (contain private message data) +*_export*.json +*_transcribed*.json + # Hook outputs hook_output.txt hook_start_output.txt diff --git a/docs/chat_export_format.md b/docs/chat_export_format.md new file mode 100644 index 0000000..03a30f5 --- /dev/null +++ b/docs/chat_export_format.md @@ -0,0 +1,97 @@ +# 聊天导出 JSON 数据格式 + +`export_chat.py` 与 `transcribe_chat.py` 生成的 JSON 文件采用紧凑格式: +默认值与空值会被省略。本文档说明如何加载和解读这类文件。 + +## 生成文件 + +```bash +.venv/bin/python3 export_chat.py [output.json] +.venv/bin/python3 transcribe_chat.py [output.json] +``` + +`export_chat.py` 负责原始导出;`transcribe_chat.py` 使用 Whisper(CPU) +为语音消息填充转录文本。`transcribe_chat.py` 可重复运行 —— 已转录的 +消息会被跳过。 + +## 顶层结构 + +```json +{ + "chat": "", + "username": "", + "exported_at": "YYYY-MM-DD HH:MM:SS", + "is_group": true, + "messages": [ ... ] +} +``` + +- `chat` —— 聊天的显示名(联系人名或群名)。 +- `username` —— 稳定的 WeChat 用户名(1-on-1 聊天为 `wxid_*`,群聊为 `*@chatroom`)。 + `transcribe_chat.py` 会优先读取本字段而非基于 `chat` 再次模糊匹配,避免同名联系人漂移。 +- `exported_at` —— 本地时间字符串,仅作溯源用途。 +- `is_group` —— **仅**群聊出现且为 `true`;1-on-1 聊天时省略。 +- `messages` —— 消息数组,跨所有 DB 分片按时间由旧到新排序。 + +消息条数 = `len(messages)`,没有 `total` 字段。 + +## 消息对象 + +每条消息必有三个字段:`local_id`、`timestamp`、`sender`。 +其余字段均为**可选**,当值为默认值或 null 时会被省略。 + +| 字段 | 类型 | 必填 | 含义 / 缺失时的默认值 | +| --------------- | ------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `local_id` | int | 是 | WeChat 内该聊天的稳定行 ID。用于重跑转录或对比导出时的消息匹配。 | +| `timestamp` | int | 是 | Unix 时间戳(秒级,本地时间已换算为秒)。通过 `datetime.fromtimestamp(ts)` 转换。 | +| `sender` | string | 是 | `"me"` 代表当前登录用户;否则为发送者的显示名 —— 1-on-1 聊天中是联系人名,群聊中是群成员名。对于无法归属的消息(如系统通知)为 `""`。 | +| `type` | string | 否 | 消息类型。**缺失时视为 `"text"`**。已知取值:`text`、`image`、`voice`、`sticker`、`video`、`link_or_file`、`call`、`system`、`recall`、`contact_card`、`location`。 | +| `content` | string | 否 | 消息的渲染文本。当没有可提取内容时省略(例如部分图片 / 通话 / 系统事件)。 | +| `transcription` | string | 否 | **仅**在 `type: "voice"` 且已完成转录的消息上出现。若 Whisper 未产出文本可能为空串 `""`。 | + +## 加载示例 + +带默认值的遍历: + +```python +import json +from datetime import datetime + +with open("chat_export_transcribed.json") as f: + data = json.load(f) + +is_group = data.get("is_group", False) + +for m in data["messages"]: + mtype = m.get("type", "text") + when = datetime.fromtimestamp(m["timestamp"]) + sender = m["sender"] # "me" | 联系人/群成员名 | "" + text = m.get("content", "") + if mtype == "voice": + text = m.get("transcription") or "[voice, untranscribed]" + print(f"[{when:%Y-%m-%d %H:%M}] {sender or '(system)'}: {text}") +``` + +判断消息是否由自己发出: + +```python +from_me = m["sender"] == "me" +``` + +筛选仍需转录的语音消息: + +```python +pending = [m for m in data["messages"] + if m.get("type") == "voice" and not m.get("transcription")] +``` + +## 解读注意事项 + +- **系统消息**(`type: "system"`)的 `sender` 为 `""` —— 不属于任何人。 + 常见内容:撤回通知("X 撤回了一条消息")、添加好友事件等。 +- **空转录**(`transcription: ""`)表示 Whisper 已经运行但未产出文本, + 通常是极短或静音片段。这与"尚未转录"(字段缺失)是不同的状态。 +- **非文本消息的 `content`** 是渲染摘要:`[视频] 12秒`、`[表情] 哈哈`、 + `[图片]` 等。原始媒体仍在 WeChat DB 中,可用 `mcp_server.py` 中的 + 辅助函数(`decode_image`、`decode_voice`)取出。 +- **群聊**中的 `sender` 是群成员解析后的显示名;当前登录用户仍为 `"me"`。 diff --git a/export_chat.py b/export_chat.py new file mode 100644 index 0000000..071e7d8 --- /dev/null +++ b/export_chat.py @@ -0,0 +1,249 @@ +""" +将单个聊天的全部消息导出为 JSON。 + +用法: + .venv/bin/python3 export_chat.py [output.json] + +参数: + 联系人显示名、备注名、群名或 wxid。 + [output.json] 可选输出路径,默认 "_export.json"。 + +示例: + .venv/bin/python3 export_chat.py + .venv/bin/python3 export_chat.py /tmp/out.json + +输出 JSON 的紧凑结构: + { + "chat": "", + "username": "", + "exported_at": "YYYY-MM-DD HH:MM:SS", + "is_group": true, // 仅群聊出现 + "messages": [ + {"local_id": 1, "timestamp": 1713..., "sender": "me", "content": "..."}, + {"local_id": 2, "timestamp": 1713..., "sender": "", "type": "voice"} + ] + } + +默认值/空值会被省略: text 消息省略 "type",无可提取内容时省略 "content", +1-on-1 聊天省略 "is_group"。 + +语音消息以 type "voice" 导出且不带 transcription 字段;运行 +transcribe_chat.py 可用 Whisper 补齐转录。 + +需先完成 WeChat DB 解密(详见 README)。 + +完整 schema、字段语义与加载示例: docs/chat_export_format.md +""" +import json +import sqlite3 +import sys +from contextlib import closing +from datetime import datetime + +import mcp_server + + +MSG_TYPE_MAP = { + 1: "text", + 3: "image", + 34: "voice", + 42: "contact_card", + 43: "video", + 47: "sticker", + 48: "location", + 49: "link_or_file", + 50: "call", + 10000: "system", + 10002: "recall", +} + + +def _msg_type_str(local_type): + base, _ = mcp_server._split_msg_type(local_type) + return MSG_TYPE_MAP.get(base, f"type_{local_type}") + + +def _resolve_sender(row, ctx, names, id_to_username): + """Resolve the sender of a message. + + Returns "me" for the logged-in user, or the sender's display name otherwise + (the contact's name in 1-on-1 chats, the member's name in groups). Empty + string for unattributable messages (e.g. system notifications). + """ + local_id, local_type, create_time, real_sender_id, content, ct = row + decoded = mcp_server._decompress_content(content, ct) + sender_from_content, _ = mcp_server._format_message_text( + local_id, local_type, decoded, ctx["is_group"], ctx["username"], ctx["display_name"], names + ) + label = mcp_server._resolve_sender_label( + real_sender_id, + sender_from_content, + ctx["is_group"], + ctx["username"], + ctx["display_name"], + names, + id_to_username, + ) + return label or "" + + +def _decode_sticker_desc(b64_desc): + """WeChat encodes sticker labels as base64 protobuf: repeated (lang, text) pairs. + Returns the 'default' language label (usually Chinese), or None. + + Limitation: treats the length byte as a single octet rather than a real protobuf + varint — labels >127 bytes would be misread. In practice sticker descriptions are + short (<30 chars), so this is adequate. Also sensitive to the bytes b"default" + appearing inside a preceding value; no such cases observed. + """ + import base64 + try: + raw = base64.b64decode(b64_desc) + except Exception: + return None + # Find the 'default' marker; text follows as: \x12 + i = raw.find(b"default") + if i < 0 or i + 7 >= len(raw) or raw[i + 7] != 0x12: + return None + try: + text_len = raw[i + 8] + text_bytes = raw[i + 9 : i + 9 + text_len] + return text_bytes.decode("utf-8") or None + except (IndexError, UnicodeDecodeError): + return None + + +def _format_sticker_message(content): + root = mcp_server._parse_xml_root(content) if content else None + if root is None: + return "[表情]" + emoji = root.find(".//emoji") + if emoji is None: + return "[表情]" + desc = emoji.get("desc") or "" + label = _decode_sticker_desc(desc) if desc else None + return f"[表情] {label}" if label else "[表情]" + + +def _format_system_message(content): + if not content: + return "[系统消息]" + if " [output.json]") + sys.exit(1) + chat = sys.argv[1] + out = sys.argv[2] if len(sys.argv) > 2 else f"{chat}_export.json" + export_chat(chat, out) diff --git a/transcribe_chat.py b/transcribe_chat.py new file mode 100644 index 0000000..6f8cb70 --- /dev/null +++ b/transcribe_chat.py @@ -0,0 +1,102 @@ +""" +为聊天导出 JSON 中的语音消息补齐转录文本。 + +用法: + .venv/bin/python3 transcribe_chat.py [output.json] + +参数: + 由 export_chat.py 产出的 JSON。 + [output.json] 可选输出路径,默认 "_transcribed.json"。 + +完整流程示例: + .venv/bin/python3 export_chat.py /tmp/chat.json + .venv/bin/python3 transcribe_chat.py /tmp/chat.json /tmp/chat_transcribed.json + +行为说明: + - 使用 OpenAI Whisper (CPU,单线程) 对每条语音消息转录。 + - 幂等: 已有 "transcription" 字段的消息会被跳过,因此崩溃/中断后可安全重跑。 + - 崩溃安全: 每处理完一条即整体重写输出 JSON,进程中断最多丢失当前一条。 + - 首次运行会下载 Whisper 模型 (~145 MB) 并缓存。 + +需要 WeChat DB 仍然在线/已解密 —— 语音 blob 是从 DB 现场按 local_id 读取的, +不从 JSON 读。 +""" +import json +import os +import sys +from datetime import datetime + +import mcp_server + + +def _transcribe_local_id(username, local_id): + row = mcp_server._fetch_voice_row(username, local_id) + if row is None: + return "[not found]" + + voice_data, create_time = row + try: + wav_path, _ = mcp_server._silk_to_wav(voice_data, create_time, username, local_id) + except Exception as e: + return f"[decode error: {e}]" + + try: + model = mcp_server._get_whisper_model() + result = model.transcribe(wav_path) + return result.get("text", "").strip() + except Exception as e: + return f"[transcribe error: {e}]" + + +def transcribe_export(input_path, output_path): + with open(input_path, encoding="utf-8") as f: + data = json.load(f) + + # 优先使用导出 JSON 中已记录的 username,避免重新模糊匹配导致同名联系人漂移。 + username = data.get("username") + chat_name = data.get("chat", "") + if not username: + username = mcp_server.resolve_username(chat_name) + if not username: + print(f"Could not resolve username for: {chat_name}") + sys.exit(1) + + messages = data["messages"] + # Compact format: type is absent for text; transcription is only present when filled. + pending = [m for m in messages if m.get("type") == "voice" and not m.get("transcription")] + total = len(pending) + + if total == 0: + print("No voice messages to transcribe.") + return + + print(f"Found {total} voice messages to transcribe.") + print("Loading Whisper model (first run downloads ~145MB)...") + mcp_server._get_whisper_model() + print("Model ready.\n") + + for i, msg in enumerate(pending, 1): + local_id = msg["local_id"] + ts = msg["timestamp"] + ts_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if isinstance(ts, (int, float)) else ts + print(f"[{i}/{total}] local_id={local_id} ({ts_str}) ... ", end="", flush=True) + result = _transcribe_local_id(username, local_id) + msg["transcription"] = result + print(repr(result[:60]) if result else '""') + + # Save after each transcription so progress isn't lost on crash + with open(output_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + print(f"\nDone. Written to {output_path}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python3 transcribe_chat.py [output.json]") + sys.exit(1) + + inp = sys.argv[1] + base, ext = os.path.splitext(inp) + out = sys.argv[2] if len(sys.argv) > 2 else f"{base}_transcribed{ext}" + transcribe_export(inp, out)