* 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,同时匹配导出工具实际产出的
文件名。
103 lines
3.6 KiB
Python
103 lines
3.6 KiB
Python
"""
|
||
为聊天导出 JSON 中的语音消息补齐转录文本。
|
||
|
||
用法:
|
||
.venv/bin/python3 transcribe_chat.py <input.json> [output.json]
|
||
|
||
参数:
|
||
<input.json> 由 export_chat.py 产出的 JSON。
|
||
[output.json] 可选输出路径,默认 "<input>_transcribed.json"。
|
||
|
||
完整流程示例:
|
||
.venv/bin/python3 export_chat.py <chat_name> /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 <input.json> [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)
|