from __future__ import annotations from datetime import datetime, timedelta from pathlib import Path import math import re import shutil import time from PIL import Image from wechatauto.uia import ControlFromHandle, GetUiClassNameWithTimeout, QT_INTERNAL_WIN_CLASS_PREFIX from wechatauto.utils.win32 import GetAllWindows def get_file_dir(dir_path=None) -> Path: if dir_path is None: dir_path = Path('.').absolute() elif isinstance(dir_path, str): dir_path = Path(dir_path) dir_path.mkdir(parents=True, exist_ok=True) return dir_path def find_window_from_root(classname=None, name=None, pid: int = None, uiaclsname: str = None, timeout=1): t0 = time.time() while True: wins = find_all_windows_from_root(classname, name, pid, uiaclsname) if len(wins) > 0: return wins[0] if time.time() - t0 > timeout: return None def find_all_windows_from_root(classname: str = None, name: str = None, pid: int = None, uiaclsname: str = None): """从全部顶层窗口中找到满足条件的 UIA 控件。 Args: classname: win32 窗口类名 name: win32 窗口标题 pid: 进程ID uiaclsname: UIA 类名(如 mmui::MainWindow),优先于 win32 类名使用 """ windows = GetAllWindows() targets = [] for window in windows: # 跳过 Qt 内部消息泵等隐藏窗口,其 UIA 服务可能阻塞调用 if window[1].startswith(QT_INTERNAL_WIN_CLASS_PREFIX): continue if ( (all((classname, name)) and classname == window[1] and name == window[2]) or (all((classname, not name)) and classname == window[1]) or (all((not classname, name)) and name == window[2]) or (all((not classname, not name))) ): try: if uiaclsname is not None: if GetUiClassNameWithTimeout(window[0]) != uiaclsname: continue targets.append(ControlFromHandle(window[0])) except Exception: continue if pid: targets = [w for w in targets if w and w.ProcessId == pid] if uiaclsname: targets = [w for w in targets if w and w.ClassName == uiaclsname] return targets def now_time(fmt='%Y%m%d%H%M%S%f') -> str: return datetime.now().strftime(fmt) def parse_wechat_time(time_str: str) -> str: """微信消息时间格式转换函数 Args: time_str: 输入的时间字符串 Returns: 转换后的时间字符串 """ time_str = time_str.replace('星期天', '星期日') match = re.match(r'^(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$', time_str) if match: month, day, hour, minute, second = match.groups() current_year = datetime.now().year return datetime(current_year, int(month), int(day), int(hour), int(minute), int(second)).strftime('%Y-%m-%d %H:%M:%S') match = re.match(r'^(\d{1,2}):(\d{1,2})$', time_str) if match: hour, minute = match.groups() return datetime.now().strftime('%Y-%m-%d') + f' {hour}:{minute}:00' match = re.match(r'^昨天 (\d{1,2}):(\d{1,2})$', time_str) if match: hour, minute = match.groups() yesterday = datetime.now() - timedelta(days=1) return yesterday.strftime('%Y-%m-%d') + f' {hour}:{minute}:00' match = re.match(r'^星期([一二三四五六日]) (\d{1,2}):(\d{1,2})$', time_str) if match: weekday, hour, minute = match.groups() weekday_num = ['一', '二', '三', '四', '五', '六', '日'].index(weekday) today_weekday = datetime.now().weekday() delta_days = (today_weekday - weekday_num) % 7 target_day = datetime.now() - timedelta(days=delta_days) return target_day.strftime('%Y-%m-%d') + f' {hour}:{minute}:00' match = re.match(r'^(\d{4})年(\d{1,2})月(\d{1,2})日 (\d{1,2}):(\d{1,2})$', time_str) if match: year, month, day, hour, minute = match.groups() return datetime(*[int(i) for i in [year, month, day, hour, minute]]).strftime('%Y-%m-%d %H:%M:%S') match = re.match(r'^(\d{2})-(\d{2}) (上午|下午) (\d{1,2}):(\d{2})$', time_str) if match: month, day, period, hour, minute = match.groups() current_year = datetime.now().year hour = int(hour) if period == '下午' and hour != 12: hour += 12 elif period == '上午' and hour == 12: hour = 0 return datetime(current_year, int(month), int(day), hour, int(minute)).strftime('%Y-%m-%d %H:%M:%S') return time_str def is_valid_image(file_path) -> bool: path = Path(file_path) if not path.exists() or not path.is_file(): return False try: with Image.open(path) as img: img.verify() # 只验证图像,不会完全解码 return True except Exception: return False def delete_update_files(): """清理微信更新缓存,避免更新弹窗干扰自动化操作""" home = Path.home() update_dir = home / 'AppData' / 'Roaming' / 'Tencent' / 'xwechat' / 'update' if update_dir.exists(): for file in update_dir.iterdir(): try: shutil.rmtree(file) if file.is_dir() else file.unlink() except Exception: pass # ============================================================================================================================================ # 消息解析方法 # ============================================================================================================================================ def detect_message_direction( image_path: str, avatar_height_ratio: float = 0.8, tolerance: int = 0, ) -> tuple[str, float]: """通过截图判断消息气泡的方向。 Args: image_path: 消息截图路径。 avatar_height_ratio: 头像在截图中占据的高度比例。 tolerance: 像素颜色比较的容忍度。 Returns: Tuple[str, float]: ``("left", distance)`` 或 ``("right", distance)``, ``distance`` 表示从对应方向开始出现气泡的列索引,便于后续定位。 """ img = Image.open(image_path) if img.mode != 'RGB': img = img.convert('RGB') w, h = img.size # 仅取中间 band 区域 band_h = int(h * avatar_height_ratio) y0 = (h - band_h) // 2 y1 = y0 + band_h pixels = img.load() # 获取像素访问对象 def is_uniform_column(x: int) -> bool: base = pixels[x, y0] # 以 band 顶部像素作为参考 for y in range(y0, y1): r, g, b = pixels[x, y] if (abs(r - base[0]) > tolerance or abs(g - base[1]) > tolerance or abs(b - base[2]) > tolerance): return False return True # 从左边扫描 left_idx = math.inf for x in range(w): if not is_uniform_column(x): left_idx = x break # 从右边扫描 right_idx = math.inf for offset, x in enumerate(range(w - 1, -1, -1)): if not is_uniform_column(x): right_idx = offset # 距右边界的列数 break if left_idx == math.inf and right_idx == math.inf: # 都没找到变化列,兜底 return 'right', math.inf if left_idx <= right_idx: return 'left', float(left_idx) return 'right', float(right_idx) def calculate_pixel_variance(region) -> float: """计算图像区域的像素变化程度""" if region.size[0] == 0 or region.size[1] == 0: return 0 # 获取所有像素值 pixels = list(region.getdata()) if not pixels: return 0 # 分别计算R、G、B通道的方差 r_values = [p[0] for p in pixels] g_values = [p[1] for p in pixels] b_values = [p[2] for p in pixels] r_variance = calculate_variance(r_values) g_variance = calculate_variance(g_values) b_variance = calculate_variance(b_values) return r_variance + g_variance + b_variance def calculate_variance(values) -> float: """计算数值列表的方差""" if not values: return 0 # 计算平均值 mean = sum(values) / len(values) # 计算方差 variance = sum((x - mean) ** 2 for x in values) / len(values) return variance def calculate_color_diversity(region) -> float: """计算区域颜色多样性(备用方法)""" pixels = list(region.getdata()) if not pixels: return 0 # 统计不同颜色的数量 color_set = set(pixels) unique_colors = len(color_set) # 计算颜色多样性比例 diversity_ratio = unique_colors / len(pixels) return diversity_ratio def detect_message_direction_enhanced( image_path: str, avatar_width_ratio: float = 0.15, avatar_height_ratio: float = 0.8, ) -> tuple[str, float]: """增强版检测,结合方差和颜色多样性""" img = Image.open(image_path).convert('RGB') width, height = img.size avatar_width = int(width * avatar_width_ratio) avatar_height = int(height * avatar_height_ratio) avatar_start_y = (height - avatar_height) // 2 avatar_end_y = avatar_start_y + avatar_height # 截取左右头像区域 left_box = (0, avatar_start_y, avatar_width, avatar_end_y) right_box = (width - avatar_width, avatar_start_y, width, avatar_end_y) left_region = img.crop(left_box) right_region = img.crop(right_box) # 计算方差和颜色多样性 left_variance = calculate_pixel_variance(left_region) right_variance = calculate_pixel_variance(right_region) left_diversity = calculate_color_diversity(left_region) right_diversity = calculate_color_diversity(right_region) # 综合评分(方差权重0.7,多样性权重0.3) left_score = left_variance * 0.7 + left_diversity * 1000 * 0.3 right_score = right_variance * 0.7 + right_diversity * 1000 * 0.3 if left_score > right_score: return 'left', float(left_score) return 'right', float(right_score) def batch_detect_messages(image_paths, method='basic', **kwargs): """批量检测多条消息的方向""" results = [] detect_func = detect_message_direction if method == 'basic' else detect_message_direction_enhanced for path in image_paths: try: result = detect_func(path, **kwargs) if isinstance(result, tuple): direction, distance = result else: direction, distance = result, None sender = '对方' if direction == 'left' else '自己' results.append({ 'path': path, 'direction': direction, 'sender': sender, 'distance': distance, }) except Exception as e: results.append({ 'path': path, 'direction': 'unknown', 'sender': '未知', 'error': str(e) }) return results