Files
kefu/2026-08-19-18-27-34/wxwork_export_final.py
2026-08-27 14:04:28 +08:00

878 lines
35 KiB
Python

"""
企业微信聊天记录导出 - 最终版
解密数据库 → 解析 message_table → 提取 protobuf 文本 → 导出 CSV
用法:
python wxwork_export_final.py # 默认: 只导出今天
python wxwork_export_final.py --days 3 # 导出最近 3 天 (含今天)
python wxwork_export_final.py --date 2026-08-19 # 导出指定日期
python wxwork_export_final.py --all # 全量导出所有历史
"""
import csv
import json
import os
import re
import sqlite3
import sys
from datetime import datetime, timedelta
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BASE_DIR)
from wxwork_crypto import (
decrypt_wxwork_database,
is_plain_sqlite_page,
is_wxsqlite3_aes128_page1,
verify_sqlite_file,
verify_wxsqlite3_aes128_key,
)
from wxwork_export_media import export_media
from wxwork_voice2text import load_voice2text, asr_missing
DEFAULT_DB_BASE = os.path.join(os.path.expanduser("~"), "Documents", "WXWork")
DEFAULT_OUTPUT = os.path.join(BASE_DIR, "wxwork_export")
KEYS_FILE = os.path.join(BASE_DIR, "wxwork_keys.json")
def detect_wxwork_dir(candidates=None):
"""自动检测企业微信数据目录 (WXWork)
依次尝试:
1. 用户配置的候选目录 (GUI 手动设置的值)
2. 当前用户 Documents\\WXWork (默认安装位置)
3. 注册表 "文档" 位置下的 WXWork (用户改过"我的文档"路径时)
4. 扫描 C:\\Users\\*\\Documents\\WXWork (企微装在别的登录用户下)
返回: 第一个包含账号 Data/message.db 的有效目录, 找不到返回 None
"""
def _valid(base):
if not base or not os.path.isdir(base):
return False
try:
for d in os.listdir(base):
if os.path.exists(os.path.join(base, d, "Data", "message.db")):
return True
except OSError:
pass
return False
# 1. 用户配置的候选目录
for c in (candidates or []):
if _valid(c):
return c
# 2. 当前用户默认位置
default = os.path.join(os.path.expanduser("~"), "Documents", "WXWork")
if _valid(default):
return default
# 3. 注册表 "文档" 位置 (用户改过系统"文档"文件夹)
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders") as k:
personal, _ = winreg.QueryValueEx(k, "Personal")
if personal:
reg_path = os.path.join(personal, "WXWork")
if _valid(reg_path):
return reg_path
except Exception:
pass
# 4. 扫描其他登录用户的 Documents\WXWork
users_root = os.path.join(os.environ.get("SystemDrive", "C:"), os.sep, "Users")
if os.path.isdir(users_root):
try:
for u in sorted(os.listdir(users_root)):
p = os.path.join(users_root, u, "Documents", "WXWork")
if _valid(p):
return p
except OSError:
pass
return None
# ---- 内容解析 ----
# 消息类型映射 (企业微信 content_type)
MSG_TYPE_MAP = {
0: "文本", 1: "文本", 2: "文本",
3: "图片", 4: "语音", 5: "表情",
6: "链接", 7: "文件", 8: "视频",
9: "位置", 10: "名片", 11: "系统",
12: "引用", 13: "红包", 14: "图片",
15: "转账", 16: "语音", 17: "视频号",
20: "合并转发", 21: "日程", 22: "接龙",
23: "文件回复", 26: "位置共享", 29: "视频通话",
31: "图文链接", 38: "系统通知", 40: "待办",
42: "文件预览", 46: "话题", 47: "图文",
49: "待办", 51: "收藏", 53: "订阅通知",
55: "文件分享", 56: "群公告", 57: "投票",
59: "回执", 61: "文件编辑", 62: "文件评论",
63: "收藏合并", 64: "卡片", 65: "群文件",
66: "工作台", 67: "位置共享", 68: "视频通话",
69: "音频通话", 70: "企业微信应用", 73: "微信好友",
74: "回复", 76: "邀请", 77: "移除",
78: "修改群名", 79: "修改群公告", 80: "加入群聊",
81: "退出群聊", 82: "解散群聊", 83: "群主转让",
101: "系统", 111: "文件", 123: "截图",
132: "系统",
503: "应用消息", 529: "朋友圈", 561: "应用消息",
565: "应用消息", 573: "应用消息",
1002: "安全通知", 1011: "系统消息", 1012: "系统消息",
1017: "系统消息", 1022: "系统消息", 1025: "系统消息",
1043: "系统消息",
1988: "系统消息",
}
def get_msg_type_name(ct):
"""消息类型名"""
try:
ct = int(ct)
except (ValueError, TypeError):
return f"类型{ct}"
return MSG_TYPE_MAP.get(ct, f"类型{ct}")
def is_personal_chat(conv_id):
"""判断会话是否为人与人之间的单聊
保留 (True):
M:xxx 微信单聊 (对方是微信用户)
S:对方UID_本账号UID 企微单聊 (两个数字 UID 用下划线连接)
过滤 (False, 即"无关紧要/群组"数据):
R:xxx 群聊 (Room)
Y:xxx 企业微信应用消息 (工作台/审批/汇报等)
O:xxx 第三方应用/服务号会话 (SCRM、加粉工具等)
MAIL/APPROVAL/... 系统虚拟会话 (企业邮箱/审批/公告等)
"""
c = str(conv_id or "")
if c.startswith("M:"):
return True
if c.startswith("S:"):
parts = c[2:].split("_")
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
return True
return False
return False
# 按会话名称过滤的官方/系统账号 (命中任一关键词即过滤该会话)
DEFAULT_BLOCKED_CONV_NAMES = ("企业微信团队", "微信团队", "微信支付", "腾讯客服", "腾讯新闻")
def is_blocked_conv_name(conv_name, blocked=None):
"""按会话名称判断是否命中过滤名单 (官方/系统账号)
blocked: 关键词元组/列表; None 使用默认名单
"""
if not conv_name:
return False
blocked = DEFAULT_BLOCKED_CONV_NAMES if blocked is None else tuple(blocked)
return any(kw and kw in str(conv_name) for kw in blocked)
def clean_text(text):
"""清洗提取的文本: 去除控制字符和 protobuf 残留"""
if not text:
return ""
# 移除控制字符 (保留 \n \t)
cleaned = ''.join(c for c in text if c.isprintable() or c in '\n\t')
# 移除开头/结尾的空格和换行
cleaned = cleaned.strip()
# 压缩多余空白
cleaned = re.sub(r'[ \t]+', ' ', cleaned)
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
return cleaned
def extract_protobuf_texts(data):
"""
从 protobuf 编码的 content 中递归提取可读文本
返回去重后的文本列表
"""
if not data or not isinstance(data, bytes):
return []
texts = []
seen = set()
def add_text(t):
t = clean_text(t)
if len(t) >= 2 and t not in seen:
# 过滤: 必须包含中文字符 或 足够多的字母
has_chinese = any('\u4e00' <= c <= '\u9fff' for c in t)
alpha_count = sum(1 for c in t if c.isalpha())
if has_chinese or alpha_count >= 6:
seen.add(t)
texts.append(t)
def parse(buf, depth):
if depth > 6 or not buf:
return
i = 0
while i < len(buf):
try:
# 读 tag (varint)
tag = 0
shift = 0
while i < len(buf) and shift < 70:
b = buf[i]
tag |= (b & 0x7f) << shift
i += 1
if not (b & 0x80):
break
shift += 7
if shift >= 70:
break
field_num = tag >> 3
wire_type = tag & 7
if field_num == 0:
break
if wire_type == 0: # varint
while i < len(buf):
b = buf[i]
i += 1
if not (b & 0x80):
break
elif wire_type == 1: # 64-bit
i += 8
elif wire_type == 2: # length-delimited
length = 0
shift = 0
while i < len(buf) and shift < 70:
b = buf[i]
length |= (b & 0x7f) << shift
i += 1
if not (b & 0x80):
break
shift += 7
if shift >= 70:
break
chunk = buf[i:i + length]
i += length
# 尝试 UTF-8 解码
try:
text = chunk.decode('utf-8')
# 无控制字符且可打印比例高
if all(ord(c) >= 32 or c in '\n\t\r' for c in text):
add_text(text)
except (UnicodeDecodeError, ValueError):
pass
# 递归解析子结构
if length < 8192:
parse(chunk, depth + 1)
elif wire_type == 5: # 32-bit
i += 4
else:
break
except Exception:
break
parse(data, 0)
# 如果 protobuf 解析没有提取到中文, 直接扫描 UTF-8 中文序列
if not any('\u4e00' <= c <= '\u9fff' for t in texts for c in t[:50]):
try:
text = data.decode('utf-8', errors='ignore')
readable = re.findall(
r'[\u4e00-\u9fff\w\s,。!?、;:“”‘’()《》【】\-—.,;:!?()\[\]/\\+*=<>@#$%^&~`]{2,}',
text)
for r in readable:
add_text(r.strip())
except Exception:
pass
return texts
def parse_content(content):
"""解析消息内容, 返回可读文本"""
if content is None:
return ""
if not isinstance(content, bytes):
content = str(content).encode('utf-8', errors='replace')
if not content:
return ""
# 情况 1: 直接是 UTF-8 文本 (无控制字符)
try:
text = content.decode('utf-8')
if text and all(ord(c) >= 32 or c in '\n\r\t' for c in text[:500]):
return clean_text(text)
except (UnicodeDecodeError, ValueError):
pass
# 情况 2: protobuf 编码
texts = extract_protobuf_texts(content)
if texts:
# 按长度排序, 取最完整的信息
texts.sort(key=len, reverse=True)
# 过滤掉 JSON 字符串和 URL
meaningful = [t for t in texts
if not (t.startswith('{') and '}' in t)
and not t.startswith('http')
and len(t) > 1]
if meaningful:
# 清洗 protobuf 残留 (内部 tag/length 字节被误并入文本)
cleaned_msgs = []
for t in meaningful:
t = t.strip()
# 模式 A: 'T 残留 T' 重复 (如 "?点击...公交z?点击...公交"), 保留后半完整文本
m = re.match(r'^(.{6,}?)[?zxa-zA-Z]{1,4}\1$', t)
if m:
t = m.group(1)
# 模式 B: 前导 ASCII 残留后紧跟中文/中文标点 (如 "?这个是域名..." / "?,刚刚更新...")
t = re.sub(r'^[?zxa-zA-Z]{1,4}(?=[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef])', '', t)
# 模式 C: 单数字残留后跟中文, 数字后前 3 个汉字内无常见量词才剥离
# (保护 "3月13日" / "7天无理由" / "5个苹果" 等合法文本)
t = re.sub(
r'^[0-9](?=[\u4e00-\u9fff])(?![\u4e00-\u9fff]{0,2}(?:月|日|年|个|天|点|号|楼|人|次|分|秒|周|元|块|台|家|辆|条|件|张|本|层|岁|万|亿|美元|室|房|折))',
'', t)
t = t.strip()
# 子串去重: 短文本是长文本的子串时跳过 (protobuf 递归解析的冗余)
if len(t) >= 3:
dup = False
for c in cleaned_msgs:
if len(c) >= 4 and (t in c or c in t):
dup = True
break
if dup:
continue
if t and t not in cleaned_msgs:
cleaned_msgs.append(t)
if cleaned_msgs:
return ' | '.join(cleaned_msgs[:2])
return texts[0]
# 情况 3: JSON
try:
obj = json.loads(content.decode('utf-8', errors='replace'))
return json.dumps(obj, ensure_ascii=False)[:2000]
except Exception:
pass
# 情况 4: 二进制数据
if len(content) <= 64:
return content.hex()
return f"[二进制数据 {len(content)} 字节]"
def format_timestamp(ts):
"""Unix 时间戳转可读格式"""
if ts is None or ts == "":
return ""
try:
ts = int(ts)
if ts > 10**12:
ts = ts // 1000
if ts < 0:
return str(ts)
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, OSError, OverflowError):
return str(ts)
# ---- 数据库解密 ----
def load_keys():
"""加载密钥映射 {user_dir: key_hex}"""
if not os.path.exists(KEYS_FILE):
return {}
with open(KEYS_FILE) as f:
data = json.load(f)
keys = data.get("keys", {})
if "global_key" in data:
keys.setdefault("*", data["global_key"])
return keys
def decrypt_with_keys(db_base, out_dir, keys_map, use_cache=True):
"""解密所有数据库
use_cache=True 时启用增量缓存: 若解密副本的修改时间不早于源库文件,
且大小正常, 则直接复用, 避免每天重复解密 (日常导出秒开).
"""
decrypted_dbs = []
os.makedirs(out_dir, exist_ok=True)
all_keys = set()
for k in keys_map.values():
try:
all_keys.add(bytes.fromhex(k.replace("x'", "").replace("'", "")))
except ValueError:
pass
if not os.path.isdir(db_base):
print(f"[-] 数据库目录不存在: {db_base}")
return decrypted_dbs
for user_dir in sorted(os.listdir(db_base)):
data_dir = os.path.join(db_base, user_dir, "Data")
if not os.path.isdir(data_dir):
continue
db_files = [f for f in os.listdir(data_dir)
if f.endswith(".db") and not f.endswith("-wal") and not f.endswith("-shm")]
if not db_files:
continue
user_out = os.path.join(out_dir, user_dir)
os.makedirs(user_out, exist_ok=True)
dir_key = None
if user_dir in keys_map:
try:
dir_key = bytes.fromhex(keys_map[user_dir])
except ValueError:
dir_key = None
# 只处理与聊天相关的数据库
relevant = [f for f in db_files if f in
("message.db", "session.db", "user.db", "company.db",
"message_lookup.db", "user_extend.db")]
for db_name in relevant:
db_path = os.path.join(data_dir, db_name)
out_path = os.path.join(user_out, db_name)
# 增量缓存: 解密副本存在且不比源文件旧则直接复用
# 同时检查 -wal 文件: 若 WAL 比解密副本新, 说明有未 checkpoint 的新消息
wal_path = db_path + "-wal"
wal_mtime = 0
try:
if os.path.exists(wal_path) and os.path.getsize(wal_path) > 0:
wal_mtime = os.path.getmtime(wal_path)
except OSError:
wal_mtime = 0
src_mtime = max(os.path.getmtime(db_path), wal_mtime)
if use_cache and os.path.exists(out_path):
try:
if (os.path.getmtime(out_path) >= src_mtime
and os.path.getsize(out_path) > 4096):
decrypted_dbs.append((out_path, db_name, user_dir))
continue
except OSError:
pass
with open(db_path, "rb") as f:
page1 = f.read(4096)
if is_plain_sqlite_page(page1):
with open(db_path, "rb") as fin, open(out_path, "wb") as fout:
fout.write(fin.read())
decrypted_dbs.append((out_path, db_name, user_dir))
continue
if not is_wxsqlite3_aes128_page1(page1):
continue
# 用目录密钥
if dir_key and verify_wxsqlite3_aes128_key(dir_key, page1):
try:
decrypt_wxwork_database(db_path, out_path, dir_key)
verify_sqlite_file(out_path)
decrypted_dbs.append((out_path, db_name, user_dir))
continue
except Exception:
pass
# 尝试所有密钥
for key in all_keys:
if verify_wxsqlite3_aes128_key(key, page1):
try:
decrypt_wxwork_database(db_path, out_path, key)
verify_sqlite_file(out_path)
decrypted_dbs.append((out_path, db_name, user_dir))
break
except Exception:
break
return decrypted_dbs
# ---- 导出 ----
def connect_sqlite(path):
conn = sqlite3.connect(path)
conn.text_factory = lambda b: b.decode('utf-8', errors='replace')
return conn
def export_messages(decrypted_dbs, out_dir, date_from=None, date_to=None,
personal_only=True, blocked_conv_names=None,
voice2text_map=None, voice_asr=False):
"""导出消息到 CSV
date_from / date_to: 'YYYY-MM-DD' 日期范围 (含两端)
None + None -> 全量导出到 out_dir 根目录 (企业微信聊天记录.csv + 会话汇总.csv + 分账号)
单天 from==to -> out_dir/按天/YYYY-MM-DD_聊天记录.csv
多天 from<to -> out_dir/按天/YYYY-MM-DD_至_YYYY-MM-DD_聊天记录.csv
personal_only: True=只导出单聊 (过滤群聊/应用消息/第三方应用, 默认); False=导出全部会话
blocked_conv_names: 会话名称关键词黑名单 (官方/系统账号, 如"企业微信团队"),
命中即过滤整个会话; None 使用 DEFAULT_BLOCKED_CONV_NAMES, 传空元组 () 关闭
voice2text_map: 语音转写文本 {(user_dir, str(server_id)): text}, None 自动读取本地缓存
voice_asr: True=对无本地缓存的语音用本地 AI 识别 (需装 pilk+faster-whisper)
返回值: 主 CSV 路径 (若 date_from/date_to 为 None 则同时输出分账号/汇总 CSV)
"""
os.makedirs(out_dir, exist_ok=True)
all_messages = []
user_cache = {} # (user_dir, uid) -> name
conv_cache = {} # (user_dir, conv_id) -> name
# 阶段 1: 收集元数据
for db_path, db_name, user_dir in decrypted_dbs:
try:
conn = connect_sqlite(db_path)
cursor = conn.cursor()
if db_name == "user.db":
try:
cursor.execute("PRAGMA table_info(user_table)")
cols = [r[1] for r in cursor.fetchall()]
if cols:
cursor.execute("SELECT id, name, real_name, account FROM user_table")
for uid, name, real_name, account in cursor.fetchall():
if uid is not None:
nm = name or real_name or account or str(uid)
user_cache[(user_dir, str(uid))] = str(nm)
except sqlite3.Error:
pass
if db_name == "session.db":
try:
cursor.execute("PRAGMA table_info(conversation_table)")
cols = [r[1] for r in cursor.fetchall()]
if cols:
cursor.execute("SELECT id, name, roomname_remark, session_id FROM conversation_table")
for cid, name, remark, sid in cursor.fetchall():
if cid:
nm = remark or name or sid
conv_cache[(user_dir, str(cid))] = str(nm) if nm else ""
except sqlite3.Error:
pass
conn.close()
except sqlite3.Error as e:
print(f" [警告] 元数据失败: {db_path}: {e}")
# 阶段 1.5: 按会话名称/ID过滤, 先计算要排除的会话ID集合 (供媒体导出复用)
blocked_conv_names = DEFAULT_BLOCKED_CONV_NAMES if blocked_conv_names is None else tuple(blocked_conv_names)
blocked_conv_ids = set()
for (user_dir, cid), cname in conv_cache.items():
if is_personal_chat(cid) and (
is_blocked_conv_name(cname, blocked_conv_names)
or is_blocked_conv_name(str(cid), blocked_conv_names)):
blocked_conv_ids.add(str(cid))
# 阶段 1.6: 导出媒体文件 (图片/语音/视频/文件) 到 out_dir/媒体文件
media_map = {}
media_stats = {}
try:
print("\n[*] 导出媒体文件 (图片/语音/视频/文件)...")
media_map, media_stats = export_media(
decrypted_dbs, out_dir, date_from=date_from, date_to=date_to,
log=print, personal_only=personal_only, blocked_conv_ids=blocked_conv_ids)
print(f"[+] 媒体导出: 复制 {media_stats.get('copied', 0)} 个文件, "
f"UUID匹配 {media_stats.get('matched_uuid', 0)}, "
f"文件名匹配 {media_stats.get('matched_filename', 0)}, "
f"仅URL {media_stats.get('url_only', 0)}")
except Exception as e:
print(f" [警告] 媒体导出失败, Excel 将不显示媒体路径: {e}")
# 阶段 1.7: 语音转文字 (本地缓存 + 可选本地 ASR 兜底)
if voice2text_map is None:
print("\n[*] 读取语音转写缓存 (msg_voice2text)...")
voice2text_map = load_voice2text(decrypted_dbs, log=print)
if voice_asr and media_map:
print("\n[*] 本地识别无缓存语音...")
voice2text_map = asr_missing(voice2text_map, media_map, log=print)
def _get_voice_text(user_dir, server_id):
"""查语音转写文本 (兼容账号维度和全局 server_id 两种 key)"""
if not voice2text_map:
return ""
sid = str(server_id)
return voice2text_map.get((user_dir, sid)) or voice2text_map.get(("", sid)) or ""
# 阶段 2: 导出消息
for db_path, db_name, user_dir in decrypted_dbs:
if db_name != "message.db":
continue
try:
conn = connect_sqlite(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='message_table'")
if not cursor.fetchone():
conn.close()
continue
cursor.execute("PRAGMA table_info(message_table)")
columns = [row[1] for row in cursor.fetchall()]
print(f"\n[+] 导出消息: {user_dir}/message.db")
print(f" 字段: {', '.join(columns)}")
cursor.execute("SELECT * FROM message_table ORDER BY send_time ASC")
rows = cursor.fetchall()
print(f" 共 {len(rows)} 条消息")
exported = 0
skipped_non_personal = 0
skipped_blocked_name = 0
for row in rows:
msg_dict = dict(zip(columns, row))
conv_id = msg_dict.get("conversation_id", "")
sender_id = msg_dict.get("sender_id", "")
ct = msg_dict.get("content_type", "")
ts = msg_dict.get("send_time") or 0
time_str = format_timestamp(ts)
# 日期范围过滤 (含两端)
day = time_str[:10]
if date_from and day < date_from:
continue
if date_to and day > date_to:
continue
# 只导出单聊 (过滤群聊 R:/ 应用 Y:/ 第三方应用 O:/ 系统会话)
if personal_only and not is_personal_chat(conv_id):
skipped_non_personal += 1
continue
content = parse_content(msg_dict.get("content"))
extra = parse_content(msg_dict.get("extra_content"))
client_id = msg_dict.get("client_id", "")
sequence = msg_dict.get("sequence", "")
message_id = msg_dict.get("message_id", "")
server_id = msg_dict.get("server_id", "")
# extra_content 仅在 content 完全无文本时兜底 (短文本如 "在不" 是有效内容, 不能被覆盖)
if extra and not content.strip():
if not (extra.startswith('{') and '}' in extra) and 'http' not in extra[:8]:
content = extra
# 会话名称
conv_name = conv_cache.get((user_dir, str(conv_id)), "")
if not conv_name:
# 单聊: M:xxx -> 查找用户
if str(conv_id).startswith("M:"):
uid = str(conv_id)[2:]
conv_name = user_cache.get((user_dir, uid), uid)
elif str(conv_id).startswith("S:"):
# S:会话为单聊, 两个 UID (对方/本账号) 顺序不固定,
# 取不是本账号(user_dir)的那个作为对方
parts = str(conv_id)[2:].split("_")
peer_uid = ""
for p in parts:
if p != user_dir:
peer_uid = p
break
if not peer_uid:
peer_uid = parts[0] if parts else ""
peer_name = user_cache.get((user_dir, peer_uid), "")
conv_name = peer_name if peer_name else ("单聊 " + str(conv_id))
elif str(conv_id).startswith("O:"):
conv_name = str(conv_id)[2:]
elif str(conv_id).startswith("Y:"):
conv_name = "应用 " + str(conv_id)[2:]
else:
conv_name = str(conv_id)
# 按会话名称/ID过滤 (官方/系统账号, 如"企业微信团队"; 也可填会话ID如 13102691405879689)
if is_blocked_conv_name(conv_name, blocked_conv_names) \
or is_blocked_conv_name(str(conv_id), blocked_conv_names):
skipped_blocked_name += 1
continue
# 发送者名称
sender_name = user_cache.get((user_dir, str(sender_id)), "")
if not sender_name:
sender_name = str(sender_id) if sender_id else "系统"
# 媒体文件路径/URL (从阶段 1.6 的 media_map 中按 server_id 匹配)
media_info = media_map.get(str(server_id), {})
media_file_path = media_info.get("path", "")
media_url = media_info.get("url", "")
# 语音转文字: 语音消息 (.silk/.amr) 优先显示转写文本
voice_text = _get_voice_text(user_dir, server_id)
is_voice = str(media_file_path).lower().endswith((".silk", ".amr")) or \
(str(media_url or "").endswith((".silk", ".amr")))
if is_voice and voice_text:
content = voice_text
all_messages.append({
"账号": user_dir,
"会话ID": conv_id,
"会话名称": conv_name,
"时间": time_str,
"发送者ID": sender_id,
"发送者": sender_name,
"消息类型": get_msg_type_name(ct),
"内容": content,
"语音转文字": voice_text,
"媒体文件路径": media_file_path,
"媒体URL": media_url,
"消息序号": sequence,
"消息ID": message_id,
"服务器ID": server_id,
"客户端ID": client_id,
})
exported += 1
conn.close()
if skipped_non_personal or skipped_blocked_name:
print(f" 已导出 {exported} 条 (过滤非单聊 {skipped_non_personal} 条, "
f"过滤官方/系统账号 {skipped_blocked_name} 条)")
else:
print(f" 已导出 {exported} 条")
except sqlite3.Error as e:
print(f" [警告] 导出失败 {db_path}: {e}")
if not all_messages:
print("\n[-] 未找到任何消息数据")
return None
# 排序
all_messages.sort(key=lambda m: m["时间"])
fieldnames = ["账号", "会话ID", "会话名称", "时间", "发送者ID", "发送者",
"消息类型", "内容", "语音转文字", "媒体文件路径", "媒体URL",
"消息序号", "消息ID", "服务器ID", "客户端ID"]
# 按天/范围模式: 输出到 按天/ 目录
if date_from or date_to:
day_dir = os.path.join(out_dir, "按天")
os.makedirs(day_dir, exist_ok=True)
if date_from and date_from == date_to:
csv_path = os.path.join(day_dir, f"{date_from}_聊天记录.csv")
summary_path = os.path.join(day_dir, f"{date_from}_会话汇总.csv")
else:
frm = date_from or "2000-01-01"
to = date_to or datetime.now().strftime("%Y-%m-%d")
csv_path = os.path.join(day_dir, f"{frm}_至_{to}_聊天记录.csv")
summary_path = os.path.join(day_dir, f"{frm}_至_{to}_会话汇总.csv")
else:
csv_path = os.path.join(out_dir, "企业微信聊天记录.csv")
summary_path = os.path.join(out_dir, "会话汇总.csv")
# 主 CSV
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(all_messages)
print(f"\n[+] 主文件: {csv_path}")
print(f" 总消息数: {len(all_messages)}")
# 会话汇总
session_stats = {}
for m in all_messages:
key = (m["账号"], m["会话名称"])
if key not in session_stats:
session_stats[key] = {"账号": m["账号"], "会话": m["会话名称"],
"会话ID": m["会话ID"], "消息数": 0, "最早": m["时间"], "最晚": m["时间"]}
session_stats[key]["消息数"] += 1
if m["时间"] and (not session_stats[key]["最早"] or m["时间"] < session_stats[key]["最早"]):
session_stats[key]["最早"] = m["时间"]
if m["时间"] and m["时间"] > session_stats[key]["最晚"]:
session_stats[key]["最晚"] = m["时间"]
with open(summary_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["账号", "会话名称", "会话ID", "消息数", "最早消息", "最晚消息"])
for info in sorted(session_stats.values(), key=lambda x: -x["消息数"]):
writer.writerow([info["账号"], info["会话"], info["会话ID"],
info["消息数"], info["最早"], info["最晚"]])
print(f" 会话汇总: {summary_path} ({len(session_stats)} 个会话)")
# 按账号分文件 (仅全量模式)
if not date_from and not date_to:
by_account = {}
for m in all_messages:
by_account.setdefault(m["账号"], []).append(m)
for acc, msgs in by_account.items():
acc_path = os.path.join(out_dir, f"聊天记录_{acc}.csv")
with open(acc_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(msgs)
print(f" 账号 {acc}: {len(msgs)} 条 -> {acc_path}")
return csv_path
def main():
import argparse
parser = argparse.ArgumentParser(description="企业微信聊天记录导出")
parser.add_argument("--output", default=DEFAULT_OUTPUT, help="输出目录")
parser.add_argument("--db-dir", default=DEFAULT_DB_BASE, help="企业微信数据库根目录")
parser.add_argument("--date", default=None, help="导出指定日期 (YYYY-MM-DD)")
parser.add_argument("--days", type=int, default=None,
help="导出最近 N 天 (含今天), 如 --days 1 只导今天; 默认 1")
parser.add_argument("--all", action="store_true", help="全量导出所有历史记录")
parser.add_argument("--with-groups", action="store_true",
help="同时导出群聊/应用消息 (默认只导出单聊)")
args = parser.parse_args()
print("=" * 60)
print(" 企业微信聊天记录导出工具 v2.3 (按天/范围导出, 仅单聊)")
print("=" * 60)
keys_map = load_keys()
if not keys_map:
print("[-] 未找到密钥文件")
return 1
print(f"[+] 已加载 {len(keys_map)} 个账号密钥")
if args.with_groups:
print("[*] 范围: 全部会话 (单聊 + 群聊 + 应用)")
else:
print("[*] 范围: 仅单聊 (群聊/应用消息/第三方应用已过滤)")
# 导出模式: --all > --date > --days > 默认今天
date_from = date_to = None
if args.all:
print("[*] 模式: 全量导出")
elif args.date:
date_from = date_to = args.date
print(f"[*] 模式: 导出指定日期 {args.date}")
else:
days = args.days if args.days and args.days > 0 else 1
date_to = datetime.now().strftime("%Y-%m-%d")
date_from = (datetime.now() - timedelta(days=days - 1)).strftime("%Y-%m-%d")
if days == 1:
print(f"[*] 模式: 只导出今天 ({date_to})")
else:
print(f"[*] 模式: 导出最近 {days} 天 ({date_from} ~ {date_to})")
print(f"\n[*] 解密数据库...")
decrypted_dbs = decrypt_with_keys(
args.db_dir, os.path.join(args.output, "decrypted"), keys_map, use_cache=True)
print(f"[+] 成功解密 {len(decrypted_dbs)} 个数据库")
if not decrypted_dbs:
print("[-] 没有可用的数据库")
return 1
print(f"\n[*] 导出聊天记录...")
csv_path = export_messages(decrypted_dbs, args.output,
date_from=date_from, date_to=date_to,
personal_only=not args.with_groups)
if csv_path:
print(f"\n{'='*60}")
print(f" 导出完成! 主文件: {csv_path}")
print(f" 输出目录: {args.output}")
print(f"{'='*60}")
return 0
if __name__ == "__main__":
sys.exit(main())