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

308 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
企业微信媒体文件导出模块
从消息 content(protobuf) 中提取文件引用(UUID/CDN URL/文件名),
在本地 Cache 目录中查找匹配文件并复制到输出目录.
文件特性:
图片: PNG/JPG 明文, Cache/Image/ 按 UUID 命名
语音: SILK_V3 明文, Cache/Voice/ 按 UUID 命名
视频: MP4 明文, Cache/Video/ 混合命名
文件: 原始格式, Cache/File/ 用原始文件名
关联方式:
1. UUID 匹配 (图片/语音, 命中率低但有的话就关联)
2. 文件名匹配 (文件/视频, 按原始文件名搜索)
3. CDN URL 记录 (本地无缓存时, 记录 URL 供后续下载)
"""
import os
import re
import shutil
import sqlite3
import sys
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BASE_DIR)
# ---- 正则 ----
# UUID (在 raw bytes 中直接搜索 ASCII)
UUID_RE = re.compile(rb"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I)
# CDN URL (wework/mmbiz qpic 域名 + 常见媒体扩展名)
URL_RE = re.compile(
rb"https?://[^\x00-\x1f\x20\"'<>]+", re.I)
# 文件名 (在去掉 URL 的文本中搜索)
FN_RE = re.compile(
r"[\w\u4e00-\u9fff\u3000-\u303f\s\-+()【】\[\]()《》、,.]{2,80}"
r"\.(?:jpg|jpeg|png|gif|bmp|webp|mp4|mov|avi|mkv|pdf|docx?|xlsx?|pptx?|txt|zip|rar|7z|silk|amr|csv)",
re.I)
# Cache 子目录映射
CACHE_TYPE_MAP = {
"Image": "图片",
"Voice": "语音",
"Video": "视频",
"File": "文件",
}
def extract_media_refs(content):
"""从消息 content(bytes) 中提取媒体引用
返回:
{"uuids": [str], "urls": [str], "filenames": [str]}
"""
result = {"uuids": [], "urls": [], "filenames": []}
if not content or not isinstance(content, bytes):
return result
# 1. ASCII UUID (直接在 bytes 中搜索)
for m in UUID_RE.finditer(content):
uid = m.group(0).decode("ascii").lower()
if uid not in result["uuids"]:
result["uuids"].append(uid)
# 2. 解码为文本
text = content.decode("utf-8", errors="ignore")
# 3. CDN URL (先提取, 然后从文本中移除以便提取文件名)
for m in re.finditer(r"https?://[^\s\x00-\x1f\"'<>]+", text):
url = m.group(0).rstrip(".,;)")
# 只保留含媒体特征的 URL
if any(kw in url.lower() for kw in (
"qpic.cn", "wwcdn", "wework", "mmbiz",
".jpg", ".png", ".jpeg", ".gif", ".mp4", ".pdf",
".docx", ".xlsx", ".silk", ".amr"
)):
if url not in result["urls"]:
result["urls"].append(url)
# 4. 文件名 (从去掉 URL 的文本中搜索)
text_no_url = re.sub(r"https?://\S+", "", text)
for m in FN_RE.finditer(text_no_url):
fn = m.group(0).strip()
# 去掉路径前缀
fn = re.sub(r"^[./\\]+", "", fn).strip()
# 过滤太短或纯数字的
if len(fn) > 4 and fn not in result["filenames"]:
# 确保有扩展名
if "." in fn:
result["filenames"].append(fn)
return result
def build_cache_index(account_dir):
"""扫描账号 Cache 目录, 建立 UUID→文件 和 文件名→文件 索引
返回:
{"by_uuid": {uuid: (filepath, type)}, "by_name": {filename: (filepath, type)}}
"""
index = {"by_uuid": {}, "by_name": {}}
cache_dir = os.path.join(account_dir, "Cache")
if not os.path.isdir(cache_dir):
return index
for subdir in ("Image", "Voice", "Video", "File"):
d = os.path.join(cache_dir, subdir)
if not os.path.isdir(d):
continue
for root, dirs, files in os.walk(d):
for fn in files:
fp = os.path.join(root, fn)
fn_lower = fn.lower()
# 按 UUID 索引
m = re.match(
r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
fn, re.I)
if m:
index["by_uuid"][m.group(1).lower()] = (fp, subdir)
# 按文件名索引 (原始名 + 去扩展名)
index["by_name"][fn_lower] = (fp, subdir)
base = os.path.splitext(fn_lower)[0]
if base and base not in index["by_name"]:
index["by_name"][base] = (fp, subdir)
return index
def match_media(refs, cache_index):
"""根据 media refs 在 cache 索引中查找匹配文件
返回: (filepath, media_type, match_method) 或 (None, None, method)
match_method: "uuid" / "filename" / "fuzzy" / "url_only" / None
"""
# 1. UUID 精确匹配
for uid in refs["uuids"]:
if uid in cache_index["by_uuid"]:
fp, mtype = cache_index["by_uuid"][uid]
return (fp, mtype, "uuid")
# 2. 文件名精确匹配
for fn in refs["filenames"]:
fn_lower = fn.lower()
if fn_lower in cache_index["by_name"]:
fp, mtype = cache_index["by_name"][fn_lower]
return (fp, mtype, "filename")
# 去路径的 basename
bn = os.path.basename(fn_lower)
if bn in cache_index["by_name"]:
fp, mtype = cache_index["by_name"][bn]
return (fp, mtype, "filename")
# 3. 文件名模糊匹配 (包含关系, 要求长度>4 避免误匹配)
for fn in refs["filenames"]:
fn_lower = fn.lower()
if len(fn_lower) < 5:
continue
for cache_fn, (fp, mtype) in cache_index["by_name"].items():
if fn_lower in cache_fn or cache_fn in fn_lower:
return (fp, mtype, "fuzzy")
# 4. 只有 URL, 无本地文件
if refs["urls"]:
return (None, None, "url_only")
return (None, None, None)
def export_media(decrypted_dbs, out_dir, date_from=None, date_to=None, log=print,
personal_only=True, blocked_conv_ids=None):
"""导出媒体文件, 返回 {server_id: media_info} 和统计
参数:
decrypted_dbs: [(db_path, db_name, account), ...]
out_dir: 输出根目录
date_from / date_to: "YYYY-MM-DD" 日期范围 (None=不过滤)
log: 日志函数
personal_only: True=只导出单聊会话 (M:/S:) 的媒体 (默认); False=全部会话
blocked_conv_ids: 会话ID黑名单集合 (按会话名称过滤出的官方/系统账号), 命中即跳过
"""
# 延迟导入, 避免与 wxwork_export_final 循环引用
from wxwork_export_final import DEFAULT_DB_BASE
blocked_conv_ids = set(blocked_conv_ids or ())
media_dir = os.path.join(out_dir, "媒体文件")
for sub in ("图片", "语音", "视频", "文件"):
os.makedirs(os.path.join(media_dir, sub), exist_ok=True)
result = {} # server_id -> media_info
stats = {
"total_media_msg": 0,
"matched_uuid": 0,
"matched_filename": 0,
"matched_fuzzy": 0,
"copied": 0,
"url_only": 0,
}
msg_dbs = [(d, a) for d, n, a in decrypted_dbs if n == "message.db"]
if not msg_dbs:
log("[-] 未找到 message.db, 跳过媒体导出")
return result, stats
for db_path, account in msg_dbs:
account_dir = os.path.join(DEFAULT_DB_BASE, account)
cache_index = build_cache_index(account_dir)
n_uuid = len(cache_index["by_uuid"])
n_name = len(cache_index["by_name"])
log(f" 账号 {account}: Cache 索引 {n_uuid} UUID + {n_name} 文件名")
conn = sqlite3.connect(db_path)
try:
cols = [r[1] for r in conn.execute(
"PRAGMA table_info(message_table)").fetchall()]
ct_idx = cols.index("content_type")
content_idx = cols.index("content")
sid_idx = (cols.index("server_id") if "server_id" in cols
else cols.index("message_id"))
conv_idx = (cols.index("conversation_id")
if "conversation_id" in cols else None)
time_col = "send_time" if "send_time" in cols else None
# 日期过滤
where = ""
params = ()
if date_from and time_col:
# send_time 是毫秒时间戳, 转为整数比较
from datetime import datetime as _dt
ts_from = int(_dt.strptime(date_from, "%Y-%m-%d").timestamp())
ts_to = int(_dt.strptime(date_to, "%Y-%m-%d").timestamp()) + 86399
where = f" WHERE {time_col} >= ? AND {time_col} <= ?"
params = (ts_from, ts_to)
# 只导出单聊会话 (M:xxx / S:xxx) 的媒体
if personal_only:
conv_filter = "conversation_id LIKE 'M:%' OR conversation_id LIKE 'S:%'"
where = (where + f" AND ({conv_filter})") if where \
else f" WHERE {conv_filter}"
rows = conn.execute(
f"SELECT * FROM message_table{where} ORDER BY {time_col or 'rowid'}",
params).fetchall()
for row in rows:
ct = row[ct_idx]
content = row[content_idx]
sid = row[sid_idx]
# 按会话名称过滤出的黑名单会话ID, 跳过
if blocked_conv_ids and conv_idx is not None \
and str(row[conv_idx]) in blocked_conv_ids:
continue
refs = extract_media_refs(content)
if not (refs["uuids"] or refs["urls"] or refs["filenames"]):
continue
stats["total_media_msg"] += 1
fp, mtype, method = match_media(refs, cache_index)
if fp and os.path.exists(fp):
if method == "uuid":
stats["matched_uuid"] += 1
elif method == "filename":
stats["matched_filename"] += 1
elif method == "fuzzy":
stats["matched_fuzzy"] += 1
# 复制文件
dest_sub = CACHE_TYPE_MAP.get(mtype, "文件")
dest_name = f"{sid}_{os.path.basename(fp)}"
dest_path = os.path.join(media_dir, dest_sub, dest_name)
if not os.path.exists(dest_path):
try:
shutil.copy2(fp, dest_path)
stats["copied"] += 1
except Exception as e:
log(f" [!] 复制失败 {fp}: {e}")
dest_path = fp # 退回原路径
result[str(sid)] = {
"path": os.path.relpath(dest_path, out_dir),
"type": dest_sub,
"url": refs["urls"][0] if refs["urls"] else "",
"method": method,
"filename": os.path.basename(fp),
}
elif method == "url_only":
stats["url_only"] += 1
result[str(sid)] = {
"path": "",
"type": "",
"url": refs["urls"][0] if refs["urls"] else "",
"method": "url_only",
"filename": "",
}
except sqlite3.Error as e:
log(f" [警告] {db_path}: {e}")
finally:
conn.close()
return result, stats