diff --git a/chat_export_helpers.py b/chat_export_helpers.py new file mode 100644 index 0000000..2bb3271 --- /dev/null +++ b/chat_export_helpers.py @@ -0,0 +1,138 @@ +"""聊天导出共享工具函数。 + +本模块包含 export_chat.py 和 export_all_chats.py 共用的消息格式化函数。 +统一维护,避免两处代码漂移。 +""" + +import base64 + +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. + """ + 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 "|]', "_", f"{prefix}_{display_name}") + out_path = os.path.join(output_dir, f"{safe}.json") + with open(out_path, "w", encoding="utf-8") as f: + json.dump(output, f, ensure_ascii=False, indent=2) + + return True, len(messages), None + + +def main(): + parser = argparse.ArgumentParser( + description="批量导出所有微信聊天记录为 JSON 文件", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python3 export_all_chats.py /path/to/output + """, + ) + parser.add_argument( + "output_dir", + nargs="?", + default=None, + help="输出目录路径 (默认: ./exported_chats)", + ) + 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") + + 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) + + names = mcp_server.get_contact_names() + + print(f"会话总数: {len(sessions)}") + print(f"联系人映射: {len(names)}") + print(f"输出目录: {output_dir}") + print("=" * 60) + + ok, skip, err, total = 0, 0, 0, 0 + for i, username in enumerate(sessions, 1): + display = names.get(username, username) + success, count, reason = export_one(username, output_dir, names) + if success: + ok += 1 + total += count + if i <= 10 or i % 100 == 0: + print(f"[{i}/{len(sessions)}] {display} - {count} 条消息") + else: + if "no tables" in str(reason) or "empty" in str(reason): + skip += 1 + if i <= 10 or i % 50 == 0: + print(f"[{i}/{len(sessions)}] {display} - 跳过({reason})") + else: + err += 1 + print(f"[{i}/{len(sessions)}] {display} - 失败: {reason}") + + print() + print("=" * 60) + print(f"完成! 成功={ok} 跳过={skip} 失败={err} 总消息={total}") + + +if __name__ == "__main__": + main() diff --git a/export_chat.py b/export_chat.py index 071e7d8..8cd8d8f 100644 --- a/export_chat.py +++ b/export_chat.py @@ -41,136 +41,11 @@ 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 "