# -*- 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