"""导出企业微信消息记录到 CSV / HTML / JSON。 输入目录默认来自 wxwork_decrypted_dir,输出到 wxwork_export_dir。 可用环境变量: WXWORK_EXPORT_CONVERSATIONS=conversation_id1,conversation_id2 WXWORK_EXPORT_FORMATS=csv,html,json """ import argparse import csv import json import os import re import sqlite3 import sys from collections import defaultdict from datetime import datetime from html import escape if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") MSG_TYPES = { 0: "文本/混合", 2: "文本", 4: "图片", 7: "语音", 15: "图片/文件", 38: "应用消息", 40: "通话/音视频", 503: "状态", 1011: "会议通知", } _MESSAGE_TABLES = ("message_table", "message_small_table", "kf_message_tableV1") def _app_paths(): from config import _app_base_dir, _config_file_path return _app_base_dir(), _config_file_path() def _load_config(): base, config_file = _app_paths() cfg = {} if os.path.exists(config_file): with open(config_file, encoding="utf-8") as f: cfg = json.load(f) decrypted_dir = cfg.get("wxwork_decrypted_dir", "wxwork_decrypted") if not os.path.isabs(decrypted_dir): decrypted_dir = os.path.join(base, decrypted_dir) output_dir = cfg.get("wxwork_export_dir", "wxwork_export") if not os.path.isabs(output_dir): output_dir = os.path.join(base, output_dir) db_dir = cfg.get("wxwork_db_dir", "") return { "base": base, "decrypted_dir": decrypted_dir, "output_dir": output_dir, "self_id": _infer_self_id(db_dir), } def _infer_self_id(db_dir): if not db_dir: return None parts = os.path.normpath(db_dir).split(os.sep) for part in reversed(parts): if part.isdigit() and len(part) >= 10: return int(part) return None def _safe_dirname(name): name = re.sub(r'[\\/:*?"<>|\r\n\t]+', "_", str(name)) name = re.sub(r"\s+", " ", name).strip(" .") return (name or "unknown")[:120] def _table_exists(conn, table): row = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,), ).fetchone() return row is not None def _open_db(path): conn = sqlite3.connect(path) conn.row_factory = sqlite3.Row return conn def _load_user_map(decrypted_dir): user_db = os.path.join(decrypted_dir, "user.db") users = {} if not os.path.exists(user_db): return users conn = _open_db(user_db) try: if _table_exists(conn, "user_table"): for row in conn.execute( "SELECT id, name, real_name, account, external_corp_name, external_job " "FROM user_table" ): name = row["real_name"] or row["name"] or row["account"] or "" if row["external_corp_name"] and row["external_corp_name"] not in name: name = f"{name} ({row['external_corp_name']})" if name else row["external_corp_name"] if name: users[int(row["id"])] = name if _table_exists(conn, "external_user_relation_v3"): for row in conn.execute( "SELECT user_id, remarks, real_remarks, corp_remark FROM external_user_relation_v3" ): name = row["real_remarks"] or row["remarks"] or row["corp_remark"] or "" if name: users[int(row["user_id"])] = name finally: conn.close() return users def _load_group_member_names(decrypted_dir): session_db = os.path.join(decrypted_dir, "session.db") members = defaultdict(dict) if not os.path.exists(session_db): return members conn = _open_db(session_db) try: if _table_exists(conn, "conversation_user_table"): for row in conn.execute( "SELECT conversation_id, user_id, nick_name FROM conversation_user_table" ): if row["nick_name"]: members[row["conversation_id"]][int(row["user_id"])] = row["nick_name"] if _table_exists(conn, "conversation_member_nickname_table"): # 该表使用 room_id,需要用 conversation_table.con_numeric_id 转成会话 ID。 room_map = {} if _table_exists(conn, "conversation_table"): for row in conn.execute("SELECT con_numeric_id, id FROM conversation_table"): room_map[int(row["con_numeric_id"])] = row["id"] for row in conn.execute( "SELECT room_id, userid, nickname FROM conversation_member_nickname_table" ): cid = room_map.get(int(row["room_id"])) if cid and row["nickname"]: members[cid][int(row["userid"])] = row["nickname"] finally: conn.close() return members def _conversation_kind(conversation_id): if conversation_id.startswith("R:"): return "群聊" if conversation_id.startswith("S:"): return "单聊" if conversation_id.startswith("M:"): return "微信联系人" if conversation_id.startswith("O:"): return "应用/公众号" if conversation_id.startswith("Y:"): return "系统会话" return "其他" def _name_from_conversation_id(conversation_id, user_map, self_id): if conversation_id.startswith("S:"): ids = [] for value in conversation_id[2:].split("_"): if value.isdigit(): ids.append(int(value)) other_ids = [uid for uid in ids if self_id is None or uid != self_id] for uid in other_ids or ids: if uid in user_map: return user_map[uid] if ":" in conversation_id: tail = conversation_id.split(":", 1)[1] if tail.isdigit() and int(tail) in user_map: return user_map[int(tail)] return conversation_id def _load_message_counts(decrypted_dir): msg_db = os.path.join(decrypted_dir, "message.db") counts = defaultdict(int) last_times = defaultdict(int) if not os.path.exists(msg_db): return counts, last_times conn = _open_db(msg_db) try: for table in _MESSAGE_TABLES: if not _table_exists(conn, table): continue for row in conn.execute( f'SELECT conversation_id, COUNT(*) AS c, MAX(send_time) AS t ' f'FROM "{table}" GROUP BY conversation_id' ): cid = row["conversation_id"] if not cid: continue counts[cid] += int(row["c"] or 0) last_times[cid] = max(last_times[cid], int(row["t"] or 0)) finally: conn.close() return counts, last_times def discover_conversations(decrypted_dir=None): cfg = _load_config() if decrypted_dir is None: decrypted_dir = cfg["decrypted_dir"] if not os.path.isdir(decrypted_dir): raise FileNotFoundError(f"企业微信解密目录不存在: {decrypted_dir}") user_map = _load_user_map(decrypted_dir) counts, message_last_times = _load_message_counts(decrypted_dir) session_db = os.path.join(decrypted_dir, "session.db") conversations = {} if os.path.exists(session_db): conn = _open_db(session_db) try: if _table_exists(conn, "conversation_table"): for row in conn.execute( "SELECT id, name, roomname_remark, last_message_time, last_message_id " "FROM conversation_table" ): cid = row["id"] if not cid: continue raw_name = row["roomname_remark"] or row["name"] or "" display = raw_name or _name_from_conversation_id( cid, user_map, cfg["self_id"] ) last_time = max( int(row["last_message_time"] or 0), message_last_times.get(cid, 0), ) conversations[cid] = { "conversation_id": cid, "display_name": display, "kind": _conversation_kind(cid), "message_count": counts.get(cid, 0), "last_time": last_time, "last_message_id": int(row["last_message_id"] or 0), } finally: conn.close() for cid, count in counts.items(): if cid in conversations: conversations[cid]["message_count"] = count conversations[cid]["last_time"] = max( conversations[cid]["last_time"], message_last_times.get(cid, 0) ) continue conversations[cid] = { "conversation_id": cid, "display_name": _name_from_conversation_id(cid, user_map, cfg["self_id"]), "kind": _conversation_kind(cid), "message_count": count, "last_time": message_last_times.get(cid, 0), "last_message_id": 0, } result = [c for c in conversations.values() if c["message_count"] > 0] result.sort(key=lambda c: (c["last_time"], c["message_count"]), reverse=True) return result def _read_varint(data, pos): value = 0 shift = 0 while pos < len(data) and shift < 64: b = data[pos] pos += 1 value |= (b & 0x7F) << shift if not (b & 0x80): return value, pos shift += 7 raise ValueError("bad varint") def _clean_text(text): text = "".join( ch if ch in "\n\t" or (ch.isprintable() and ch not in "\x0b\x0c") else " " for ch in text ) text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def _looks_like_plain_text(data, text): if not text: return False control = sum(1 for b in data if b < 32 and b not in (9, 10, 13)) if control / max(len(data), 1) > 0.08: return False printable = sum(1 for ch in text if ch.isprintable() or ch in "\n\t") return printable / max(len(text), 1) > 0.9 def _decode_text_segment(segment): if not segment or b"\x00" in segment: return None try: text = segment.decode("utf-8") except UnicodeDecodeError: return None text = _clean_text(text) if len(text) < 2: return None if re.fullmatch(r"[0-9a-fA-F]{32,}", text): return None printable = sum(1 for ch in text if ch.isprintable() or ch in "\n\t") if printable / max(len(text), 1) < 0.9: return None return text def _parse_protobuf_strings(data, depth=0): if depth > 4 or not data: return [] pos = 0 out = [] fields = 0 try: while pos < len(data): tag, pos = _read_varint(data, pos) if tag == 0: return [] wire = tag & 7 fields += 1 if wire == 0: _, pos = _read_varint(data, pos) elif wire == 1: pos += 8 elif wire == 5: pos += 4 elif wire == 2: length, pos = _read_varint(data, pos) if length < 0 or pos + length > len(data): return [] segment = data[pos:pos + length] pos += length text = _decode_text_segment(segment) if text: out.append(text) else: out.extend(_parse_protobuf_strings(segment, depth + 1)) else: return [] if pos > len(data): return [] except Exception: return [] return out if fields else [] def _dedupe_texts(values): seen = set() out = [] for value in values: value = _clean_text(value) if not value or value in seen: continue seen.add(value) out.append(value) return out def decode_content(raw): if raw is None: return "" if isinstance(raw, str): return _clean_text(raw) data = bytes(raw) if not data: return "" try: plain = data.decode("utf-8") if _looks_like_plain_text(data, plain): return _clean_text(plain) except UnicodeDecodeError: pass texts = _dedupe_texts(_parse_protobuf_strings(data)) if texts: return "\n".join(texts[:12]) for enc in ("utf-8", "gbk", "utf-16le"): try: text = _clean_text(data.decode(enc, errors="replace")) if text and "\ufffd" not in text[:20]: return text[:2000] except Exception: continue return f"[二进制内容 {len(data)} 字节]" def _format_time(ts): try: ts = int(ts or 0) except (TypeError, ValueError): ts = 0 if ts <= 0: return "" if ts > 20_000_000_000: ts = ts / 1000 return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") def _message_type_name(content_type): return MSG_TYPES.get(int(content_type or 0), f"未知({content_type})") def _display_message_content(content_type, content, extra_content, local_extra_content): text = content or extra_content or local_extra_content if text: return text return f"[{_message_type_name(content_type)}]" def _build_message(row, conv_map, user_map, member_names, self_id): cid = row["conversation_id"] sender_id = int(row["sender_id"] or 0) sender = member_names.get(cid, {}).get(sender_id) or user_map.get(sender_id) if self_id is not None and sender_id == self_id: sender = "我" if not sender: sender = str(sender_id) if sender_id else "系统" content = decode_content(row["content"]) extra_content = decode_content(row["extra_content"]) local_extra_content = decode_content(row["local_extra_content"]) content_type = int(row["content_type"] or 0) conv = conv_map.get(cid, {}) return { "source_table": row["source_table"], "message_id": int(row["message_id"] or 0), "server_id": int(row["server_id"] or 0), "sequence": int(row["sequence"] or 0), "conversation_id": cid, "conversation": conv.get("display_name") or cid, "conversation_kind": conv.get("kind") or _conversation_kind(cid), "sender_id": sender_id, "sender": sender, "content_type": content_type, "type_name": _message_type_name(content_type), "send_time": int(row["send_time"] or 0), "time": _format_time(row["send_time"]), "flag": int(row["flag"] or 0), "content": content, "extra_content": extra_content, "local_extra_content": local_extra_content, "display_content": _display_message_content( content_type, content, extra_content, local_extra_content ), "is_sent": self_id is not None and sender_id == self_id, } def _iter_message_rows(message_db, selected_ids=None): selected_ids = set(selected_ids or []) conn = _open_db(message_db) try: for table in _MESSAGE_TABLES: if not _table_exists(conn, table): continue where = "" params = [] if selected_ids: placeholders = ",".join("?" for _ in selected_ids) where = f"WHERE conversation_id IN ({placeholders})" params = list(selected_ids) sql = ( f'SELECT "{table}" AS source_table, message_id, server_id, sequence, ' f"sender_id, conversation_id, content_type, send_time, flag, " f"content, extra_content, local_extra_content " f'FROM "{table}" {where} ' f"ORDER BY send_time, sequence, message_id" ) yield from conn.execute(sql, params) finally: conn.close() HTML_TEMPLATE = """ {title}
{title}
{meta}
{body}
""" def _write_html(path, conv, messages): parts = [] last_day = None is_group = conv.get("kind") == "群聊" for msg in messages: day = msg["time"][:10] if msg["time"] else "" if day and day != last_day: parts.append(f'
{escape(day)}
') last_day = day side = "sent" if msg["is_sent"] else "received" sender_label = "" if is_group or not msg["is_sent"]: sender_label = f'
{escape(msg["sender"])}
' initial = escape((msg["sender"] or "?")[0].upper()) content = escape(msg["display_content"] or "") type_line = escape(f'{msg["type_name"]} · {msg["time"]}') parts.append( f'
' f'
{initial}
' f'
{sender_label}' f'
{content}
' f'
{type_line}
' f'
' ) meta = f'{conv.get("kind", "")} · {len(messages)} 条消息 · {conv["conversation_id"]}' with open(path, "w", encoding="utf-8") as f: f.write( HTML_TEMPLATE.format( title=escape(conv["display_name"]), meta=escape(meta), body="\n".join(parts), ) ) def _write_csv(path, messages): with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow([ "时间", "会话", "会话ID", "发送者", "发送者ID", "消息类型", "内容", "message_id", "server_id", "sequence", "flag", ]) for msg in messages: writer.writerow([ msg["time"], msg["conversation"], msg["conversation_id"], msg["sender"], msg["sender_id"], msg["type_name"], msg["display_content"], msg["message_id"], msg["server_id"], msg["sequence"], msg["flag"], ]) def _write_json(path, conv, messages): with open(path, "w", encoding="utf-8") as f: json.dump( { "conversation": conv, "message_count": len(messages), "messages": messages, }, f, ensure_ascii=False, indent=2, ) def _selected_from_env(): raw = os.environ.get("WXWORK_EXPORT_CONVERSATIONS", "").strip() if not raw: return None return {item.strip() for item in raw.split(",") if item.strip()} def _formats_from_env(): raw = os.environ.get("WXWORK_EXPORT_FORMATS", "").strip() if not raw: return {"csv"} formats = {item.strip().lower() for item in raw.split(",") if item.strip()} valid = {"csv", "html", "json"} return formats & valid or {"csv"} def export_messages(selected_ids=None, formats=None): cfg = _load_config() decrypted_dir = cfg["decrypted_dir"] output_dir = cfg["output_dir"] message_db = os.path.join(decrypted_dir, "message.db") if not os.path.isdir(decrypted_dir): raise FileNotFoundError(f"企业微信解密目录不存在: {decrypted_dir}") if not os.path.exists(message_db): raise FileNotFoundError(f"企业微信消息库不存在: {message_db}") formats = formats or _formats_from_env() selected_ids = selected_ids if selected_ids is not None else _selected_from_env() conversations = discover_conversations(decrypted_dir) conv_map = {conv["conversation_id"]: conv for conv in conversations} if selected_ids: missing = sorted(selected_ids - set(conv_map)) if missing: print(f"提示: {len(missing)} 个选择的会话没有消息或不存在") user_map = _load_user_map(decrypted_dir) member_names = _load_group_member_names(decrypted_dir) grouped = defaultdict(list) seen = set() for row in _iter_message_rows(message_db, selected_ids): key = (row["conversation_id"], row["message_id"], row["server_id"], row["sequence"]) if key in seen: continue seen.add(key) msg = _build_message(row, conv_map, user_map, member_names, cfg["self_id"]) grouped[msg["conversation_id"]].append(msg) os.makedirs(output_dir, exist_ok=True) total_conversations = 0 total_messages = 0 for cid, messages in sorted( grouped.items(), key=lambda item: (conv_map.get(item[0], {}).get("last_time", 0), len(item[1])), reverse=True, ): conv = conv_map.get(cid) or { "conversation_id": cid, "display_name": cid, "kind": _conversation_kind(cid), "message_count": len(messages), "last_time": messages[-1]["send_time"] if messages else 0, } folder = _safe_dirname(f'{conv["display_name"]}_{cid}') out_dir = os.path.join(output_dir, folder) os.makedirs(out_dir, exist_ok=True) info_path = os.path.join(out_dir, ".info") with open(info_path, "w", encoding="utf-8") as f: f.write(f"conversation_id: {cid}\n") f.write(f"display_name: {conv['display_name']}\n") f.write(f"kind: {conv.get('kind', '')}\n") f.write(f"message_count: {len(messages)}\n") if "csv" in formats: _write_csv(os.path.join(out_dir, "messages.csv"), messages) if "html" in formats: _write_html(os.path.join(out_dir, "messages.html"), conv, messages) if "json" in formats: _write_json(os.path.join(out_dir, "messages.json"), conv, messages) total_conversations += 1 total_messages += len(messages) print(f" {conv['display_name']} ({cid}): {len(messages)} 条") print(f"\n完成: {total_conversations} 个会话, 共 {total_messages} 条消息") print(f"输出目录: {os.path.abspath(output_dir)}") return { "conversation_count": total_conversations, "message_count": total_messages, "output_dir": os.path.abspath(output_dir), } def main(argv=None): parser = argparse.ArgumentParser(description="Export WXWork messages") parser.add_argument("--list", action="store_true", help="list conversations and exit") parser.add_argument( "--conversation", action="append", help="conversation ID to export; can be passed multiple times", ) parser.add_argument("--formats", help="comma separated formats: csv,html,json") args = parser.parse_args(argv) if args.list: conversations = discover_conversations() print(f"发现 {len(conversations)} 个有消息的企业微信会话") for conv in conversations: last_time = _format_time(conv["last_time"]) print( f"{conv['conversation_id']}\t{conv['message_count']}\t" f"{last_time}\t{conv['kind']}\t{conv['display_name']}" ) return 0 selected = set(args.conversation) if args.conversation else None formats = None if args.formats: formats = {item.strip().lower() for item in args.formats.split(",") if item.strip()} export_messages(selected_ids=selected, formats=formats) return 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as exc: print(f"导出失败: {exc}", file=sys.stderr) sys.exit(1)