878 lines
35 KiB
Python
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())
|