"""消息监控 — 全列表指纹对比 + 窗口冻结检测 + ControlFromHandle 强制刷新""" import time import logging import threading import traceback import unicodedata import faulthandler from typing import Optional, Callable, List faulthandler.enable() # 捕获 C 扩展段错误,输出 traceback 到 stderr logging.getLogger("comtypes").setLevel(logging.WARNING) logger = logging.getLogger("msgbot.monitor") # ⚠ 顺序重要: torch/easyocr 必须在 COM 模块 (uiautomation/winrt) 之前导入, # 否则 c10.dll 会因 COM 初始化冲突抛出 WinError 1114。 _HAS_EASYOCR = False _easyocr = None _np = None try: import easyocr as _easyocr import numpy as _np _HAS_EASYOCR = True except (ImportError, OSError) as _e: import traceback as _tb _easyocr = None _np = None logger.debug(f"[ocr] EasyOCR 导入失败: {type(_e).__name__}: {_e}") logger.debug(f"[ocr] Traceback: {_tb.format_exc()}") import uiautomation as auto import win32gui import win32con import win32api import win32process import re import sys import ctypes import difflib import os # ⭐ 偏旁部首 Unicode 库:从 unicode_radicals.md 读取,用于 OCR 碎片过滤 _RADICAL_CHARS: set = set() _RADICALS_FILE = os.path.join(os.path.dirname(__file__), 'unicode_radicals.md') def _load_radicals(): """从 unicode_radicals.md 加载偏旁字符集""" global _RADICAL_CHARS try: with open(_RADICALS_FILE, 'r', encoding='utf-8') as f: for line in f: # 匹配 "亻 | U+2E80" 格式 m = re.match(r'^.\s*\|\s*U\+([0-9A-Fa-f]{4})', line.strip()) if m: _RADICAL_CHARS.add(chr(int(m.group(1), 16))) logger.info(f"[radicals] 已加载偏旁库: {len(_RADICAL_CHARS)} 个偏旁") except Exception as e: logger.warning(f"[radicals] 偏旁库加载失败: {e}, 使用默认范围 U+2E80-U+2FDF") # 降级:使用默认范围 _RADICAL_CHARS = set(chr(i) for i in range(0x2E80, 0x2FE0)) # 启动时加载 _load_radicals() import subprocess import json import psutil import os import random from datetime import datetime import signal from PIL import Image, ImageGrab, ImageEnhance, ImageFilter from winrt.windows.media.ocr import OcrEngine from winrt.windows.globalization import Language from winrt.windows.graphics.imaging import SoftwareBitmap, BitmapPixelFormat if not _HAS_EASYOCR: logger.debug("[ocr] EasyOCR 未就绪,仅使用 Windows OCR") # ===== 主动防御: 全平台 UI 按钮/标签/面板标题黑名单 ===== # 微信/TIM/QQ 各界面按钮、标签、状态栏文本,OCR 读到后直接丢弃。 # 仅精确匹配(整行),零误杀风险。 _UI_EXACT_BLOCKLIST = frozenset({ # — 微信 顶栏/导航 — '微信', '通讯录', '发现', '我', # — 微信 发现页 — '朋友圈', '视频号', '看一看', '搜一搜', '直播', '附近', '扫一扫', '摇一摇', '小程序', # — 微信 我页 — '服务', '购物', '收藏', '卡包', '表情', '游戏中心', '设置', '切换账号', '退出登录', # — 微信 设置页 — '开启群聊验证', '朋友权限', '停用', '更新', '存储空间', '清空缓存', '帮助与反馈', # — 微信 桌面版面板 — '小程序面板', '朋友圈', # 紧凑版面板标题 # — 微信 聊天会话列表 — '新的朋友', '群聊', '标签', '公众号', '我的设备', '我的收藏', # — 微信 输入栏 — '输入', '按住说话', '松开发送', # — 微信 聊天附加功能 — '视频通话', '语音通话', '个人名片', '位置共享', '红包', '转账', '相机', '相册', '文件', '音乐', # — 微信 群聊信息面板 — '群聊名称', '群二维码', '群公告', '群聊信息', '群成员', '全部', '未读', '置顶', '免打扰', '置顶聊天', '我在本群的昵称', '显示群成员昵称', '聊天记录', '查找聊天记录', '聊天文件', '清空聊天记录', '投诉', '成员', '邀请', '添加成员', '移出群聊', '解散群聊', # — 微信 公众号 — '关注', '赞', '在看', '分享', '留言', '作者', '阅读原文', '不让看', '取消关注', # — TIM/QQ 顶栏/侧栏 — '消息', '联系人', '勋章墙', '收藏', '文件', '设置', # — TIM/QQ 状态 — '在线', '离线', '忙碌', '隐身', 'Q我吧', '离开', # — TIM/QQ 搜索 — '搜索', '查找', '搜索聊天记录', # — TIM/QQ 联系人/群管理 — '我的设备', '我的收藏', '最近联系人', '创建群聊', '添加好友', '添加群', '添加群聊', '创建讨论组', # — TIM/QQ 聊天窗口 — '截图', '文件传输', '远程桌面', '远程控制', '发送文件', '发送图片', '关闭', '最小化', '最大化', # — TIM/QQ 面板 — 'QQ会员', 'QQ空间', '应用宝', '兴趣部落', '同城服务', '我的文件', '网络硬盘', '收藏夹', '好友动态', # — 自定义插件 — '启用监控', '停止监控', '监控已启用', '监控已停止', # — 通用系统提示 — '加载中', '加载失败', '正在连接', '连接失败', '网络连接成功', '网络连接断开', '连接中', '已连接', '已断开', '重新连接', '消息发送失败', '消息已发出', '对方正在输入', '点击查看', '点击进入', '已显示全部消息', '以上是历史消息', '你已添加了', '现在可以开始聊天了', '当前网络不可用', '请检查网络设置', '操作失败', '操作成功', '请求超时', # — Smart-Dump 漏网 — '手机', }) # ===== 主动防御: UI 子串黑名单(含其中任何子串即跳过)===== # 这些词在用户日常聊天中极不可能出现,只在 UI 面板/系统标签中出现。 # 防止 OCR 碎片精确匹配失败但子串命中。 _UI_SUBSTR_BLOCKLIST = frozenset({ '启用监控', '停止监控', '数据看板', '应用设置', '债券总和', '注册用户总数', 'php管理员', '消息漫游', # QQ 群"共同好友"推荐(非聊天内容,OCR误读触发查库) '共同好友', '点击添加好友', }) class MessageMonitor: """多线程消息监控器""" def __init__(self, on_message: Optional[Callable[[str, str], None]] = None): self._running = False self._thread: Optional[threading.Thread] = None self._poll_interval = 2.0 self._on_message = on_message self._wechat_enabled = True self._qq_enabled = False # 全列表签名 & 完整文本列表 (用于新消息比对) self._last_sig: dict = {} self._last_texts: dict = {} # 最新消息文本(供 GUI 展示) self._last_msg: dict = {} # 轮询计数 self._poll_count: dict = {} # QQ 轮询跳过计数(每 3 周期 poll 一次) self._qq_skip_counter = 0 # 窗口句柄缓存(存 HWND,不存 Control 引用) self._cached_hwnd: dict = {} # 窗口对应进程 ID(验证窗口是否仍属同一进程) self._cached_pid: dict = {} # 窗口未找到冷却截止时间(time.time 值),期内跳过搜索 self._window_not_found_until: dict = {} # 连续相同签名计数(用于冻结检测) self._stale_count: dict = {} # 连续空读 self._empty_reads: dict = {} # 剪贴板原始文本(用于自动回复提取发送者) self._raw_clipboard: dict = {} # 发送中事件锁(防止自动回复时监控轮询干扰) self._sending_event = threading.Event() # 应用变体:qq→"qq_nt"或"tim" self._app_variant: dict = {} # 已处理消息的全局持久化集合(跨平台、跨轮次,防回环) self._seen_messages: set = set() # 已回复文本的永久集—永不裁切(剪贴板读到自己回复时必拦) self._replied_texts: set = set() # 上次回复时间戳(冷却期内跳过该平台 poll) self._last_reply_time: dict = {} # 同平台新消息冷却:同一 poll 或相邻 poll 内多条消息只处理第一条 self._last_new_msg_time: dict = {} self._new_msg_cooldown: float = 3.0 # 同平台 3 秒内不重复处理 # 发送失败消息冷却:msg_hash → 失败时间,冷却期内不再尝试回复(防止卡死) self._failed_send_cooldown: dict = {} # platform → timestamp # 跨平台前台冷却:某平台刚切过前台(回复后),对端平台等待此时间再截图 self._foreground_cooldown_until: dict = {} # platform → float(time.time) # 同平台回复冷却:回复后此秒数内同平台跳过 OCR(防读到自身回复回声) self._self_reply_cooldown_until: dict = {} # platform → float(time.time) # 本轮选中的 OCR 引擎(用于日志标记) self._last_chosen_engine: dict = {} # platform → "windows"|"easyocr" # OCR 引擎(Windows 原生,lazy init) self._ocr_engine = None # EasyOCR 备用引擎(lazy init) self._easyocr_reader = None # EasyOCR 子进程(GPU crash 隔离,崩溃只杀子进程不冻电脑) self._easyocr_proc: Optional['subprocess.Popen'] = None self._easyocr_broken: bool = False # EasyOCR 子进程调用计数:累积 GPU 内存泄漏,每 MAX 次主动重启清缓存 self._easyocr_call_count: int = 0 self._easyocr_max_calls: int = 3 # 最多 3 次后重启子进程 # 引擎选择计数器 self._ocr_engine_wins: dict = {"windows": 0, "easyocr": 0} # 上次 OCR 原始文本(用于增量检测) self._ocr_last_text: dict = {} # OCR 垃圾连续计数(连续垃圾超过阈值清缓存重找) self._ocr_garbage_count: dict = {} # 截图 hash(像素级,截图不变→OCR 必然无新消息) self._last_screenshot_hash: dict = {} # platform → str self._ocr_unchanged: dict = {} # platform → bool(本轮因截图不变跳过) self._last_ocr_time: dict = {} # platform → float(上次真正做OCR的时间戳) # EasyOCR 子进程锁(QQ/微信独立线程共用同一子进程) self._easyocr_lock = threading.Lock() # QQ 独立轮询线程(不阻塞微信主循环) self._qq_thread: Optional[threading.Thread] = None # 双击 ESC 急停线程 self._emergency_thread: Optional[threading.Thread] = None # 剪贴板内容 hash(防止跨平台读到过时数据) self._last_clipboard_hash: dict = {} # platform → str(hash) # 紧急停止回调(通知 GUI 更新状态) self._emergency_stop_callback: Optional[Callable[[], None]] = None # 底层键盘钩子回调引用(防止 GC 回收导致系统崩溃) self._esc_hook_proc = None def set_poll_interval(self, seconds: float): self._poll_interval = max(0.5, seconds) def set_enabled_platforms(self, wechat: bool = True, qq: bool = False): self._wechat_enabled = wechat self._qq_enabled = qq def set_callback(self, cb: Callable[[str, str], None]): self._on_message = cb def set_emergency_callback(self, cb: Callable[[], None]): self._emergency_stop_callback = cb def start(self): if self._running: return self._running = True # EasyOCR 已在子进程隔离(easyocr_worker.py),不需在主线程预加载 self._thread = threading.Thread(target=self._run_loop, daemon=True, name="monitor") self._thread.start() # QQ 独立轮询线程,不阻塞微信主循环(WeChat EasyOCR ~15-20s 每轮) self._qq_thread = threading.Thread(target=self._qq_poll_loop, daemon=True, name="monitor-qq") self._qq_thread.start() # 双击 ESC 急停检测线程 self._emergency_thread = threading.Thread(target=self._esc_check_loop, daemon=True, name="monitor-esc") self._emergency_thread.start() logger.info("消息监控已启动") def stop(self): self._running = False if self._thread and self._thread.is_alive(): self._thread.join(timeout=3) self._thread = None if self._qq_thread and self._qq_thread.is_alive(): self._qq_thread.join(timeout=3) self._qq_thread = None if self._emergency_thread and self._emergency_thread.is_alive(): # Post WM_QUIT 唤醒 GetMessage 阻塞,让钩子线程退出 try: ctypes.windll.user32.PostThreadMessageW( self._emergency_thread.ident, 0x0012, 0, 0 # WM_QUIT = 0x0012 ) except Exception: pass self._emergency_thread.join(timeout=2) self._emergency_thread = None # 杀死 EasyOCR 子进程(防止 GPU 推理残留吃资源/冻电脑) try: if self._easyocr_proc and self._easyocr_proc.poll() is None: self._easyocr_proc.kill() self._easyocr_proc.wait(timeout=3) logger.info("[stop] EasyOCR 子进程已杀死") except Exception as e: logger.debug(f"[stop] 杀 EasyOCR 异常: {e}") self._easyocr_proc = None # 释放钩子回调引用(防止 GC 延迟导致卸载后崩溃) if hasattr(self, '_esc_hook_proc'): del self._esc_hook_proc self._cached_hwnd.clear() self._cached_pid.clear() self._last_sig.clear() self._last_msg.clear() self._stale_count.clear() self._empty_reads.clear() self._poll_count.clear() logger.info("消息监控已停止") @property def is_running(self) -> bool: return self._running # ── 轮询主循环 ────────────────────────────────────────── def _run_loop(self): # 创建 Windows 消息队列(使 AttachThreadInput / SetForegroundWindow 在 daemon 线程生效) try: ctypes.windll.user32.PeekMessageW(None, 0, 0, 0, 1) # PM_NOREMOVE except Exception: pass heartbeat = 0 logger.debug("_run_loop 进入 while 循环") while self._running: try: if self._wechat_enabled and not self._sending_event.is_set(): self._poll("wechat") except Exception as e: logger.error(f"轮询异常: {e}\n{traceback.format_exc()}") heartbeat += 1 if heartbeat % 10 == 0: logger.info(f"[♡] 心跳 #{heartbeat}") time.sleep(self._poll_interval) def _qq_poll_loop(self): """QQ 独立轮询线程:不阻塞微信 OCR,每轮间隔 ~2s。 QQ OCR 截图通常较小(裁剪后),EasyOCR 耗时 1-3s; 微信 OCR 每轮 ~15-20s。分开线程后 QQ 响应从 ~34s 降到 ~3-5s。 """ try: ctypes.windll.user32.PeekMessageW(None, 0, 0, 0, 1) except Exception: pass # 随机 stagger 3-5s,避免 QQ/微信同时 OCR 抢镜 stagger = random.uniform(3, 5) logger.debug(f"[qq] 轮询延迟 {stagger:.1f}s 与微信错开") time.sleep(stagger) while self._running: try: if self._qq_enabled and not self._sending_event.is_set(): self._poll("qq") except Exception as e: logger.error(f"QQ轮询异常: {e}\n{traceback.format_exc()}") time.sleep(self._poll_interval) # ── 双击 ESC 急停 ────────────────────────────────────── def _esc_check_loop(self): """独立 daemon 线程:RegisterHotKey 全局热键检测双击 ESC → 强制急停。 使用 RegisterHotKey 注册全局热键 + GetMessage 消息泵, 不依赖窗口焦点,任何进程按 ESC 都能捕获。 比 WH_KEYBOARD_LL 更可靠(避免 ctypes 64位调用约定问题)。 """ from ctypes import wintypes _VK_ESCAPE = 0x1B _WM_HOTKEY = 0x0312 _HOTKEY_ID_ESC = 0x4553 # 'ES' # ⭐ 步骤1: 创建消息队列 _msg = wintypes.MSG() ctypes.windll.user32.PeekMessageW(ctypes.byref(_msg), None, 0, 0, 0) # ⭐ 步骤2: 注册全局热键(hwnd=NULL → 投递到线程消息队列) # MOD_NOREPEAT = 0x4000:长按不重复触发,只有按下时触发一次 ok = ctypes.windll.user32.RegisterHotKey(None, _HOTKEY_ID_ESC, 0x4000, _VK_ESCAPE) if not ok: err = ctypes.windll.kernel32.GetLastError() # err=1409 = ERROR_HOTKEY_ALREADY_REGISTERED,其他热键已占 ESC logger.error("[急停] RegisterHotKey 失败 (err=%d) — ESC 急停不可用", err) return logger.info("[急停] 全局热键注册成功,双击 ESC 急停已激活") # ⭐ 步骤3: 消息泵 — 检测双击 ESC last_esc_time = 0.0 while self._running: ret = ctypes.windll.user32.GetMessageW(ctypes.byref(_msg), None, 0, 0) if ret <= 0: break if _msg.message == _WM_HOTKEY and _msg.wParam == _HOTKEY_ID_ESC: now = time.time() if last_esc_time > 0 and (now - last_esc_time) < 0.5: last_esc_time = 0.0 logger.warning("⚠️ 双击ESC急停触发!强制终止所有操作...") self._emergency_stop() else: last_esc_time = now # 线程退出 → 注销热键 ctypes.windll.user32.UnregisterHotKey(None, _HOTKEY_ID_ESC) def _emergency_stop(self): """强制停止监控+杀死 EasyOCR+清空发送锁。""" self._running = False # 释放发送锁(防止被 hang 的发送操作卡住) self._sending_event.clear() # 杀死 EasyOCR 子进程(GPU 推理可能卡死不响应) try: if self._easyocr_proc and self._easyocr_proc.poll() is None: self._easyocr_proc.kill() self._easyocr_proc.wait(timeout=2) logger.warning("[急停] EasyOCR 子进程已杀死") except Exception as e: logger.debug(f"[急停] 杀 EasyOCR 异常: {e}") # 通知回调(GUI 更新状态) try: if self._emergency_stop_callback: self._emergency_stop_callback() except Exception as e: logger.debug(f"[急停] 回调异常: {e}") logger.warning("⚠️ 急停完成 — 消息监控已停止,点击开关可重新启动") # ── 窗口定位 ───────────────────────────────────────────── def _ensure_hwnd(self, platform: str) -> Optional[int]: """查找窗口并返回 HWND(不缓存 Control)。""" hwnd = self._cached_hwnd.get(platform) try: if hwnd: if win32gui.IsWindow(hwnd): return hwnd except Exception: pass self._cached_hwnd.pop(platform, None) self._cached_pid.pop(platform, None) # 非阻塞:窗口未找到冷却期内直接跳过搜索,不浪费 12s 超时 cooldown_until = self._window_not_found_until.get(platform, 0) if time.time() < cooldown_until: return None if platform == "wechat": for name in ["微信", "WeChat"]: try: w = auto.WindowControl(Name=name) if w.Exists(maxSearchSeconds=0.5): rect = w.BoundingRectangle if rect.width() > 200 and rect.height() > 100: h = w.NativeWindowHandle # 确保存顶层窗口句柄(与 GetForegroundWindow 返回值一致) top = win32gui.GetAncestor(h, win32con.GA_ROOT) # 窗口不可见(最小化/完全遮挡)→ 截图会取到覆盖窗口内容 if not win32gui.IsWindowVisible(top): continue self._cached_hwnd[platform] = top self._cached_pid[platform] = win32process.GetWindowThreadProcessId(top)[1] self._window_not_found_until.pop(platform, None) logger.info(f"[{platform}] 窗口已定位: {name} handle={top}") return top except Exception: pass elif platform == "qq": # 只按进程名找窗口,不管标题是什么。 # TIM/QQ 标题是动态的(跟随会话显示),任何标题都合法。 for proc_name in ["TIM.exe", "QQ.exe"]: try: for proc in psutil.process_iter(['name', 'pid']): if proc.info['name'] and proc.info['name'].lower() == proc_name.lower(): pid = proc.info['pid'] pid_windows = [] def _pid_enum_cb(hwnd, results, _pid=pid): try: _, found_pid = win32process.GetWindowThreadProcessId(hwnd) if found_pid == _pid: top = win32gui.GetAncestor(hwnd, win32con.GA_ROOT) if top not in results: results.append(top) except Exception: pass return True win32gui.EnumWindows(_pid_enum_cb, pid_windows) # 所有属于该进程的顶层窗口中,取第一个尺寸>=聊天区的、可见的 for top in pid_windows: if not (win32gui.IsWindow(top) and win32gui.IsWindowVisible(top)): continue rect = win32gui.GetWindowRect(top) if (rect[2] - rect[0]) > 200 and (rect[3] - rect[1]) > 100: variant = "tim" if "TIM" in proc_name.upper() else "qq_nt" self._app_variant[platform] = variant self._cached_hwnd[platform] = top self._cached_pid[platform] = pid self._window_not_found_until.pop(platform, None) title = win32gui.GetWindowText(top) logger.info(f"[{platform}] 窗口已定位: {proc_name} pid={pid} title={title!r} top={top}") return top except Exception: continue # 兜底:UIA 搜索 TXGuiFoundation 类名窗口(进程已启动但窗口尚未就绪时) try: root = auto.GetRootControl() for child in root.GetChildren(): try: if child.ClassName == "TXGuiFoundation": rect = child.BoundingRectangle if rect.width() > 200 and rect.height() > 100: h = child.NativeWindowHandle top = win32gui.GetAncestor(h, win32con.GA_ROOT) pid = win32process.GetWindowThreadProcessId(top)[1] self._app_variant[platform] = "qq_nt" self._cached_hwnd[platform] = top self._cached_pid[platform] = pid self._window_not_found_until.pop(platform, None) logger.info(f"[{platform}] 窗口已定位(UIA TXGuiFoundation): top={top}") return top except Exception: continue except Exception: pass # 搜索完毕没找到 → 设冷却避免重复 12s 超时 self._window_not_found_until[platform] = time.time() + 30 return None def _ensure_hwnd_with_timeout(self, platform: str, timeout: float = 12.0) -> Optional[int]: """_ensure_hwnd 包装 daemon 线程超时保护。 ThreadPoolExecutor 创建非 daemon 线程 → Ctrl+C 进程不退出。 改用 daemon thread + Event.wait(timeout),daemon 线程不阻塞进程退出。 """ # 冷却期内免线程直接跳过 cooldown_until = self._window_not_found_until.get(platform, 0) if time.time() < cooldown_until: return None result = [None] done = threading.Event() def worker(): # 如果外层已设冷却(超时后立即设),不再重复 UIA 搜索 cooldown_until = self._window_not_found_until.get(platform, 0) if time.time() < cooldown_until: done.set() return result[0] = self._ensure_hwnd(platform) done.set() t = threading.Thread(target=worker, daemon=True) t.start() if not done.wait(timeout=timeout): logger.warning(f"[{platform}] _ensure_hwnd 超时({timeout:.0f}s),跳过本轮") self._window_not_found_until[platform] = time.time() + 30 return None return result[0] # ── 强制刷新 UIA ────────────────────────────────────────── def _fresh_window(self, platform: str): """从缓存 HWND 创建全新的 WindowControl(绕过 UIA 缓存)。""" hwnd = self._cached_hwnd.get(platform) if not hwnd: return None try: # 用 NativeWindowHandle 重建,保证 UIA 新缓存 win = auto.ControlFromHandle(hwnd) if win is not None: return win except Exception: pass return None def _find_qq_window_fresh(self): """通过类名重新查找 QQ/TIM 窗口,绕过 UIA 缓存(比 ControlFromHandle 更彻底)。""" try: # 先按窗口名称查找 for name in ["QQ", "TIM"]: try: w = auto.WindowControl(Name=name) if w.Exists(maxSearchSeconds=0.2): rect = w.BoundingRectangle if rect.width() > 200 and rect.height() > 100: return w except Exception: pass # 按 TXGuiFoundation 类名深度查找 root = auto.GetRootControl() for child in root.GetChildren(): try: if child.ClassName == "TXGuiFoundation": rect = child.BoundingRectangle if rect.width() > 200 and rect.height() > 100: return child except Exception: pass except Exception: pass return None def _force_uia_refresh(self, platform: str): """通过交互强制窗口更新 UIA 控件树。""" hwnd = self._cached_hwnd.get(platform) if not hwnd: return try: # 方式1: 窗口重绘 win32gui.InvalidateRect(hwnd, None, True) win32gui.UpdateWindow(hwnd) # QQ: 避免标题栏点击(不会干扰用户操作) if platform == "qq": win32gui.PostMessage(hwnd, win32con.WM_VSCROLL, win32con.SB_BOTTOM, 0) win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0) win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0) else: # 方式2: 向窗口发送非客户区点击(仅微信) win32gui.PostMessage(hwnd, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN win32gui.PostMessage(hwnd, 0x00A2, 2, 0) # WM_NCLBUTTONUP logger.debug(f"[{platform}] UIA强制刷新") except Exception as e: logger.debug(f"[{platform}] 强制刷新失败: {e}") def _force_scroll_bottom(self, platform: str): """滚动窗口到底部,触发 UI 更新。""" hwnd = self._cached_hwnd.get(platform) if not hwnd: return try: # 1. WM_VSCROLL SB_BOTTOM win32gui.PostMessage(hwnd, win32con.WM_VSCROLL, win32con.SB_BOTTOM, 0) # 2. Ctrl+End 快捷键 win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_CONTROL, 0) win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0) win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0) win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_CONTROL, 0) # 3. InvalidateRect 强制重绘 win32gui.InvalidateRect(hwnd, None, True) except Exception as e: logger.debug(f"[{platform}] 滚动到底失败: {e}") def _ensure_visible(self, hwnd) -> bool: """确保窗口可见且真正处于前台(Z-order 最前),用于 OCR 截图路径。 截屏用 ImageGrab.grab(bbox) 截的是整屏坐标,若目标窗口被遮挡,截到的就是 遮挡物(如用户停留的终端)。光靠 SwitchToThisWindow 无法保证置顶,必须 SetForegroundWindow。本方法:恢复最小化/隐藏 → AllowSetForegroundWindow 解除系统前台限制 → SetForegroundWindow + SwitchToThisWindow 兜底,最多重试 3 次 并校验前台成功。不做 TOPMOST(避免 DWM 重排冻结)。 """ root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT) if not root_hwnd or not win32gui.IsWindow(root_hwnd): return False # 1) 恢复最小化 / 显示隐藏窗口 if win32gui.IsIconic(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) time.sleep(0.2) if not win32gui.IsWindowVisible(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) time.sleep(0.2) # 2) 解除系统前台限制(ASFW_ANY),让后台线程也能 SetForegroundWindow try: ctypes.windll.user32.AllowSetForegroundWindow(-1) except Exception: pass # 3) 真正置前并校验:SetForegroundWindow + SwitchToThisWindow 兜底,重试 3 次 for _ in range(3): try: win32gui.SetForegroundWindow(root_hwnd) except Exception: pass time.sleep(0.12) try: ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) except Exception: pass time.sleep(0.1) if self._check_foreground(root_hwnd): return True logger.warning(f"[{self.__class__.__name__}] 窗口 {root_hwnd} 唤醒失败,可能仍被遮挡") return False def _ensure_foreground(self, hwnd, max_retries=5) -> bool: """确保窗口在前台,重试多次。失败返回 False,不强行发送。""" root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT) if not root_hwnd or not win32gui.IsWindow(root_hwnd): logger.warning(f"[前台] 无效句柄 {hwnd}") return False # 前置条件:最小化则恢复,隐藏则显示 if win32gui.IsIconic(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) time.sleep(0.2) if not win32gui.IsWindowVisible(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) time.sleep(0.2) # 允许任意进程 SetForegroundWindow(移除系统限制,与发送路径一致) try: ctypes.windll.user32.AllowSetForegroundWindow(-1) # ASFW_ANY except Exception: pass for i in range(max_retries): # Method 1: AttachThreadInput + SetForegroundWindow self._attach_set_foreground(root_hwnd) time.sleep(0.15) if self._check_foreground(root_hwnd): return True # Method 2: SwitchToThisWindow(权限要求低,轻量) try: ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) time.sleep(0.15) if self._check_foreground(root_hwnd): return True except Exception: pass time.sleep(0.1) logger.warning(f"无法将窗口 {hwnd} 带到前台,放弃操作") return False @staticmethod def _check_foreground(root_hwnd) -> bool: """检查窗口是否在前台(或其子窗口)。""" try: fg = win32gui.GetForegroundWindow() if fg == root_hwnd: return True if fg and win32gui.IsChild(root_hwnd, fg): return True except Exception: pass return False def _attach_set_foreground(self, hwnd): """AttachThreadInput + SetForegroundWindow,从 daemon 线程也能成功。""" try: current_tid = ctypes.windll.kernel32.GetCurrentThreadId() target_tid = ctypes.windll.user32.GetWindowThreadProcessId(hwnd, None) if ctypes.windll.user32.AttachThreadInput(current_tid, target_tid, True): try: win32gui.SetForegroundWindow(hwnd) except Exception: pass finally: ctypes.windll.user32.AttachThreadInput(current_tid, target_tid, False) return # AttachThreadInput 失败 → 裸 SetForegroundWindow try: win32gui.SetForegroundWindow(hwnd) except Exception: pass except Exception: try: win32gui.SetForegroundWindow(hwnd) except Exception: pass def _read_win32_texts(self, platform: str) -> list: """Win32 API 读窗口文本,递归枚举(depth 不限)。""" hwnd = self._cached_hwnd.get(platform) if not hwnd: return [] texts = [] def enum_callback(child_hwnd, _): try: text = win32gui.GetWindowText(child_hwnd) if text and len(text.strip()) >= 2: texts.append(text.strip()) except Exception: pass return True try: main_text = win32gui.GetWindowText(hwnd) if main_text and len(main_text.strip()) >= 2: texts.append(main_text.strip()) win32gui.EnumChildWindows(hwnd, enum_callback, None) except Exception: pass return texts # ── OCR 读屏(QQ 专用,Windows 原生 OCR) ────────────────── def _ocr_ensure_engine(self): """惰性初始化 OCR 引擎(中文简体)。""" if self._ocr_engine is None: try: self._ocr_engine = OcrEngine.try_create_from_language( Language('zh-CN') ) if self._ocr_engine: logger.info(f"[ocr] OCR引擎就绪: {self._ocr_engine.recognizer_language.display_name}") else: logger.warning("[ocr] 创建OCR引擎失败") except Exception as e: logger.error(f"[ocr] OCR引擎初始化异常: {e}") return self._ocr_engine @staticmethod def _normalize_ocr_text(text: str) -> str: """Windows OCR 给 CJK 字符间强加空格 → 去除这些空格使文本可去重。 "问 题 1 : 你 答 : 你 好" → "问题1:你答:你好" "你 好 he llo" → "你好 he llo"(ASCII 词内空格保留) 此外修复常见 Windows OCR 字符混淆错误(只在非 md 文本上运行, md 文档由分割后逐片段 md 检测过滤,不受影响)。 """ # 所有紧邻 CJK/全角字符的空格全部去掉 # CJK 范围:汉字 (u4e00-) + 扩展 (u3400-) + 全角/兼容 (uff00-) text = re.sub( r'(?<=[\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef\u3000-\u303f])' r'\s+', '', text ) text = re.sub( r'\s+' r'(?=[\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef\u3000-\u303f])', '', text ) # ⭐ Windows OCR 字符混淆修复 # 《》→ <>:Minecraft 聊天大量使用 等尖括号,OCR 误读为中文书名号 text = text.replace('《', '<').replace('》', '>') # l→| 管道分隔符:|quiz| / |start| / |info| 误读为 lquizl / lstartl / linfol text = re.sub(r'(?<=\s)l([A-Za-z]\w*?)l(?=\s)', r'|\1|', text) # .→ . 全角点→半角数字小数点 (0.45 → 0.45) text = re.sub(r'(\d)[.](\d)', r'\1.\2', text) # 清理消息末尾的状态标记(如 "0~" "O~" "C~" "0~O~"),避免污染 KB 匹配 text = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', text) # 清理 HTML 标签(OCR 截到网页/终端时常带
等) text = re.sub(r'<\s*br\s*/?\s*>', '\n', text, flags=re.IGNORECASE) text = re.sub(r'<\s*div\s*/?\s*>', '\n', text, flags=re.IGNORECASE) text = re.sub(r'<[^>]+>', '', text) return text.strip() @staticmethod def _is_ocr_console_garbage(text: str) -> bool: """OCR 输出的文本是否明显是控制台/Python 垃圾。 当 OCR 截错窗口(如读到终端窗口)时,文本包含: - Python 路径/模块名:comtypes, uiautomation, sklearn, jieba, winrt - 脚本文件名:monitor.py, main.py - 虚拟环境路径:.venv - 日志关键词:DEBUG, INFO, WARNING, msgbot - 控制台序列:←[, [32m, [0m 返回 True = 应当丢弃。 """ garbage_patterns = [ r'comtypes', r'uiautomation', r'sklearn', r'jieba', r'winrt\.', r'msgbot\.', r'monitor\.py', r'main\.py', r'\.venv', r'Loading model', r'Prefix dict', r'WARNING', r'UserWarning', r'Building prefix', r'←\[', r'\[32m', r'\[0m', r'\\n', ] for pat in garbage_patterns: if re.search(pat, text, re.IGNORECASE): return True cjk_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff') # 全字母数字+标点无中文 → 肯定是非聊天内容(阈值降到 15) if cjk_count == 0 and len(text.strip()) > 15: return True # OCR 随机字符垃圾:无中文 + 特殊字符比例高(如 = ~ @ # $ % ^ & *) # EasyOCR 在包含头像/表情/特殊 UI 的区域会输出 544UUJ1W H V=YJ~JL:e 这类乱码 if cjk_count == 0 and len(text.strip()) >= 8: special_chars = sum(1 for c in text if c in '=~@#$%^&*_+{}[]|\\<>/`!') alpha_count = sum(1 for c in text if c.isalpha()) digit_count = sum(1 for c in text if c.isdigit()) # 特殊字符占比过高(>15%)且字母数字混杂 → OCR 乱码 if special_chars / len(text) > 0.15 and alpha_count >= 2 and digit_count >= 2: return True return False def _validate_qq_window(self, hwnd: int) -> bool: """验证窗口是否确实是 QQ/TIM 进程。 不尝试自动恢复隐藏/最小化窗口——交给剪贴板兜底的 _ensure_foreground 处理。 _ensure_foreground 有多重重试+SetForegroundWindow,比这里抢先 ShowWindow 更能正确弹出主窗口而非托盘预览。 """ if not win32gui.IsWindow(hwnd): return False if not win32gui.IsWindowVisible(hwnd): return False if win32gui.IsIconic(hwnd): return False # 验证进程名 try: _, pid = win32process.GetWindowThreadProcessId(hwnd) hproc = win32api.OpenProcess( 0x0400 | 0x0010, # PROCESS_QUERY_INFORMATION | PROCESS_VM_READ False, pid ) if hproc: exe_path = win32process.GetModuleFileNameEx(hproc, 0) win32api.CloseHandle(hproc) exe_name = exe_path.split('\\')[-1].lower() if exe_path else '' if exe_name in ('qq.exe', 'tim.exe'): return True except Exception: pass return False @staticmethod def _fuzzy_duplicate(new_text: str, seen_set: set, threshold: float = 0.80) -> bool: """检查 new_text 是否与 seen_set 中已有文本相似度超阈值。 OCR 输出有噪声(每次勖彐碎片、行首数字、尾部截断不同),精确匹配无法去重。 用 difflib.SequenceMatcher 的 quick_ratio() 做快速上界剪枝。 """ if not new_text or len(new_text) < 4: return False for existing in seen_set: if abs(len(existing) - len(new_text)) > max(len(existing), len(new_text)) * 0.4: # 长度差距超 40%,不可能相似(quick_ratio 上界 = 2*min/max < 阈值) continue ratio = difflib.SequenceMatcher(None, new_text, existing).quick_ratio() if ratio >= threshold: return True return False # ── 双 OCR 引擎 ────────────────────────────────────────── def _init_easyocr(self): """惰性初始化 EasyOCR(首次约 3-10 秒)。""" if not _HAS_EASYOCR or self._easyocr_reader is not None: return try: logger.info("[ocr] EasyOCR 初始化中(首次约 3-10 秒)...") # 限制 torch 线程数,避免 MKL/OpenMP 在多线程环境段错误 import torch torch.set_num_threads(1) use_gpu = torch.cuda.is_available() self._easyocr_reader = _easyocr.Reader(['ch_sim', 'en'], gpu=use_gpu) if use_gpu: logger.info("[ocr] EasyOCR 就绪(GPU 模式)") else: logger.info("[ocr] EasyOCR 就绪(CPU 模式)") except Exception as e: logger.error(f"[ocr] EasyOCR 初始化失败: {e}") self._easyocr_reader = None # ── EasyOCR 子进程隔离 ──────────────────────────────────── # 直接 GPU 推理 crash(access violation)会杀整个进程 + 冻电脑。 # 子进程方案:GPU crash 只杀子进程,主进程降级到 Windows OCR。 def _ensure_easyocr_subprocess(self) -> bool: """确保 EasyOCR 子进程在运行。返回 True = 可用。""" if self._easyocr_broken: return False if self._easyocr_proc is not None: if self._easyocr_proc.poll() is None: return True # 还在跑 logger.warning("[ocr] EasyOCR 子进程已退出,重启...") self._easyocr_proc = None worker_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), 'easyocr_worker.py' ) if not os.path.exists(worker_path): logger.error(f"[ocr] EasyOCR worker 不存在: {worker_path}") self._easyocr_broken = True return False try: self._easyocr_proc = subprocess.Popen( [sys.executable, '-u', worker_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, # 防 stderr 管道满阻塞 text=True, bufsize=1, # 行缓冲 ) logger.debug("[ocr] EasyOCR 子进程已启动") return True except Exception as e: logger.error(f"[ocr] 启动 EasyOCR 子进程失败: {e}") self._easyocr_broken = True self._easyocr_proc = None return False def _call_easyocr_safe(self, img_np, timeout: Optional[float] = None) -> Optional[str]: """通过子进程安全调用 EasyOCR。返回识别文本,失败/崩溃/超时时返回 None。 使用 _easyocr_lock 保护子进程 stdin/stdout,避免 QQ/微信独立线程并发写乱。 timeout — 子进程读取超时秒数,None=无限等待;超时后杀死子进程下次重启。 """ import io import base64 if not self._ensure_easyocr_subprocess(): return None try: # numpy → PIL → PNG bytes → base64 img_pil = Image.fromarray(img_np) buf = io.BytesIO() img_pil.save(buf, format='PNG') img_b64 = base64.b64encode(buf.getvalue()).decode() with self._easyocr_lock: self._easyocr_proc.stdin.write(json.dumps({"img": img_b64}) + "\n") self._easyocr_proc.stdin.flush() from queue import Queue, Empty as _QueueEmpty # readline 包装超时:用 daemon 线程 + Queue 实现跨平台 if timeout is not None: _q = Queue() def _read_worker(): try: _q.put(self._easyocr_proc.stdout.readline()) except Exception: pass _t = threading.Thread(target=_read_worker, daemon=True) _t.start() try: line = _q.get(timeout=timeout) except _QueueEmpty: logger.debug(f"[ocr] EasyOCR 子进程超时({timeout}s),杀死子进程下次重启") try: if self._easyocr_proc and self._easyocr_proc.poll() is None: self._easyocr_proc.kill() except Exception: pass self._easyocr_proc = None # 清引用,下次 poll 自动重启(不设 _easyocr_broken) return None else: line = self._easyocr_proc.stdout.readline() if not line: raise ConnectionError("EasyOCR 子进程 stdout 关闭") result = json.loads(line) if result.get("ok"): # 计数 + 主动重启:EasyOCR GPU 内存泄漏累积 → OOM → 冻结 self._easyocr_call_count += 1 if self._easyocr_call_count >= self._easyocr_max_calls: logger.debug(f"[ocr] EasyOCR 已达 {self._easyocr_max_calls} 次调用,重启子进程清 GPU 缓存") try: if self._easyocr_proc and self._easyocr_proc.poll() is None: self._easyocr_proc.kill() self._easyocr_proc.wait(timeout=3) except Exception: pass self._easyocr_proc = None self._easyocr_call_count = 0 return result["text"] else: logger.debug(f"[ocr] EasyOCR worker 错误: {result.get('error', '?')[:100]}") return None except (BrokenPipeError, ConnectionError, OSError) as e: logger.debug(f"[ocr] EasyOCR 子进程通信断开: {e}") self._easyocr_broken = True self._easyocr_proc = None return None except Exception as e: logger.debug(f"[ocr] EasyOCR 子进程异常: {e}") return None @staticmethod def _score_ocr_text(text: str) -> float: """为 OCR 结果质量打分(越高越好)。对比引擎用。""" if not text or len(text.strip()) < 5: return 0.0 score = 0.0 # 1) 有效 CJK 数量(越多 = 识别越好) cjk = sum(1 for c in text if '\u4e00' <= c <= '\u9fff') score += cjk * 0.5 # 2) Unicode 垃圾字符扣分(OCR 常见误识碎片) junk = set('勖彐丬亻俨冉勹刂冫孑亠丷') junk_count = sum(1 for c in text if c in junk) score -= junk_count * 3 # 3) CJK 间空格比例扣分(Windows OCR 特有毛病) # "你 好" vs "你好" — 空格越多说明识别越差 total = len(text) spaces = text.count(' ') if total > 0 and cjk > 0: space_penalty = (spaces / total) * 10 score -= space_penalty # 4) 时间戳加分 = 消息结构更完整 timestamps = len(re.findall(r'\d{1,2}:\d{2}', text)) score += min(timestamps * 2, 6) return max(score, 0.0) def _pick_ocr_text(self, win_text: str, easy_text: str, image: Image.Image) -> tuple: """双引擎对比,选质量更高的结果。日志记录引擎胜负统计。返回 (text, engine_name)。""" if not easy_text: return (win_text, "windows") if not win_text: return (easy_text, "easyocr") win_score = self._score_ocr_text(win_text) easy_score = self._score_ocr_text(easy_text) chosen = "windows" if win_score >= easy_score else "easyocr" self._ocr_engine_wins[chosen] = self._ocr_engine_wins.get(chosen, 0) + 1 total = sum(self._ocr_engine_wins.values()) ratio = self._ocr_engine_wins.get(chosen, 0) / max(total, 1) logger.debug( f"[ocr] 引擎对比: win={win_score:.1f} vs easy={easy_score:.1f} → 选{chosen} " f"(胜率{ratio:.0%}, {total}轮)" ) return (win_text, chosen) if chosen == "windows" else (easy_text, chosen) @staticmethod def _safe_screenshot(bbox: tuple, timeout: float = 3.0): """带超时的屏幕截图。ImageGrab.grab() 的 BitBlt 可能因 DWM 卡死→整机冻结 2-3min。 用线程+timeout 隔离:BitBlt 卡死只杀线程,不波及 DWM/桌面。 timeout=3.0: 3 秒截不到就跳过本轮,bot 继续下次轮询。 """ _im = [None] def _grab(): try: _im[0] = ImageGrab.grab(bbox=bbox) except Exception: pass import threading t = threading.Thread(target=_grab, daemon=True) t.start() t.join(timeout=timeout) if t.is_alive(): logger.warning(f"[ocr] 截图超时 ({timeout}s),BitBlt 可能卡在 DWM,跳过本轮") return None return _im[0] def _ocr_dual_engine(self, im_processed: Image.Image, im_original: Image.Image, platform: str) -> tuple: """双引擎并发 OCR:Windows OCR + EasyOCR 同时跑,最多等 10 秒。 哪個先出用哪個,兩個都出時 _pick_ocr_text 質量對比選優。 im_processed: 预处理后的图(2x resize + sharpen + contrast, RGBA) im_original: 原始截图(bad allocation 降级用) 返回 (raw_text, engine_name) 含義同 _pick_ocr_text。 """ import concurrent.futures engine = self._ocr_ensure_engine() if not engine: return ("", "") win_text: list[str] = [""] easy_text: list[str] = [""] def _win_worker(): try: im = im_processed if im.mode != 'RGBA': im = im.convert('RGBA') sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height) sb.copy_from_buffer(im.tobytes()) text = engine.recognize_async(sb).get().text.strip() win_text[0] = text except (RuntimeError, OSError) as e: estr = str(e) if "bad allocation" in estr or "memory" in estr.lower(): try: im2 = im_original if im2.mode != 'RGBA': im2 = im2.convert('RGBA') sb2 = SoftwareBitmap(BitmapPixelFormat.RGBA8, im2.width, im2.height) sb2.copy_from_buffer(im2.tobytes()) text2 = engine.recognize_async(sb2).get().text.strip() win_text[0] = text2 except Exception: pass # 其他异常静默处理 def _easy_worker(): if not _HAS_EASYOCR or self._easyocr_broken: return # EasyOCR 用原图(1x):2x 图送 GPU 显存压力大 + 加速泄漏 img_np = _np.array(im_original) text = self._call_easyocr_safe(img_np, timeout=10) if text: easy_text[0] = text with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex: win_fut = ex.submit(_win_worker) easy_fut = ex.submit(_easy_worker) done, not_done = concurrent.futures.wait( [win_fut, easy_fut], timeout=10.0, return_when=concurrent.futures.ALL_COMPLETED ) for f in not_done: f.cancel() wt = win_text[0] et = easy_text[0] # 日志(原始输出逐行记录,用於長期對比兩引擎能力) if wt: for i, l in enumerate(wt.split('\n')): if l.strip(): logger.info(f"[ocr] [windows/原始] line[{i}]: {l.strip()[:120]!r}") if et: for i, l in enumerate(et.split('\n')): if l.strip(): logger.info(f"[ocr] [easyocr/原始] line[{i}]: {l.strip()[:120]!r}") if not wt and not et: return ("", "") return self._pick_ocr_text(wt, et, im_processed) def _ocr_read_qq(self) -> list: """OCR 截屏 QQ 消息区域 -> 双引擎(Windows OCR + EasyOCR)提取文本。 包含窗口验证 + 垃圾过滤 + 连续垃圾清缓存。 """ # 同平台冷却:回复后 15s 内跳过,防读到自身回复回声 # 冷却期间也设 _ocr_unchanged → _read_messages 跳过所有 fallback # (_read_via_clipboard_once 调 _ensure_foreground → AttachThreadInput → 崩 TIM Electron) if time.time() < self._self_reply_cooldown_until.get("qq", 0): self._ocr_unchanged["qq"] = True return [] hwnd = self._cached_hwnd.get("qq") if not hwnd: return [] if not self._validate_qq_window(hwnd): gc = self._ocr_garbage_count.get("qq", 0) + 1 self._ocr_garbage_count["qq"] = gc if gc == 1: # 首次失败时详细日志 visible = win32gui.IsWindowVisible(hwnd) if win32gui.IsWindow(hwnd) else False iconic = win32gui.IsIconic(hwnd) if win32gui.IsWindow(hwnd) else False logger.debug(f"[qq] OCR: 窗口不可用(visible={visible}, iconic={iconic})") if gc >= 3: logger.info("[qq] OCR: 窗口不合法连续3次,清缓存") self._cached_hwnd.pop("qq", None) self._cached_pid.pop("qq", None) self._app_variant.pop("qq", None) self._ocr_garbage_count["qq"] = 0 return [] engine = self._ocr_ensure_engine() if not engine: return [] try: # ⭐ 先唤醒再取窗口 rect(最小化恢复后位置会变化),再截屏 # ImageGrab.grab 截整屏坐标,若 QQ 被遮挡(用户停在终端)截到的就是遮挡物 if not self._ensure_visible(hwnd): logger.debug("[qq] 唤醒失败,跳过本轮") return [] time.sleep(0.4) rect = win32gui.GetWindowRect(hwnd) if not rect: return [] left, top, right, bottom = rect win_w = right - left win_h = bottom - top # QQ NT 窗口布局: ~0-5% 标签栏, ~5-30% 联系人/群列表, ~30-55% 会话列表, ~55-100% 消息面板 variant = self._app_variant.get("qq", "") # TIM (Electron) 安全:不做 PostMessage 点击 + 不做 keybd_event 全局键 # 只做 PostMessage VK_END(异步,安全),避免干扰 Electron 渲染进程 if variant == "tim": win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0) win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0) time.sleep(0.3) else: # 点一下消息面板区域设置焦点(QQ NT 默认焦点在会话列表,VK_END 只会滚会话列表) try: click_x = int(win_w * 0.72) # 消息面板中心偏右 click_y = int(win_h * 0.50) lparam = win32api.MAKELONG(click_x, click_y) win32gui.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam) win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) logger.debug(f"[qq] 点击消息面板({click_x},{click_y})设置焦点") time.sleep(0.15) except Exception as e: logger.debug(f"[qq] 点击消息面板失败: {e}") # Ctrl+End 滚动到底部,只截最新消息(避开历史堆积+bot 旧回复) VK_CONTROL = 0x11 VK_END = 0x23 # 方式A: keybd_event(窗口在前台时生效) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) ctypes.windll.user32.keybd_event(VK_END, 0, 0, 0) time.sleep(0.05) ctypes.windll.user32.keybd_event(VK_END, 0, 2, 0) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0) # 方式B: PostMessage 兜底(keybd_event 可能被 TIM 忽略) win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0) win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0) time.sleep(0.3) # TIM/QQ 窗口布局: ~5-30% 左栏, ~30-50% 会话列表(含文档预览), ~50-100% 消息面板 # cap_left=25% 覆盖消息面板左半侧,捕获用户消息;颜色检测+md过滤拦截bot回复 cap_left = int(left + win_w * 0.25) cap_top = top + max(80, int(win_h * 0.06)) # 跳过标题栏+群名头栏 cap_right = int(left + win_w * 0.78) # 跳过右栏群成员列表(~80%+) 和滚动条 cap_bottom = bottom - max(100, int(win_h * 0.10)) # 输入框上方 if cap_right <= cap_left or cap_bottom <= cap_top: return [] im = self._safe_screenshot((cap_left, cap_top, cap_right, cap_bottom)) if im is None: return [] if im.width < 50 or im.height < 50: return [] # 保存调试截图(在颜色/哈希检查之前,即使跳过也有图可查) try: script_dir = os.path.dirname(os.path.abspath(__file__)) ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19] _dbg_path = os.path.join(script_dir, f"ocr_debug_qq_{ts}.png") im.save(_dbg_path, format="PNG") _clean_old_ocr_debug(script_dir, keep=6, prefix="ocr_debug_qq_") if os.path.exists(_dbg_path): logger.info(f"[qq] 调试截图已保存: {_dbg_path}") else: logger.warning(f"[qq] 调试截图保存后未找到文件: {_dbg_path}") except Exception as e: logger.warning(f"[qq] 调试截图保存失败: {e} (路径: {_dbg_path})") # ⭐ 颜色像素检测:如果截图中只有 bot 蓝色气泡,无白色用户消息,跳过 if not self._has_user_colors(im, "qq"): self._ocr_unchanged["qq"] = True logger.debug(f"[qq] 像素检测: 无对方消息, 跳过OCR (截图: {os.path.basename(_dbg_path)})") return [] # ⭐ 截图 hash:像素级指纹——内容没变就直接跳过整轮(easyocr 同一截图每次输出不同文本) # dHash + 5秒强制刷新:即使hash相同,每5秒也强制做一次OCR,防止永久卡死 h = self._image_hash(im) now = time.time() last_hash = self._last_screenshot_hash.get("qq", "") last_time = self._last_ocr_time.get("qq", 0) if h == last_hash and (now - last_time) < 5: self._ocr_unchanged["qq"] = True logger.debug(f"[qq] 截图 hash 相同,屏幕无变化,跳过本轮") return [] self._last_screenshot_hash["qq"] = h self._last_ocr_time["qq"] = now self._ocr_unchanged.pop("qq", None) # 图像预处理:提升 Windows OCR 识别准确率 # Windows OCR 对低 DPI/未处理截图识别差——CJK 空格、字符混淆 # 2x LANCZOS 放大 + 锐化 + 对比度增强 im_original = im # 保留原图,bad allocation 时降级 try: im = im.resize((im.width * 2, im.height * 2), Image.LANCZOS) im = ImageEnhance.Sharpness(im).enhance(1.8) im = ImageEnhance.Contrast(im).enhance(1.4) logger.debug(f"[ocr] 预处理: resize 2x + sharpen(1.8) + contrast(1.4)") except Exception as e: logger.debug(f"[ocr] 预处理失败(跳过): {e}") # ── 双引擎并发 OCR(Windows OCR + EasyOCR 同时跑,最多等 10 秒)── raw_text, chosen_engine = self._ocr_dual_engine(im, im_original, "qq") if not raw_text: return [] self._last_chosen_engine["qq"] = chosen_engine return self._process_ocr_output(raw_text, "qq") except Exception as e: logger.debug(f"[qq] OCR失败: {e}") return [] def _ocr_read_wechat(self) -> list: """OCR 截屏微信消息区域 -> 双引擎提取文本。 微信 UIA 树不暴露聊天消息内容(新版微信 UIA 架构变化),必须用 OCR。 微信自绘控件,keybd_event 无法滚动,只截屏当前可见区域。 """ # 同平台冷却:回复后 15s 内跳过,防读到自身回复回声 if time.time() < self._self_reply_cooldown_until.get("wechat", 0): return [] hwnd = self._cached_hwnd.get("wechat") if not hwnd: return [] if not win32gui.IsWindow(hwnd): self._cached_hwnd.pop("wechat", None) self._cached_pid.pop("wechat", None) return [] engine = self._ocr_ensure_engine() if not engine: return [] try: # ⭐ 先唤醒再截屏:ImageGrab.grab 截整屏坐标,若微信被遮挡(用户停在终端) # 截到的就是遮挡物。唤醒后才取窗口 rect(最小化恢复后位置会变化)。 if not self._ensure_visible(hwnd): logger.debug("[wechat] 唤醒失败,跳过本轮") return [] time.sleep(0.4) # 让 DWM 完成窗口激活的合成,防紧接 BitBlt 卡死 rect = win32gui.GetWindowRect(hwnd) if not rect: return [] left, top, right, bottom = rect win_w = right - left win_h = bottom - top # WeChat 窗口布局: ~0-6% icon tab栏, ~6-32% 会话列表, ~32-98% 消息面板 # 左边界 40% 跳过会话列表(含通讯录/视频号/游戏中心等快捷入口) # 顶部跳过导航栏(微信/通讯录/朋友圈/视频号 tab) cap_left = int(left + win_w * 0.34) cap_top = top + max(80, int(win_h * 0.08)) cap_right = right - 10 # 跳过右边框 cap_bottom = bottom - max(65, int(win_h * 0.07)) # 跳过输入框 if cap_right <= cap_left or cap_bottom <= cap_top: return [] im = self._safe_screenshot((cap_left, cap_top, cap_right, cap_bottom)) if im is None: return [] if im.width < 50 or im.height < 50: return [] # 保存调试截图(在颜色/哈希检查之前,即使跳过也有图可查) try: script_dir = os.path.dirname(os.path.abspath(__file__)) ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19] _dbg_path = os.path.join(script_dir, f"ocr_debug_wx_{ts}.png") im.save(_dbg_path, format="PNG") _clean_old_ocr_debug(script_dir, keep=6, prefix="ocr_debug_wx_") if os.path.exists(_dbg_path): logger.info(f"[wechat] 调试截图已保存: {_dbg_path}") else: logger.warning(f"[wechat] 调试截图保存后未找到文件: {_dbg_path}") except Exception as e: logger.warning(f"[wechat] 调试截图保存失败: {e} (路径: {_dbg_path})") # ⭐ 颜色像素检测:如果截图中只有自身绿色气泡,无对方白色消息,跳过 if not self._has_user_colors(im, "wechat"): self._ocr_unchanged["wechat"] = True logger.debug(f"[wechat] 像素检测: 无对方消息(自身消息已过滤), 跳过OCR") return [] # ⭐ 截图 hash:像素级指纹——内容没变就直接跳过整轮 # dHash + 5秒强制刷新:即使hash相同,每5秒也强制做一次OCR,防止永久卡死 h = self._image_hash(im) now = time.time() last_hash = self._last_screenshot_hash.get("wechat", "") last_time = self._last_ocr_time.get("wechat", 0) if h == last_hash and (now - last_time) < 5: self._ocr_unchanged["wechat"] = True logger.debug(f"[wechat] 截图 hash 相同,屏幕无变化,跳过本轮") return [] self._last_screenshot_hash["wechat"] = h self._last_ocr_time["wechat"] = now self._ocr_unchanged.pop("wechat", None) # 图像预处理(同 QQ) im_original = im # 保留原图,bad allocation 时降级 try: im = im.resize((im.width * 2, im.height * 2), Image.LANCZOS) im = ImageEnhance.Sharpness(im).enhance(1.8) im = ImageEnhance.Contrast(im).enhance(1.4) except Exception as e: logger.debug(f"[wechat] OCR 预处理失败(跳过): {e}") # ── 双引擎并发 OCR(Windows OCR + EasyOCR 同时跑,最多等 10 秒)── raw_text, chosen_engine = self._ocr_dual_engine(im, im_original, "wechat") if not raw_text: return [] self._last_chosen_engine["wechat"] = chosen_engine return self._process_ocr_output(raw_text, "wechat") except Exception as e: logger.debug(f"[wechat] OCR失败: {e}") return [] def _process_ocr_output(self, raw_text: str, platform: str) -> list: """OCR 原始文本的通用后处理:垃圾检测 → 时间戳分割 → 过滤清洗。 供 QQ / 微信 OCR 路径共用。 """ # —— WeChat 跨窗口污染检测:OCR 读到 QQ/TIM 调试输出时丢弃整批 —— if platform == "wechat": # 注意: WinOCR 输出带空格和全角符号(如"完 成 : 553 chars"), # EasyOCR 用 ASCII 符号。regex 需兼容两种。 _cross_pollution_markers = [ r'sharpen', # 图片预处理参数 r'contrast\b', # 对比度参数 r'设置焦点', # UIA 调试输出 r'污染.*标记', # 自家 log: "污染(5/7标记)" r'丢弃.*整批', # 自家 log: "丢弃整批13 items" r'last=', # 自家 log: "last=\"关闭\"" r'OCR\s*完\s*[成]', # OCR完成 / OCR 完 成 — 自家 log r'(?:完成|完成)\s*[:\uFF1A]\s*\d+\s+Char', # 完成:553 chars / 完成 : 553 Chars r'[((]\s*\d+[\s,,\/]*\d+\s*[))]', # (1005,476) / ( 5 / 7 )坐标 r'原始\s*[\])]', # 原始] / 原始 ] r'后处理\s*[\])]', # 后处理] / 后处理 ] r'line\s*[\[(]\s*[\d@]\s*[\]))]', # line[0] / line [0] / line(0) r'reader\s*就绪', # reader就绪状态 / reader 就绪 r'(?:扫描|跳过|丢弃)\s*本\s*轮', # 跳过本轮 / 跳 过 本 轮 — 自家 log ] _hit = sum(1 for m in _cross_pollution_markers if re.search(m, raw_text, re.IGNORECASE)) # WinOCR 给 CJK 加空格("完 成 : 553 chars")→ raw regex 匹配不到 # 归一化后再查一次,双路覆盖两种引擎格式 if _hit < 2: norm = self._normalize_ocr_text(raw_text) _hit = sum(1 for m in _cross_pollution_markers if re.search(m, norm, re.IGNORECASE)) if _hit >= 2: logger.warning(f"[{platform}] 跨窗口污染({_hit}/{len(_cross_pollution_markers)}标记),丢弃整批") return [] # —— 垃圾检测 —— if self._is_ocr_console_garbage(raw_text): gc = self._ocr_garbage_count.get(platform, 0) + 1 self._ocr_garbage_count[platform] = gc if gc >= 3: logger.info(f"[{platform}] OCR: 垃圾连续3次,清缓存") self._cached_hwnd.pop(platform, None) self._cached_pid.pop(platform, None) self._ocr_garbage_count[platform] = 0 return [] self._ocr_garbage_count[platform] = 0 self._ocr_last_text[platform] = raw_text # —— JSON/YAML 配置块剥离 —— # 微信 OCR 截屏范围 40%-98%,常把右侧文档/配置面板(JSON/YAML)一起截入 # JSON 块("key": value, {, })和 YAML 块(key: value)跟用户消息混在一起 # 在 split 前先剥离这些行,避免 JSON 碎片污染用户消息 raw_text = self._strip_json_yaml_blocks(raw_text, platform) # —— 行切割 + 尾标清理 —— ocr_lines = [] for l in raw_text.split('\n'): l = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', l).strip() if l: ocr_lines.append(l) engine_label = self._last_chosen_engine.get(platform, "") for i, rl in enumerate(ocr_lines): logger.info(f"[ocr] [{engine_label}/后处理] line[{i}]: {rl[:120]!r}") # —— 时间戳分割 —— expanded = [] for i, rl in enumerate(ocr_lines): before = rl rl = self._normalize_ocr_text(rl) logger.debug(f"[ocr] stage[{i}] normalize: len={len(before)} -> {rl[:120]!r}") rl = rl.replace('&', '') rl = rl.replace(':', ':') logger.debug(f"[ocr] stage[{i}] clean: len={len(rl)} -> {rl[:120]!r}") rl = re.sub(r'\s+', ' ', rl) parts = re.split(r'(\d{1,2}:\d{1,2}(?::\d{1,2})?)', rl) logger.debug(f"[ocr] stage[{i}] split -> {len(parts)} parts: " f"{[p[:60] for p in parts if p.strip()]}") buf = '' last_ts = None # 跟踪上一时间戳,用于判断消息块年龄 for part in parts: if re.match(r'^\d{1,2}:\d{1,2}', part): if buf.strip(): if last_ts and self._is_timestamp_old(last_ts, 300): logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}过旧({300}s),跳过: {buf.strip()[:60]!r}") else: if self._is_markdown_document(buf.strip()): extracted = self._extract_user_text_from_md_fragment(buf.strip()) for e in extracted: if e: # 再检:提取后的文本不应是纯 md 语法 if self._is_markdown_document(e): logger.debug(f"[ocr] stage[{i}] md-only after extraction: {e[:60]!r}") continue expanded.append(e) if not extracted: logger.debug(f"[ocr] stage[{i}] markdown fragment skipped: {buf.strip()[:80]!r}") else: msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', buf.strip()) if msg: msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip() if msg: if self._is_reply_fragment(msg): logger.debug(f"[ocr] stage[{i}] 回复残片跳过: {msg[:60]!r}") else: expanded.append(msg) buf = '' last_ts = part else: buf += part if buf.strip(): # 尾部检查:如果最后一个可见时间戳已过期(≥300s),尾部内容也是历史消息 # 注释:21:56:17清晰可见但已过期 → 尾部内容不应被当成新消息 if last_ts and self._is_timestamp_old(last_ts, 300): logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}已过期(≥{300}s),跳过尾部: {buf.strip()[:60]!r}") elif self._is_markdown_document(buf.strip()): extracted = self._extract_user_text_from_md_fragment(buf.strip()) for e in extracted: if e: if self._is_markdown_document(e): logger.debug(f"[ocr] stage[{i}] md-only after extraction: {e[:60]!r}") continue if self._is_reply_fragment(e): logger.debug(f"[ocr] stage[{i}] md提取回复残片跳过: {e[:60]!r}") continue expanded.append(e) if not extracted: logger.debug(f"[ocr] stage[{i}] markdown fragment skipped: {buf.strip()[:80]!r}") else: msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', buf.strip()) if msg: msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip() if msg: if self._is_reply_fragment(msg): logger.debug(f"[ocr] stage[{i}] 回复残片跳过: {msg[:60]!r}") else: # EasyOCR 常把横向相邻的两条消息(用户消息+群昵称/UI文字) # 检测为同一行,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一" # 这种合并文本。尝试在中文句尾标点处拆分为多条。 split_msgs = self._split_merged_ocr_text(msg) for sm in split_msgs: expanded.append(sm) # —— 逐行过滤 —— ocr_lines = expanded texts = [] for line in ocr_lines: if not line: continue if line in ("查看更多", "收起", "选择会话开始聊天"): continue if re.match(r'^[✿♡♥❤★☆✦✧❀❁❂❃❄❅❆❇❈❉❊❋➠➡➢➣➤.·•●○◦◎◉◍◐◑◒◓◔◕◖◗◢◣◤◥☺☻☼♠♣♦♤♧♡♢♔♕♖♗♘♙🕐-🕧\s]{1,3}$', line): continue line = re.sub(r'^0+', '', line) line = re.sub(r'[勖彐"\'\s\u2000-\u206f]+$', '', line) # 末尾裸字母:OCR checkbox [V] 碎成 "? O"、" O"、" V" line = re.sub(r'\s+[OV]\s*$', '', line) if not line: continue if re.match(r'^问题\d+\s*[::]', line): continue if re.match(r'^[\d:\s]+$', line): continue line = re.sub(r'^\d+[\..]\d+s?\s*\d*[.\.]?\d*', '', line) line = re.sub(r'^[\d.\.\s]{2,20}[一-鿿]{1,5}?[,。、]', '', line) if not line: continue if self._is_system_message(line): continue if re.search(r'群成员|索群成员|搜索成员', line): logger.debug(f"[{platform}] OCR 过滤: 群成员侧栏 line={line[:60]!r}") continue if re.match(r'^[^)】〕」]{2,40}[(【〔「][^)】〕」]{1,15}[)】〕」]', line): logger.debug(f"[{platform}] OCR 过滤: 群名头栏 line={line[:60]!r}") continue if re.match(r'^[一-鿿]{1}$', line): continue # OCR 碎片:≤8 字符且无中文 → 时间戳/checkbox 残片 if len(line) <= 8 and not re.search(r'[\u4e00-\u9fff]', line): continue # OCR 随机字符垃圾:无中文 + 特殊字符比例高(如 `544UUJ1W H V=YJ~JL:e`) # EasyOCR 对头像/表情/特殊 UI 区域会输出这类无意义字母数字符号组合 if not re.search(r'[\u4e00-\u9fff]', line) and len(line) >= 10: special_chars = sum(1 for c in line if c in '=~@#$%^&*_+{}[]|\\<>/`') alpha_count = sum(1 for c in line if c.isalpha()) digit_count = sum(1 for c in line if c.isdigit()) if special_chars / len(line) > 0.15 and alpha_count >= 2 and digit_count >= 2: logger.debug(f"[{platform}] OCR 过滤: 随机字符垃圾 line={line!r}") continue # checkbox 残片:`[V]` `[X]` `[√]` 开头,明显不是用户提问 if re.match(r'^\[[VX√✗]\s*\](?:\s*[A-Za-z0-9_\-./]+)?\s*$', line): logger.debug(f"[{platform}] OCR 过滤: checkbox残片 line={line!r}") continue # 多 checkbox 碎片:一行含 2+ 个 [V] 标记,md 列表 OCR 残留 if len(re.findall(r'\[\s*[VXvx√✗]\s*\]', line)) >= 2: logger.debug(f"[{platform}] OCR 过滤: 多checkbox碎片 line={line!r}") continue # 全角括号 md 链接碎片:`[text](url` 或 `](` — md 链接 OCR 全角退化 if re.search(r'\](', line): logger.debug(f"[{platform}] OCR 过滤: 全角括号碎片 line={line!r}") continue if re.match(r'^\d{1,2}[::\s]\d{1,2}', line): continue if re.match(r'^\d{4}[/-]\d{1,2}', line): continue # ===== 主动防御: UI 子串黑名单 (全平台) ===== # 含特定 UI 面板词的碎片直接丢弃,不依赖精确匹配 if any(sub in line for sub in _UI_SUBSTR_BLOCKLIST): logger.debug(f"[{platform}] OCR 过滤: UI子串 line={line!r}") continue # ===== 主动防御: UI 按钮/标签黑名单 (精确匹配, 全平台) ===== if line in _UI_EXACT_BLOCKLIST: logger.debug(f"[{platform}] OCR 过滤: UI黑名单 line={line!r}") continue # ===== 主动防御: 系统状态计数 (全平台) ===== if re.search(r'(?:浏览|阅读|查看)\d+[个条款篇张次]', line): logger.debug(f"[{platform}] OCR 过滤: 浏览状态 line={line!r}") continue # ===== 主动防御: 多段数据面板 (全平台) ===== segs = [s.strip() for s in re.split(r'[。;]', line) if s.strip()] if len(segs) >= 3: digit_segs = sum(1 for s in segs if re.search(r'\d', s)) short_segs = sum(1 for s in segs if len(s) < 30) if digit_segs >= 2 and short_segs >= 3: logger.debug(f"[{platform}] OCR 过滤: 数据面板 line={line!r}") continue # ===== log/log排版残余: OCR 截到自身调试日志的尾巴 (全平台) ===== # "] line[0]" "line[o]:" — 引擎日志行号残留 if re.search(r'\]\s*line\s*\[\d', line, re.IGNORECASE): logger.debug(f"[{platform}] OCR 过滤: log行号 line={line!r}") continue # "Pkdown" — "markdown" 的 OCR 乱码碎片 if re.search(r'[Pp]kdown', line): logger.debug(f"[{platform}] OCR 过滤: Pkdown碎片 line={line!r}") continue # "fragment skipped" — log 文本直接裸露 if 'fragment skipped' in line.lower(): logger.debug(f"[{platform}] OCR 过滤: fragment-skipped line={line!r}") continue # 引号前缀 OCR 残余: "' '玩家..." "'注册用户总数' ," if re.match(r"^'+[\s,)]", line) or re.match(r"^'+[^']+'+\s*,", line): logger.debug(f"[{platform}] OCR 过滤: 引号残余 line={line!r}") continue # 引号后缀 OCR 残余: "注册用户总数' ,"(中文后跟引号逗号) if re.search(r"[\u4e00-\u9fff]'\s*,?\s*$", line): logger.debug(f"[{platform}] OCR 过滤: 引号尾缀 line={line!r}") continue # URL-only: 无中文且含 http/https → OCR 面板图片URL碎片 # OCR 常把 https://img.ypshidifu.cn 识别为 httpsLlimgypshidifu(丢失 ://) if not re.search(r'[\u4e00-\u9fff]', line) and re.search(r'https?\S', line, re.IGNORECASE): logger.debug(f"[{platform}] OCR 过滤: URL-only碎片 line={line!r}") continue # —— 纯非中文乱码:QQ/微信中文群消息都至少有中文 —— # URL 被 OCR 啃成 hpsllimgypshidifu cnli/...(丢失 https://)时 URL-only 漏网 # 排除游戏指令前缀 / 以免误杀 /register password 等合法消息 if len(line) > 10 and not re.search(r'[\u4e00-\u9fff]', line) and not line.startswith('/'): logger.debug(f"[{platform}] OCR 过滤: 纯非中文乱码 line={line!r}") continue # —— CJK 比例过低乱码:EasyOCR 把 URL/代码认成罕见汉字混一堆字母 —— # 如 "刈甘珂呋; .怂U止X旋XLeJ(ILUS.//OITIUU" — 含中文但中文极少 # 用户正常消息的 CJK 比例通常 > 30% if len(line) > 15 and re.search(r'[A-Za-z]', line): cjk_count = sum(1 for c in line if '\u4e00' <= c <= '\u9fff') if cjk_count > 0 and cjk_count / max(len(line), 1) < 0.15: logger.debug(f"[{platform}] OCR 过滤: CJK比例过低乱码 line={line!r}") continue # —— 游戏UI / 非聊天文本乱码(CJK+英文混合) —— # QQ/微信 OCR 在非聊天区域(服务器列表/物品栏/群公告)产出 # CJK+英文混合乱码,特征:下划线英文("Ierite_spear")、CJK后跟大写英文("玩家Ierite") has_cjk = bool(re.search(r'[\u4e00-\u9fff]', line)) has_alpha = bool(re.search(r'[A-Za-z]', line)) if has_cjk and has_alpha and len(line) > 10: # 下划线英文 — 正常中文聊天极少包含("Ierite_spear" 游戏ID/代码残片) if re.search(r'[a-zA-Z]+_[a-zA-Z]+', line): logger.debug(f"[{platform}] OCR 过滤: 游戏UI乱码(下划线英文) line={line!r}") continue # CJK+大写英文组合("玩家Ierite" "比武Mtp")— 游戏UI公告/服务器列表特有 if re.search(r'[\u4e00-\u9fff][A-Z][a-z]{1,}', line): logger.debug(f"[{platform}] OCR 过滤: 游戏UI乱码(CJK+大写英文) line={line!r}") continue # —— 绿色气泡 OCR 乱码:| 管道符混中文 —— # bot 的绿色气泡/蓝色气泡文字经过 OCR 后常把 :; 识别为 |, # 正常中文聊天从不混用 | 与中文。如 "GU |进行配置" "SMTP | 设置" if '|' in line and re.search(r'[\u4e00-\u9fff]', line) and len(line) > 5: logger.debug(f"[{platform}] OCR 过滤: 管道符碎片(绿色气泡) line={line!r}") continue # —— 游戏指令列表碎片:重复 /cmd 模式 —— # OCR 截到命令帮助文本如 "/sdfl主指令 /sdfl reload /sdfl update" # 正常用户消息极少含 ≥2 个独立 /command slash_cmds = re.findall(r'(?:^|\s)/(\w{2,})', line) if len(slash_cmds) >= 2: logger.debug(f"[{platform}] OCR 过滤: 指令列表碎片(多/cmd={slash_cmds}) line={line!r}") continue # —— 斜线英文命令碎片:"/sdfl undo撤销导入" "/register密码" —— # 首字符是 / 且紧跟 ASCII 字母 → 命令帮助/参考碎片,非用户普通对话 # 不拦截 "/兑换码怎么用"(斜线后是中文) if re.match(r'^/[A-Za-z]', line): logger.debug(f"[{platform}] OCR 过滤: 斜线英文命令 line={line!r}") continue # 消息中段含 /英文命令(如"怎么办?/printer玩家名")→ 指令碎片拼接 if re.search(r'(?<=[\s??。!,,])/[A-Za-z]{2,}', line): logger.debug(f"[{platform}] OCR 过滤: 斜线命令碎片(中段) line={line!r}") continue # —— OCR 误读斜线为 I/J:"/sdfl"→"Isdfl", "/import"→"Jimport" —— # 特征1:含已知命令关键词(sdfl/sdf1),零误杀 if re.match(r'^[A-Za-z]', line) and ('sdfl' in line.lower() or 'sdf1' in line.lower()): logger.debug(f"[{platform}] OCR 过滤: 变形命令(sdfl) line={line!r}") continue # 特征2:大写字母+4+小写 + CJK + >10 字符 → 非自然对话 if re.match(r'^[A-Z][a-z]{4,}', line) and re.search(r'[\u4e00-\u9fff]', line) and len(line) > 10: logger.debug(f"[{platform}] OCR 过滤: 变形命令(大写+字母) line={line!r}") continue # —— Bot自身回复过滤:pipe 表格格式(|参数1|参数2|参数3|结果|) —— # OCR 截屏可能截到旧的 bot 回复,pipe 表格不应进入 KB 匹配 pipe_count = line.count('|') if pipe_count >= 3 and len(line) > 30: logger.debug(f"[{platform}] OCR 过滤: bot表格(pipe={pipe_count}) line={line!r}") continue # —— JSON/YAML 碎片兜底:OCR 截屏混入配置面板 —— # 行首是 "key": value 或 key: value(YAML)→ 配置面板碎片 if re.match(r'^["\']?[a-zA-Z_][\w]*["\']?\s*:\s*["\d\[\{tfn]', line): logger.debug(f"[{platform}] OCR 过滤: JSON行 {line!r}") continue if re.match(r'^[a-zA-Z_][\w]*\s*:\s*["\w\d]', line) and \ not re.match(r'^(https?|ftp)://', line) and \ not re.match(r'^[a-zA-Z_][\w]*\s*:\s*/', line): logger.debug(f"[{platform}] OCR 过滤: YAML行 {line!r}") continue # —— 末尾 #标题 碎片兜底 —— # OCR 时间戳分割可能遗漏尾部 " #测试" " #草" 等 md 标题碎片 # 注:\s+ 要求空格在前,避免误杀 "C#语言" "中文#标签" if re.search(r'#[\u4e00-\u9fff]', line): cleaned = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', line).strip() if cleaned != line: if not cleaned or MessageMonitor._is_markdown_document(cleaned): logger.debug(f"[{platform}] OCR 过滤: 末尾#标题碎片 line={line!r}") continue line = cleaned # 如果 line 本身以 # 开头(纯 md),前面的 _is_markdown_document 应该已捕获 # 但此处再补一刀 if MessageMonitor._is_markdown_document(line): logger.debug(f"[{platform}] OCR 过滤: md纯标题碎片 line={line!r}") continue # —— OCR 幻觉乱码检测:连续重复字符(LLL / 111 / ===) —— # EasyOCR 对头像/特殊 UI 输出如 ":山LeLyeeuCL...ggsegU凶>债券管理" # 普通消息不含连续 5+ 相同字符 if re.search(r'([A-Za-z0-9])\1{4,}', line): logger.debug(f"[{platform}] OCR 过滤: 幻觉乱码(重复) line={line!r}") continue # —— OCR 乱码检测:CJK+拉丁混合乱码(如"默i"、"鼍") —— # 正常中文消息不会出现单个拉丁字母夹在CJK之间 if re.search(r'[\u4e00-\u9fff][a-zA-Z][\u4e00-\u9fff]', line): logger.debug(f"[{platform}] OCR 过滤: CJK+拉丁混合乱码 line={line!r}") continue # 生僻字/罕见字检测:CJK扩展区字符(U+3400-U+4DBF, U+20000-U+2A6DF) if re.search(r'[\u3400-\u4dbf\U00020000-\U0002a6df]', line): logger.debug(f"[{platform}] OCR 过滤: 生僻字/罕见字 line={line!r}") continue # —— 系统配置内容过滤:Wiki/文档页面内容 —— if re.search(r'概览|数据看板|在线玩家|注册用户|活跃玩家|玩家数据', line): logger.debug(f"[{platform}] OCR 过滤: 系统配置内容(Wiki) line={line!r}") continue # —— 回复内容片段检测:OCR 捕获 bot 自己刚发的回复 —— # 用 SequenceMatcher 找 OCR 行与 replied 文本的最长公共子串 # 先试原始文本(含字母数字),再试 CJK-only(阈值 6,防短碎片漏网) if self._replied_texts and len(line) > 8: import difflib is_match = False # 第一轮:原始文本匹配 for r in self._replied_texts: if len(r) < 5: continue sm = difflib.SequenceMatcher(None, line, r) if sm.find_longest_match(0, len(line), 0, len(r)).size >= 6: is_match = True break # 第二轮:CJK-only 降阈值匹配(OCR 碎片字母被啃掉时) if not is_match: cjk_line = ''.join(c for c in line if '\u4e00' <= c <= '\u9fff') if len(cjk_line) >= 5: for r in self._replied_texts: r_cjk = ''.join(c for c in r if '\u4e00' <= c <= '\u9fff') if len(r_cjk) < 5: continue sm = difflib.SequenceMatcher(None, cjk_line, r_cjk) if sm.find_longest_match(0, len(cjk_line), 0, len(r_cjk)).size >= 5: is_match = True break if is_match: logger.debug(f"[{platform}] OCR 过滤: 回复内容片段 line={line[:60]!r}") continue # === 全面 md 语法兜底:正常用户不写 md,只可能是 bot 回复 === # ** 加粗标记(用户聊天从不打 **) if '**' in line: logger.debug(f"[{platform}] OCR 过滤: 加粗语法 line={line!r}") continue # 行首 # 标题(未在前面的 md 检测中被捕获的) if re.match(r'^#{1,6}(?:\s|\d|\[|[\u4e00-\u9fff])', line): logger.debug(f"[{platform}] OCR 过滤: 行首标题 line={line!r}") continue # 编号列表:1. 2) 3、 开头 if re.match(r'^\d{1,3}[\.\)、]\s', line): logger.debug(f"[{platform}] OCR 过滤: 编号列表 line={line!r}") continue # 全角 md 链接残片:]( 或 [text](url if re.search(r'\](', line) or re.search(r'\[.+?\](', line): logger.debug(f"[{platform}] OCR 过滤: 全角链接残片 line={line!r}") continue # —— * 开头 md 残片:OCR 吞了另一半,如 *如有错误。请您 —— # 正常用户在 QQ/微信聊天从不以 * 开头 if line.startswith('*'): logger.debug(f"[{platform}] OCR 过滤: *开头残片 line={line!r}") continue # —— *...* 配对残片:OCR 把 **使用** 啃成 *x但不衢...x* —— # 任何 * 包裹内容(不论内容如何)都是 md 语法残余 if re.search(r'\*[^*]+\*', line): logger.debug(f"[{platform}] OCR 过滤: *配对残片 line={line!r}") continue # —— 结构化数据面板:QQ/TIM 指令面板(指令:/rtp 权限类型:玩家 图标:标题:)—— # 3+ 个 中文短词:值 结构,用户聊天从不会连续3+个冒号键值对 colon_kv = re.findall(r'[\u4e00-\u9fff]{1,6}[::](?:[A-Za-z0-9/]|[\u4e00-\u9fff])', line) if len(colon_kv) >= 3: logger.debug(f"[{platform}] OCR 过滤: 结构化面板 line={line!r}") continue # —— OCR 偏旁碎片过滤:氵、亻、讠、扌等 CJK 偏旁不是完整汉字 —— # 从 unicode_radicals.md 加载偏旁字符集,统计出现次数 # ≥1 个偏旁字符 → 判定为 OCR 把汉字拆碎了,整段丢弃 radical_cnt = sum(1 for c in line if c in _RADICAL_CHARS) if radical_cnt >= 1: logger.debug(f"[{platform}] OCR 过滤: 偏旁碎片(radical={radical_cnt}) line={line!r}") continue # —— 系统配置内容过滤:SMTP/邮件地址/代码片段不是用户消息 —— if re.search(r'@example|smtp|\.py\b|\.bat\b|\.sh\b|//.*\.\w{2,4}\b', line, re.IGNORECASE): logger.debug(f"[{platform}] OCR 过滤: 系统配置内容 line={line!r}") continue if re.search(r'@\w+\.\w{2,4}[,.\s]', line) and re.search(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', line): logger.debug(f"[{platform}] OCR 过滤: 邮件地址格式 line={line!r}") continue # —— OCR 短句/病句检测:OCR 把消息"嚼碎"生成碎片 —— # 通用疑问词(有这些词说明有查询意向,放行) _q_words = {"什么", "怎么", "如何", "为什么", "吗", "呢", "吧", "能", "可以", "有没有", "哪些", "哪", "哪几个", "是不是", "会不会", "能不能", "怎么办", "好不好", "行不行"} # "自行修?" → 3字无查询意向碎片;"将其修正后重三" → 病句 if len(line) <= 8: # ≤8字短句:必须包含疑问词才放行,否则是OCR碎片 # 有问号但无疑问词 → 病句(如"自行修?"无查询意向) has_q = any(w in line for w in _q_words) if not has_q: logger.debug(f"[{platform}] OCR 过滤: 短句碎片(≤8字无问词) line={line!r}") continue if 5 < len(line) < 50: # 病句1:句子以动词/助词结尾但缺少宾语且无标点 if re.search(r'[后以了过]+.{0,3}$', line) and not re.search(r'[。!?,,;;::?]$', line): logger.debug(f"[{platform}] OCR 过滤: 病句(动词结尾) line={line!r}") continue # 病句2:句子以"三"结尾(OCR误读"试"为"三") if re.search(r'重三$', line): logger.debug(f"[{platform}] OCR 过滤: 病句(重三) line={line!r}") continue # 病句3:句子缺少主语且无标点(如"将其修正后重三" "认管理员密码多少") # OCR常把"默认"识别丢掉→"认管理员密码多少"、把"帮"识别丢掉→"查xx" if re.match(r'^[其将把被认查问帮试想]', line) and not re.search(r'[。!?,,;;::?]$', line): logger.debug(f"[{platform}] OCR 过滤: 病句(缺主语) line={line!r}") continue # 病句4:OCR 把用户消息和系统提示拼接的复合碎片 # 如"自行修?# 本插件" "请自行下载源码修改...使用类json" if '#' in line or '源码' in line and '修改' in line: # 含文档标记或系统提示特征 if re.search(r'#\s*本|请自行|源码.*修改|修改.*源码', line): logger.debug(f"[{platform}] OCR 过滤: 病句(系统提示拼接) line={line!r}") continue # 病句5:句子含"修"但不是完整查询(如"自行修?" "帮我修" "能不能修") # 除非有明确疑问词,否则"修"碎片不应触发查库 if re.search(r'修[??。]?\s*$', line) and not any(w in line for w in _q_words): logger.debug(f"[{platform}] OCR 过滤: 病句(修字碎片) line={line!r}") continue # 病句6:句子包含时间戳格式(如"16:29" "2026/7/29") # OCR 常把时间戳和用户消息拼接,如"认管理员密码多少?在嘲多改?" if re.search(r'\d{1,2}:\d{1,2}(?::\d{1,2})?|\d{4}[/-]\d{1,2}[/-]\d{1,2}', line): logger.debug(f"[{platform}] OCR 过滤: 病句(含时间戳) line={line!r}") continue # 病句7:句子以数字开头且包含问号(如"1屏蔽?不想要的后缀。") # 可能是 OCR 把消息编号和内容拼接 if re.match(r'^\d+', line) and re.search(r'[??]', line): logger.debug(f"[{platform}] OCR 过滤: 病句(数字开头+问号) line={line!r}") continue # 病句8:句子包含引号包裹的时间戳(如'"2026/7/29"') if re.search(r'["""][^"""]*\d{4}[/-]\d{1,2}[/-]\d{1,2}[^"""]*["""]', line): logger.debug(f"[{platform}] OCR 过滤: 病句(引号包裹时间戳) line={line!r}") continue # 病句9:纯问句碎片(如"怎么办?" "好不好?")无实质内容 # 只有疑问词+标点,或疑问词+少量无意义字符 _pure_q = re.match(r'^(怎么办|好不好|行不行|能不能|可以吗|是不是|有没有|是什么|怎么样|在哪里)\s*[??。!]*\s*$', line) if _pure_q: logger.debug(f"[{platform}] OCR 过滤: 病句(纯问句碎片) line={line!r}") continue # 病句10:问句+乱码碎片拼接(如"怎么办?/printer玩家名") # 问词开头 + 非自然字符(斜线/英文/数字乱拼) if re.match(r'^(怎么办|好不好|行不行|能不能|可以吗|是什么|怎么样)', line): # 检查问词后面是否有乱码特征 rest = re.sub(r'^(怎么办|好不好|行不行|能不能|可以吗|是什么|怎么样)\s*[??。!]*\s*', '', line) if rest and (re.search(r'/[A-Za-z]{2,}', rest) or re.search(r'[a-zA-Z]{3,}', rest) or len(rest) < 3): logger.debug(f"[{platform}] OCR 过滤: 病句(问句+乱码拼接) line={line!r}") continue texts.append(line) logger.debug(f"[{platform}] OCR -> {len(ocr_lines)} raw, {len(texts)} filtered, " f"last=\"{texts[-1][:50] if texts else ''}\"") return texts def _read_via_clipboard_once(self, platform: str) -> list: """剪贴板法单次执行(安全键盘导航:Enter + Ctrl+End + Ctrl+A/C,无 Esc)。 去重由 _poll 的 _replied_texts + _seen_messages 负责,本函数只过滤并返回。 回复回环防御由 _paste_and_send 写入 _replied_texts 承担。 """ hwnd = self._cached_hwnd.get(platform) if not hwnd or not win32gui.IsWindow(hwnd): return [] import win32clipboard as wc old = None try: wc.OpenClipboard() if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT): old = wc.GetClipboardData(wc.CF_UNICODETEXT) wc.CloseClipboard() except Exception: pass texts = [] raw_lines = [] # 预初始化,防止 content 为空时 UnboundLocalError try: # 确保窗口在前台(失败则返回空,避免发送到错误窗口) # QQ 剪贴板优先路径需要更可靠的前台保证(与发送路径 retries 一致) if not self._ensure_foreground(hwnd, max_retries=5): return [] # PostMessage 点击消息区域(设焦点,不全局,不比鼠标移动) rect = win32gui.GetWindowRect(hwnd) if rect: left, top, right, bottom = rect w = right - left h = bottom - top # QQ NT 消息面板在 ~55-95% 宽度区域 click_x = int(w * 0.72) # 消息面板中部 click_y = int(h * 0.60) # 消息面板中部偏下 lparam = win32api.MAKELONG(click_x, click_y) win32gui.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam) win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) time.sleep(0.2) # 滚动到底部(PostMessage 安全,不切焦点,确保新消息可见) self._force_scroll_bottom(platform) time.sleep(0.2) # TIM/QQ NT: 安全键盘导航(无 Esc,不关窗口) # 每步前校验目标窗口仍在焦点,防止 keybd_event 泄露到控制台 VK_CONTROL = 0x11 variant = self._app_variant.get(platform, "") if variant in ("tim", "qq_nt"): VK_RETURN = 0x0D VK_END = 0x23 # Enter 激活当前对话 if self._fg_is_target(hwnd): ctypes.windll.user32.keybd_event(VK_RETURN, 0, 0, 0) time.sleep(0.05) ctypes.windll.user32.keybd_event(VK_RETURN, 0, 2, 0) time.sleep(0.3) # Ctrl+End 滚动到底部(双保险:PostMessage + keybd_event) if self._fg_is_target(hwnd): ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) ctypes.windll.user32.keybd_event(VK_END, 0, 0, 0) time.sleep(0.05) ctypes.windll.user32.keybd_event(VK_END, 0, 2, 0) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0) time.sleep(0.3) # Ctrl+A (全选) — 仅当目标窗口仍在焦点 # 先检查发送锁:主线程可能正在 _paste_and_send,避免 Ctrl+C 覆盖其剪贴板 if self._sending_event.is_set(): logger.debug(f"[{platform}] 发送中,剪贴板读取跳过防竞争") return [] VK_A = 0x41 if self._fg_is_target(hwnd): ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) ctypes.windll.user32.keybd_event(VK_A, 0, 0, 0) time.sleep(0.05) ctypes.windll.user32.keybd_event(VK_A, 0, 2, 0) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0) time.sleep(0.15) # Ctrl+C (复制) VK_C = 0x43 if self._fg_is_target(hwnd): ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) ctypes.windll.user32.keybd_event(VK_C, 0, 0, 0) time.sleep(0.05) ctypes.windll.user32.keybd_event(VK_C, 0, 2, 0) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0) time.sleep(0.3) wc.OpenClipboard() if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT): content = wc.GetClipboardData(wc.CF_UNICODETEXT) if content: raw_lines = [l.strip() for l in content.strip().split('\n') if l.strip()] self._raw_clipboard[platform] = content wc.CloseClipboard() logger.debug(f"[{platform}] 剪贴板读取 → {len(raw_lines)} lines") # ⭐ 剪贴板 hash:内容没变化 → 跳过(防止跨平台读到过时数据) import hashlib cur_h = hashlib.md5(content.encode('utf-8') if content else b'').hexdigest() last_h = self._last_clipboard_hash.get(platform, '') if cur_h == last_h: logger.debug(f"[{platform}] 剪贴板内容未变,跳过 stale 数据") return [] self._last_clipboard_hash[platform] = cur_h # ── 过滤:只返回"像用户消息"的行 ── sender_pattern = re.compile(r'^[\u4e00-\u9fff·A-Za-z0-9_\-]{1,10}$') for line in raw_lines: # 清理 QQ 消息末尾的状态标记(如 "O~" "C~" "0~O~"),避免污染 KB 匹配 line = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', line).strip() if not line: continue # 跳过:系统消息、纯时间、纯数字、无意义短行 if self._is_system_message(line): continue # 跳过:控制台/Python 垃圾(剪贴板截错窗口内容) if self._is_ocr_console_garbage(line): continue # 跳过:单行发送者名(1-10汉字/字母/数字/下划线) if sender_pattern.match(line): continue # 跳过:纯时间格式(连字符/斜杠分格日期) if re.match(r'^\d{1,2}[::\s]\d{2}', line): continue if re.match(r'^\d{4}[/-]\d{1,2}', line): continue # 跳过:纯 emoji / 纯标点(短于 6 个字符且只有符号) clean = re.sub('[~!@#$%^&*()_+\\-=\\[\\]{};:\\\'",.<>/?·!@#¥%……&*()—+。,、;:""\'\'【】{}「」『』《》【】 \\t\\n\\r\\u2000-\\u206f\\u2190-\\u2bff\\U0001F000-\\U0001FFFF]', '', line) if len(clean) == 0 and len(line) <= 6: continue # 跳过:游戏UI乱码(CJK+下划线英文、CJK+大写英文组合) if re.search(r'[\u4e00-\u9fff]', line) and re.search(r'[A-Za-z]', line) and len(line) > 10: if re.search(r'[a-zA-Z]+_[a-zA-Z]+', line): logger.debug(f"[{platform}] 剪贴板过滤: 游戏UI乱码(下划线英文) line={line!r}") continue if re.search(r'[\u4e00-\u9fff][A-Z][a-z]{1,}', line): logger.debug(f"[{platform}] 剪贴板过滤: 游戏UI乱码(CJK+大写英文) line={line!r}") continue # 通过全部检查 → 保留 texts.append(line) logger.debug(f"[{platform}] 剪贴板过滤后 → {len(texts)} 条用户消息") except Exception as e: logger.debug(f"[{platform}] 剪贴板失败: {e}") finally: if old is not None: try: wc.OpenClipboard() wc.EmptyClipboard() wc.SetClipboardText(old, wc.CF_UNICODETEXT) wc.CloseClipboard() except Exception: pass return texts def get_raw_clipboard(self, platform: str) -> str: """获取最近一次剪贴板读取的原始文本。""" return self._raw_clipboard.get(platform, "") @staticmethod def _parse_last_sender(raw_text: str) -> Optional[str]: """从剪贴板原始文本中提取最后一条消息的发送者。 TIM/QQ 剪贴板格式通常是: [发送者] [时间] [消息] (空行) [发送者] [时间] [消息] """ if not raw_text: return None blocks = re.split(r'\n\s*\n', raw_text.strip()) if not blocks: return None last_block = blocks[-1].strip() if not last_block: return None lines = [l.strip() for l in last_block.split('\n') if l.strip()] if not lines: return None candidate = lines[0] # 排除时间格式 if re.match(r'^\d{1,2}[::]\d{2}', candidate): return None if re.match(r'^\d{4}[/-]\d{1,2}', candidate): return None if len(candidate) > 30: return None return candidate def _search_contact(self, platform: str, contact: str) -> bool: """在 TIM/QQ 搜索框中搜索联系人并切换到对应会话。""" hwnd = self._cached_hwnd.get(platform) if not hwnd: return False try: win = auto.ControlFromHandle(hwnd) search = win.EditControl(Name="搜索", maxSearchSeconds=0.5) if not search.Exists(maxSearchSeconds=0.3): logger.warning(f"[{platform}] 找不到搜索框") return False # 恢复窗口并前置(失败则放弃搜索) if win32gui.IsIconic(hwnd): win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) if not self._ensure_foreground(hwnd): logger.warning(f"[{platform}] 无法前置窗口,放弃搜索联系人") return False time.sleep(0.2) # SetFocus 到搜索框(不移动鼠标) search.SetFocus() time.sleep(0.2) # 全选已有内容并输入联系人名 auto.SendKeys('{Ctrl}a') time.sleep(0.1) auto.SendKeys(contact) time.sleep(0.5) # Enter 打开第一个结果 auto.SendKeys('{Enter}') time.sleep(0.5) logger.info(f"[{platform}] 已搜索并切换到联系人: {contact}") return True except Exception as e: logger.error(f"[{platform}] 搜索联系人失败: {e}") return False # ── SendInput 键盘模拟 ───────────────────────────────── @staticmethod def _key_down(vk: int): """SendInput KEYBDINPUT keydown.""" ctypes.windll.user32.keybd_event(vk, 0, 0, 0) @staticmethod def _key_up(vk: int): """SendInput KEYBDINPUT keyup (KEYEVENTF_KEYUP=2).""" ctypes.windll.user32.keybd_event(vk, 0, 2, 0) @staticmethod def _send_key_press(vk: int, delay: float = 0.05): """SendInput single key press (down + up).""" ctypes.windll.user32.keybd_event(vk, 0, 0, 0) time.sleep(delay) ctypes.windll.user32.keybd_event(vk, 0, 2, 0) @staticmethod def _send_ctrl_combo(vk: int): """Send Ctrl+: Ctrl down → key down → key up → Ctrl up.""" VK_CONTROL = 0x11 ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) ctypes.windll.user32.keybd_event(vk, 0, 0, 0) ctypes.windll.user32.keybd_event(vk, 0, 2, 0) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0) @staticmethod def _post_key(hwnd, vk, delay=0.05): """PostMessage 键盘到指定窗口(不全局,安全)。""" win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, vk, 0) time.sleep(delay) win32gui.PostMessage(hwnd, win32con.WM_KEYUP, vk, 0) @staticmethod def _post_ctrl_combo(hwnd, vk, delay=0.05): """PostMessage Ctrl+组合键到指定窗口(不泄露到控制台)。""" VK_CONTROL = 0x11 win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, VK_CONTROL, 0) win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, vk, 0) time.sleep(delay) win32gui.PostMessage(hwnd, win32con.WM_KEYUP, vk, 0) win32gui.PostMessage(hwnd, win32con.WM_KEYUP, VK_CONTROL, 0) def _fg_is_target(self, hwnd) -> bool: """校验目标窗口仍在焦点,防止 keybd_event 泄露到控制台。""" try: fg = win32gui.GetForegroundWindow() root = win32gui.GetAncestor(hwnd, win32con.GA_ROOT) return fg == root or (fg and win32gui.IsChild(root, fg)) except Exception: return False @staticmethod def _image_hash(im) -> str: """感知哈希:缩至 9x8 灰阶 → dHash(差值哈希,对局部变化敏感)。 dHash 比较每行相邻像素的梯度,比 aHash(均值哈希)更能捕捉文字变化。 9 列 × 8 行 = 72 位 hash,比 8×8=64 位 aHash 信息量更大。 """ small = im.convert('L').resize((9, 8), Image.LANCZOS) pixels = list(small.getdata()) # 每行 9 个像素,比较相邻像素:左 > 右 → 1,否则 → 0 bits = [] for row in range(8): for col in range(8): left = pixels[row * 9 + col] right = pixels[row * 9 + col + 1] bits.append('1' if left > right else '0') return ''.join(bits) @staticmethod def _is_white_bubble_pixel(px, x, y, w, h) -> bool: """判定 (x,y) 的纯白像素是否属于"白色气泡"(对方消息),而非: - 聊天浅灰背景(本身不是 >=245 候选) - 彩色(绿/蓝)自身气泡上的白色文字(邻域为彩色底色) 规则:四方向(±4px)邻域中 >=3 个也为 near-white(>=245) 才算气泡。 白字笔画的邻域多为彩色气泡底色 → 不通过 → 不被误判为对方消息。 """ d = 4 hits = 0 for dx, dy in ((d, 0), (-d, 0), (0, d), (0, -d)): nx, ny = x + dx, y + dy if 0 <= nx < w and 0 <= ny < h: try: rr, gg, bb = px[nx, ny][:3] if rr >= 245 and gg >= 245 and bb >= 245: hits += 1 except Exception: pass return hits >= 3 @staticmethod def _has_user_colors(im, platform: str) -> bool: """区域颜色检测:判断截图中是否有**对方发来的消息**,决定是否需要OCR。 ⭐ 核心原则:自身消息 = 最顶层门禁,不过则全部取消。 - QQ: 用户白色气泡左对齐,bot/对方蓝色气泡右对齐 - WeChat: 对方白色气泡左对齐,自己绿色气泡右对齐 返回 True=有对方消息需要OCR,False=仅自身消息/无消息 → 跳过 """ w, h = im.size if w < 10 or h < 10: return True # 跳过顶部 20%(群名/大标题栏) check = im.crop((0, int(h * 0.20), w, h)) cw, ch = check.size if cw < 20 or ch < 20: return True # 左右半区 left_half = check.crop((0, 0, cw // 2, ch)) right_half = check.crop((cw // 2, 0, cw, ch)) step_x = max(2, cw // 15) step_y = max(2, ch // 15) if platform == "qq": # QQ: 蓝色 = bot/对方气泡(右对齐) def is_bot(r, g, b): return 110 <= r <= 190 and 170 <= g <= 235 and 220 <= b <= 255 else: # WeChat: 绿色 = 自己发的消息(右对齐)— 不是 bot 色 def is_self(r, g, b): return 135 <= r <= 180 and 205 <= g <= 250 and 75 <= b <= 125 is_bot = is_self def analyze(img): p = img.load() bot = other = white = total = 0 iw, ih = img.width, img.height for y in range(0, ih, step_y): for x in range(0, iw, step_x): total += 1 try: r, g, b = p[x, y][:3] except: continue if r >= 245 and g >= 245 and b >= 245: # ⚠️ 纯白像素可能是:①对方白色气泡(背景/白字) ②聊天浅灰背景 # ③绿色/蓝色自身气泡上的白色文字。 # ②③ 不能算"对方消息"——否则微信自身绿气泡白字被误判为对方 # → OCR → 自回复循环。必须确认邻域也为大面积白色(气泡)才计入。 if MessageMonitor._is_white_bubble_pixel(p, x, y, iw, ih): white += 1 elif is_bot(r, g, b): bot += 1 else: other += 1 return bot, other, white, total left_bot, left_other, left_white, left_total = analyze(left_half) right_bot, right_other, right_white, right_total = analyze(right_half) rlogger = logging.getLogger("msgbot.monitor") rlogger.debug(f"[{platform}] 像素: 左其它={left_other}/{left_total} " f"{'自身' if platform=='wechat' else 'bot'}={left_bot} 白={left_white} | " f"右其它={right_other}/{right_total} " f"{'自身' if platform=='wechat' else 'bot'}={right_bot} 白={right_white}") # ====== 微信专用逻辑 ====== # 颜色语义: # 白色(>225,>225,>225) = 对方消息(左对齐) # 绿色(135-180,205-250,75-125) = 自己消息(右对齐) # 其它 = 背景/UI元素 if platform == "wechat": # ⭐ 自身消息门禁:右大量绿色(自身) + 左无白色(无对方) → 仅自身,跳过 if right_bot >= 3 and left_white < 3: rlogger.debug(f"[wechat] 像素检测: 仅自身消息(右绿={right_bot},左白={left_white}), 跳过") return False # 左半区有白色气泡 → 有对方消息,必须OCR if left_white >= 3: rlogger.debug(f"[wechat] 像素检测: 有对方消息(左白={left_white}), 继续") return True # 左半区无白色气泡 → 无对方消息,跳过 # (包括:仅自身消息、空聊天、非聊天界面) rlogger.debug(f"[wechat] 像素检测: 无对方白色气泡(左白={left_white}), 跳过") return False # ====== QQ/TIM 专用逻辑 ====== # 颜色语义: # 白色(>225,>225,>225) = 用户消息(左对齐) # 蓝色(110-190,170-235,220-255) = bot/对方消息(右对齐) # 其它 = 背景/UI元素 # ⭐ 核心原则:只要左半区无白色气泡 → 无用户消息 → 跳过 # ⭐ 跨窗口污染检测:QQ窗口无绿色气泡,若检测到大量绿色像素,说明截到了微信窗口 def is_wechat_green(r, g, b): return 135 <= r <= 180 and 205 <= g <= 250 and 75 <= b <= 125 left_green = right_green = 0 for y in range(0, ch, step_y): for x in range(0, cw // 2, step_x): try: r, g, b = check.load()[x, y][:3] if is_wechat_green(r, g, b): left_green += 1 except: continue for y in range(0, ch, step_y): for x in range(cw // 2, cw, step_x): try: r, g, b = check.load()[x, y][:3] if is_wechat_green(r, g, b): right_green += 1 except: continue total_green = left_green + right_green if total_green >= 3: rlogger.debug(f"[qq] 像素检测: 检测到微信绿色像素(绿={total_green}), 判定为微信窗口, 跳过") return False # 左半区有白色气泡 → 有用户消息,必须OCR if left_white >= 3: rlogger.debug(f"[qq] 像素检测: 有用户消息(左白={left_white}), 继续") return True # 左半区无白色气泡 → 无用户消息,跳过 # (包括:仅bot回复、空聊天、非聊天界面) rlogger.debug(f"[qq] 像素检测: 无用户白色气泡(左白={left_white}), 跳过") return False @staticmethod def _split_merged_ocr_text(text: str) -> list: """尝试将 OCR 单行合并文本在中文句尾标点处拆分。 EasyOCR 常把横向相邻的两条消息(用户问句 + 群昵称/UI文字/其他人消息) 检测为同一行文本,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一"式合并。 启发式: 在[。?!?]后紧跟CJK字符的位置拆分,前提是两侧都有足够中文。 不拆分续接句(第二段以"但/而/并/且/或/还/也/又/就/才/再/变"开头)。 """ if len(text) < 10: return [text] _continuations = {'但', '而', '并', '且', '或', '还', '也', '又', '就', '才', '再', '变', '便', '则', '却', '虽', '因', '为', '与', '和', '同', '跟'} result = [] remaining = text max_splits = 2 # 最多拆 2 次(3 段),防过度拆分 while max_splits > 0: # 找[。?!?]后紧跟CJK的位置 m = re.search(r'([。?!?])([\u4e00-\u9fff])', remaining) if not m: break split_pos = m.start() + 1 # 标点后 first = remaining[:split_pos] second = remaining[split_pos:] # 两侧都需有足够 CJK first_cjk = sum(1 for c in first if '\u4e00' <= c <= '\u9fff') second_cjk = sum(1 for c in second if '\u4e00' <= c <= '\u9fff') if first_cjk < 3 or second_cjk < 3: break # 第二段不以续接词开头 if second[0] in _continuations: break # 第二段太短(少于 3 CJK)→ 不拆 first_part = first second_part = second result.append(first_part) remaining = second_part max_splits -= 1 result.append(remaining) if len(result) > 1: logger.debug(f"[ocr] 句尾标点拆分: {[r[:60] for r in result]}") return result def _is_reply_fragment(self, text: str) -> bool: """检查文本是否为 bot 回复内容的碎片(OCR 截取的 md 残片/跨行碎片)。""" if not self._replied_texts or len(text) < 6: return False import difflib # 原始文本匹配(含字母数字) for r in self._replied_texts: if len(r) < 5: continue sm = difflib.SequenceMatcher(None, text, r) if sm.find_longest_match(0, len(text), 0, len(r)).size >= 6: return True # CJK-only 降阈值 cjk_t = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff') if len(cjk_t) >= 5: for r in self._replied_texts: r_cjk = ''.join(c for c in r if '\u4e00' <= c <= '\u9fff') if len(r_cjk) < 5: continue sm = difflib.SequenceMatcher(None, cjk_t, r_cjk) if sm.find_longest_match(0, len(cjk_t), 0, len(r_cjk)).size >= 5: return True return False @staticmethod def _is_timestamp_old(ts_str: str, max_age: int = 300) -> bool: """检查 HH:MM 或 HH:MM:SS 时间戳是否超过 max_age 秒。 处理跨午夜边界:如果 diff > 12h 说明是昨天的时间戳。 返回 True = 时间戳太老,应跳过。 """ ts = ts_str.strip() if not ts: return False m = re.match(r'^(\d{1,2}):(\d{2})(?::(\d{2}))?$', ts) if not m: return False h, mi, s = int(m.group(1)), int(m.group(2)), int(m.group(3) or 0) if h > 23 or mi > 59 or s > 59: return False now = datetime.now() try: parsed = now.replace(hour=h, minute=mi, second=s, microsecond=0) except ValueError: return False diff = (now - parsed).total_seconds() # 跨午夜: diff > 12h 说明时间戳来自昨天 if diff > 43200: parsed = parsed.replace(day=parsed.day - 1) diff = (now - parsed).total_seconds() # 未来时间戳: diff < -12h 说明是明天(当前 23:55,时间戳 00:05) if diff < -43200: parsed = parsed.replace(day=parsed.day + 1) diff = (now - parsed).total_seconds() return diff > max_age @staticmethod def _is_system_message(text: str) -> bool: """过滤机器人/系统消息,不作为用户消息处理。""" if not text: return True # NFKC 标准化:去除 UIA/OCR 夹杂的零宽字符、不可见 Unicode text = unicodedata.normalize('NFKC', text) # 系统前缀/标记 system_markers = [ "✅", "❌", "📝", "🔍", "📄", "➡", "♡", "✿", "[WECHAT]", "[QQ]", "[wechat]", "[qq]", ] for m in system_markers: if text.startswith(m): return True # ⭐ QQ/微信 界面噪声文字(按钮/提示/碎片/导航,非用户消息) qq_ui_noise = [ "查看更多", "可查看在其他设备产生的聊天记录", "收藏", "转发", "多选", "引用", "回复", "选择会话开始聊天", # 微信底部导航 "通讯录", "发现", "我", "消息", "朋友圈", # 微信/QQ 顶部/侧边 "搜索", "添加到表情", "更多", # 微信底部栏 + 附加按钮 "导航", "视频号", # QQ 空间/动态 "空间", "动态", "说说", "日志", "相册", # 通用 UI "发送", "接收", "取消", "确认", "确定", "关闭", ] for n in qq_ui_noise: if text == n: return True # 系统事件模板 — 注意:用 startswith 而非 in,避免混合文本(OCR 多行合一)误杀 system_patterns = [ "已自动回复", "知识库中未找到", "找到相关结果", "监控已启动", "监控已暂停", "设置已保存", "🤖", "找不到联系人", "当前会话回复", "自动回复触发", "匹配度", # KB 文档内容格式(机器人回复格式),OCR 可能把用户消息+KB 回复混成一行 # 只有全行以"答:"开头才过滤,内含"答:"的混合文本不过滤 "答:", # 纯系统命令反馈 "PS ", "❯ ", "$ ", ] for p in system_patterns: if text.startswith(p): return True # 纯时间格式(不含消息内容) if re.match(r'^\d{1,2}:\d{2}', text): return True return False @staticmethod def _strip_json_yaml_blocks(raw_text: str, platform: str = "") -> str: """剥离 OCR 截屏中混入的 JSON/YAML 配置块。 微信 OCR 截屏范围 40%-98%,常把右侧文档/配置面板一起截入。 JSON 块("key": value, {, })和 YAML 块(key: value, - item) 跟用户消息混在一起,导致 split 后 JSON 碎片污染用户消息行。 策略:逐行检测,丢弃明显是 JSON/YAML 语法的行。 用户聊天不会写 "key": value 或 { } 块。 """ lines = raw_text.split('\n') cleaned = [] in_json_block = False brace_depth = 0 for line in lines: stripped = line.strip() if not stripped: cleaned.append(line) continue # 检测 JSON 对象块开始/结束 if stripped == '{' or stripped.startswith('{') and '"' in stripped: in_json_block = True brace_depth = stripped.count('{') - stripped.count('}') logger.debug(f"[{platform}] JSON块剥离: 开始 {stripped[:60]!r}") continue if in_json_block: brace_depth += stripped.count('{') - stripped.count('}') if brace_depth <= 0: in_json_block = False logger.debug(f"[{platform}] JSON块剥离: 结束 {stripped[:60]!r}") continue # JSON 行特征: "key": value 或 "key": "value" if re.match(r'^["\']?[a-zA-Z_][\w]*["\']?\s*:\s*["\d\[\{tfn]', stripped): logger.debug(f"[{platform}] JSON块剥离: key-value行 {stripped[:60]!r}") continue # YAML 行特征: key: value(无引号,非 URL) if re.match(r'^[a-zA-Z_][\w]*\s*:\s*["\w\d]', stripped) and \ not re.match(r'^(https?|ftp)://', stripped) and \ not re.match(r'^[a-zA-Z_][\w]*\s*:\s*/', stripped): logger.debug(f"[{platform}] JSON块剥离: YAML行 {stripped[:60]!r}") continue # YAML 列表项: - item if re.match(r'^-\s+\S', stripped) and \ not re.match(r'^-\s*[\u4e00-\u9fff]', stripped): logger.debug(f"[{platform}] JSON块剥离: YAML列表 {stripped[:60]!r}") continue # 纯 JSON 数组: [ "item", ... ] if stripped.startswith('[') and stripped.endswith(']') and \ '"' in stripped: logger.debug(f"[{platform}] JSON块剥离: 数组 {stripped[:60]!r}") continue cleaned.append(line) return '\n'.join(cleaned) @staticmethod def _is_markdown_document(text: str) -> bool: """检测是否为 Markdown 格式的文档内容。 Bot 回复用 md 格式(# 标题、[链接](url)、列表等),用户日常对话不用。 借此区分 bot 旧回复和历史堆积 vs 用户新消息。 """ if not text: return False # # 行首标题:短文本也检测(OCR 把 ##协议修 切成 5 字符片段) if re.match(r'^#{1,6}(?:\s|\[|[\u4e00-\u9fff])', text): return True # 行首列表项:短文本也检测(- 玩家义务 / * 注意) # 必须放 len<10 之前,否则 "- 玩家应尊重他"(8c) 漏网 if re.match(r'^[\-\*]\s', text): return True # 全角括号退化 md 链接:短文本也检测 if re.search(r'\](', text): return True if re.search(r'\[.+?\](', text): return True # OCR 退化 md checkbox:短文本也检测([V] / [x]) if re.search(r'\[\s*[VXvx√✗]\s*\]', text): return True # OCR 退化 md 链接缺括号:短文本也检测 if re.search(r'\[.+?\]\(', text): return True # **加粗**语法:短文本也检测(bot md 回复标配,用户日常不用) if re.search(r'\*\*.+?\*\*', text): return True #
HTML 换行标记 — bot md 回复中用于换行,用户聊天不打 if re.search(r'<\s*br\s*/?\s*>', text, re.IGNORECASE): return True # ###1. / ####数字标题 — 数字直接跟在 # 后(OCR 常见) if re.match(r'^#{1,6}\d', text): return True # 编号列表项:1. 2. 12. / 1) 2) / 1、2、 if re.match(r'^\d{1,3}[\.\)、]\s', text): return True # 引用块:> 开头 if re.match(r'^>\s', text): return True # 水平分隔线:--- 或 === 连续 3+ 个 if re.match(r'^[-=]{3,}\s*$', text): return True # |表格行:| 开头和结尾 if re.match(r'^\|.+\|$', text): return True # *斜体* / ***粗斜体* 或任何 ** 裸标记 # 用户日常对话从不打 **,只可能是 bot md 回复 if '**' in text: return True if len(text) < 10: return False # ##直接跟汉字(OCR 丢失 ##后空格时常见:##文档导航) if re.match(r'^#{1,6}[\u4e00-\u9fff]', text): return True # 包含 markdown 链接 [text](url) if re.search(r'\[.+?\]\(.+?\)', text): return True # 包含 .md 文件引用(如 "gin.md)" 分割后断开的链接) if re.search(r'\S+\.md\)', text): return True # 包含代码块标记 ``` 或 `code` if re.search(r'`{1,3}', text): return True # 连续 3+ 行包含 markdown 特征:| 表格、--- 分隔线、> 引用 pipes = text.count('|') if pipes >= 3: return True # 内联 # # 标题标记(如 "# # 必装依赖" → 分割后 `# #` 或 `# #`) if re.search(r'#\s*#', text): return True return False @staticmethod def _extract_user_text_from_md_fragment(text: str) -> list: """从混合了 md 文档和用户消息的 fragment 中提取用户文本。 OCR 经常把 bot md 回复和用户新消息合并为一行无换行文本, 如 "用户问题 ##bot标题|表格|数据|...另一个用户问题"。 本方法找到第一个 md 标记位置,提取其前面的用户文本; 再找到末尾连续非 md 尾巴,提取为第二条用户文本。 """ # ── 整段以 ## 开头 → 无用户文本(无空格 ##中文/###数字 也捕获)── if re.match(r'^#{1,6}(?:\s|\[|[\u4e00-\u9fff]|\d)', text): return [] # 整段以 ** 开头 → 纯加粗语法,无用户文本 if text.startswith('**'): return [] # 整段以 - / * / 数字编号 开头 → 列表项,无用户文本 if re.match(r'^[\-\*\d]\s', text): return [] # 整段以 > 开头 → 引用块,无用户文本 if re.match(r'^>\s', text): return [] # 整段以 [text]( 开头 → 全角括号 md 链接碎片,无用户文本 if re.match(r'^\[.+?\](', text): return [] # ── 找第一个 markdown 起始标记的位置 ── # 标记:## 标题、[text](url)、.md)、| 表格分隔、``` 代码块 md_start = len(text) # 默认整段都是 md # ## 标题(放宽:允许 ##后直接跟汉字/[,不限于空格) m = re.search(r'(?<=\S)\s*#{1,6}(?:\s|\[|[\u4e00-\u9fff])', text) if m and m.start() < md_start: md_start = m.start() # [text](url) 链接(含全角括号退化 [text](url)) m = re.search(r'(?<=\S)\s*\[.+?\]\(.+?\)', text) if m and m.start() < md_start: md_start = m.start() m = re.search(r'(?<=\S)\s*\[.+?\](.+?)', text) if m and m.start() < md_start: md_start = m.start() # .md) 文件引用 m = re.search(r'(?<=\S)\s*\S+\.md\)', text) if m and m.start() < md_start: md_start = m.start() # | 表格 m = re.search(r'(?<=\S)\s*\|', text) if m and m.start() < md_start: md_start = m.start() # ``` 代码块 m = re.search(r'`{3}', text) if m and m.start() < md_start: md_start = m.start() results = [] # 提取 md 标记前的用户文本 if md_start > 2: # 如果 md 标记是 | 且前一个字符非空白 → head 是表格细胞值,不是用户提问 skip_head = ( md_start < len(text) and text[md_start] == '|' and md_start > 0 and not text[md_start-1].isspace() ) if not skip_head: head = text[:md_start].strip() # 去掉尾部状态标记 head = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', head).strip() # 过滤纯标点/符号 if head and not re.match(r'^[\d:\s|]+$', head): results.append(head) # 提取末尾可能的用户文本(md 块之后的尾巴) # 找最后一个句号/感叹号/问号后的内容(可能是新消息的开头) tail_pattern = re.search(r'[。!?\?!.]\s*(.{2,50})$', text) if tail_pattern: tail = tail_pattern.group(1).strip() tail = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', tail).strip() if tail and not re.match(r'^[\d:\s|]+$', tail): # 不是纯标点、不是表格碎片 if not re.match(r'^[|/\-─━━═]+$', tail): results.append(tail) # 末尾没有句号但有中文问号结尾的(如 "PHP用户端有哪些登录模式?") if not tail_pattern: tail2 = re.search(r'([\u4e00-\u9fff].{2,50}\?)$', text) if tail2: tail = tail2.group(1).strip() tail = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', tail).strip() if tail and len(tail) >= 3: # 避免重复 if tail not in results: results.append(tail) # ── 终极过滤:提取结果含 ** 加粗语法或 # 标题 → 不是用户消息 ── # 正常聊天用户从不打 md 语法标记 results = [r for r in results if '**' not in r and not r.startswith('#')] return results def _paste_and_send_safe(self, text: str, hwnd: int, msg_hash: int, platform: str) -> bool: """Electron 应用安全发送(TIM/微信)。无 AttachThreadInput、无 SendMessage 同步点击。 Electron 多线程架构下 AttachThreadInput 会导致死锁, SendMessage 同步阻塞在渲染进程 busy 时直接炸进程。 只用 SwitchToThisWindow + 前台确认后 keybd_event。 """ import win32clipboard as wc # 保存原始剪贴板 old = None try: wc.OpenClipboard() if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT): old = wc.GetClipboardData(wc.CF_UNICODETEXT) wc.CloseClipboard() except Exception: pass success = False try: # 设剪贴板 try: wc.OpenClipboard() wc.EmptyClipboard() wc.SetClipboardText(text, wc.CF_UNICODETEXT) wc.CloseClipboard() except Exception as e: logger.error(f"[{platform}] 剪贴板设文本失败: {e}") return False root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT) # 恢复/显示窗口(仅 SwitchToThisWindow + SetForegroundWindow,无 AttachThreadInput) try: if win32gui.IsIconic(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) time.sleep(0.3) if not win32gui.IsWindowVisible(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) time.sleep(0.3) ctypes.windll.user32.AllowSetForegroundWindow(-1) ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) time.sleep(0.2) win32gui.SetForegroundWindow(root_hwnd) time.sleep(0.3) except Exception as e: logger.debug(f"[{platform}] 窗口唤醒异常: {e}") # 验证前台 — 失败则放弃,不做 SendMessage 防崩 fg_now = win32gui.GetForegroundWindow() fg_ok = (fg_now == root_hwnd) or (fg_now and win32gui.IsChild(root_hwnd, fg_now)) if not fg_ok: logger.warning(f"[{platform}] 前台失败,放弃发送(不做 SendMessage 防崩)") return False # keybd_event 发送(只有在前台确认后才做) VK_A = 0x41 VK_V = 0x56 VK_BACK = 0x08 VK_RETURN = 0x0D try: self._send_ctrl_combo(VK_A) time.sleep(0.05) self._key_down(VK_BACK) self._key_up(VK_BACK) time.sleep(0.05) self._send_ctrl_combo(VK_V) time.sleep(0.3) self._key_down(VK_RETURN) self._key_up(VK_RETURN) time.sleep(0.5) except Exception as e: logger.error(f"[{platform}] keybd_event 发送失败: {e}") return False logger.info(f"[{platform}] 已回复(安全路径): {text[:60]}") # 记录回复到去重集(与 _paste_and_send 一致) reply_text = text.strip() self._replied_texts.add(reply_text) self._seen_messages.add(reply_text) if '\n' in reply_text: self._replied_texts.add(reply_text.replace('\n', ' ')) self._replied_texts.add(reply_text.replace('\n', ' ').rstrip('.')) self._seen_messages.add(reply_text.replace('\n', ' ')) self._seen_messages.add(reply_text.replace('\n', ' ').rstrip('.')) for part in reply_text.split('\n'): part = part.strip() if part and len(part) > 3: self._replied_texts.add(part) self._seen_messages.add(part) if part.startswith('#'): no_hash = re.sub(r'^#+\s*', '', part).strip() if no_hash and len(no_hash) > 2: self._replied_texts.add(no_hash) self._seen_messages.add(no_hash) self._last_reply_time[platform] = time.time() self._self_reply_cooldown_until[platform] = time.time() + 15.0 flat = reply_text.replace('\n', ' ') if len(flat) > 15: self._replied_texts.add(flat[:30]) if len(flat) > 40: self._replied_texts.add(flat[-30:].strip()) self._foreground_cooldown_until[platform] = time.time() + 30.0 success = True return True finally: # 恢复原始剪贴板 if old is not None: try: wc.OpenClipboard() wc.EmptyClipboard() wc.SetClipboardText(old, wc.CF_UNICODETEXT) wc.CloseClipboard() except Exception: pass # 失败时记录冷却 if not success: self._failed_send_cooldown[msg_hash] = time.time() self._seen_messages.add(text.strip()) return True def _paste_and_send(self, platform: str, text: str) -> bool: """粘贴文本到输入框并发送(剪贴板 + SendInput)。""" hwnd = self._cached_hwnd.get(platform) if not hwnd: return False # 失败冷却检查:60 秒内同一消息不重试(防止卡死) msg_hash = hash(text) % 100000 last_fail = self._failed_send_cooldown.get(msg_hash, 0.0) if time.time() - last_fail < 60.0: logger.debug(f"[{platform}] 发送冷却中({int(60-(time.time()-last_fail))}s),跳过") return False import win32clipboard as wc # 保存剪贴板 old = None try: wc.OpenClipboard() if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT): old = wc.GetClipboardData(wc.CF_UNICODETEXT) wc.CloseClipboard() except Exception: pass try: # 设置答案到剪贴板 wc.OpenClipboard() wc.EmptyClipboard() wc.SetClipboardText(text, wc.CF_UNICODETEXT) wc.CloseClipboard() # ⛔ 剪贴板回读校验:设完立即读回,确认内容一致才继续 # 防止 monitor 线程的 _read_via_clipboard_once 在设与用之间 Ctrl+C 覆盖 try: wc.OpenClipboard() if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT): verify = wc.GetClipboardData(wc.CF_UNICODETEXT) else: verify = None wc.CloseClipboard() if verify != text: logger.error(f"[{platform}] 剪贴板校验失败: 设了{len(text)}c但读回{len(verify or '')}c,可能被其他线程覆盖") raise RuntimeError("clipboard verification failed") except RuntimeError: raise except Exception: logger.error(f"[{platform}] 剪贴板校验异常,放弃发送") raise # ── Electron 安全发送路径:TIM/微信都无 AttachThreadInput、无 SendMessage ── if (platform == "qq" and self._app_variant.get("qq") == "tim") or platform == "wechat": return self._paste_and_send_safe(text, hwnd, msg_hash, platform) # ── 更激进的窗口唤醒 ── # 1) 允许任意进程 SetForegroundWindow(移除系统限制) try: ctypes.windll.user32.AllowSetForegroundWindow(-1) # ASFW_ANY except Exception: pass # 2) 恢复/显示窗口 root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT) if win32gui.IsIconic(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) time.sleep(0.3) if not win32gui.IsWindowVisible(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) time.sleep(0.3) # 3) AttachThreadInput + SetForegroundWindow(不发送全局 Alt 键干扰 QQ 焦点) try: self._attach_set_foreground(root_hwnd) except Exception as e: logger.debug(f"[{platform}] _attach_set_foreground 异常: {e}") time.sleep(0.2) # 4) 再 SetForegroundWindow + SwitchToThisWindow try: win32gui.SetForegroundWindow(root_hwnd) ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) except Exception as e: logger.debug(f"[{platform}] SetForegroundWindow/SwitchToThisWindow 异常: {e}") time.sleep(0.3) # 5) 验证 foreground — 不成功则用 SendMessage 点击 + keybd_event VK_A = 0x41 VK_V = 0x56 VK_BACK = 0x08 VK_RETURN = 0x0D fg_now = win32gui.GetForegroundWindow() fg_ok = (fg_now == root_hwnd) or (fg_now and win32gui.IsChild(root_hwnd, fg_now)) if not fg_ok: logger.warning(f"[{platform}] 前台唤醒失败(fg={fg_now}, target={root_hwnd}),SendMessage 点击+keybd_event 兜底") # SendMessage 同步点击设置焦点(Electron 接受 SendMessage 点击) rect = win32gui.GetWindowRect(hwnd) if rect: left, top, right, bottom = rect w = right - left h = bottom - top click_x = int(w * 0.72) click_y = int(h * 0.92) lparam = win32api.MAKELONG(click_x, click_y) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) time.sleep(0.3) # 再试一次 SetForegroundWindow(SendMessage 点击后可能已生效) self._attach_set_foreground(root_hwnd) try: win32gui.SetForegroundWindow(root_hwnd) except Exception: pass time.sleep(0.2) # keybd_event 发送(不依赖 PostMessage——Electron 不吃 PostMessage 键盘) self._send_ctrl_combo(VK_A) time.sleep(0.05) self._key_down(VK_BACK) self._key_up(VK_BACK) time.sleep(0.05) self._send_ctrl_combo(VK_V) time.sleep(0.3) self._key_down(VK_RETURN) self._key_up(VK_RETURN) time.sleep(0.5) logger.info(f"[{platform}] 已回复(SendMessage+keybd): {text[:60]}") else: # ── 前台成功:SendMessage 点击输入框(同步,确保焦点) + keybd_event ── rect = win32gui.GetWindowRect(hwnd) if rect: left, top, right, bottom = rect w = right - left h = bottom - top click_x = int(w * 0.72) click_y = int(h * 0.92) lparam = win32api.MAKELONG(click_x, click_y) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) time.sleep(0.2) # keybd_event 发送(窗口已在前台) self._send_ctrl_combo(VK_A) time.sleep(0.05) self._key_down(VK_BACK) self._key_up(VK_BACK) time.sleep(0.05) self._send_ctrl_combo(VK_V) time.sleep(0.3) self._key_down(VK_RETURN) self._key_up(VK_RETURN) time.sleep(0.5) logger.info(f"[{platform}] 已回复: {text[:60]}") # 记录回复文本到去重集 # 将回复文本加入 _replied_texts(永久集,永不裁切,必拦)和 seen reply_text = text.strip() self._replied_texts.add(reply_text) self._seen_messages.add(reply_text) # 添加换行符归一化变体(跨频道转发时会替换 \n → 空格) if '\n' in reply_text: self._replied_texts.add(reply_text.replace('\n', ' ')) self._replied_texts.add(reply_text.replace('\n', ' ').rstrip('.')) self._seen_messages.add(reply_text.replace('\n', ' ')) self._seen_messages.add(reply_text.replace('\n', ' ').rstrip('.')) for part in reply_text.split('\n'): part = part.strip() if part and len(part) > 3: self._replied_texts.add(part) self._seen_messages.add(part) # 同时追踪去掉 ## 前缀的标题文本(OCR 常丢失 ## 后的空格) if part.startswith('#'): no_hash = re.sub(r'^#+\s*', '', part).strip() if no_hash and len(no_hash) > 2: self._replied_texts.add(no_hash) self._seen_messages.add(no_hash) self._last_reply_time[platform] = time.time() # 同平台 15s 冷却:回复回声截到也不触发 self._self_reply_cooldown_until[platform] = time.time() + 15.0 # 追加前30字符/后30字符变体(OCR 碎片常截取回复的开头或结尾) flat = reply_text.replace('\n', ' ') if len(flat) > 15: self._replied_texts.add(flat[:30]) if len(flat) > 40: self._replied_texts.add(flat[-30:].strip()) # 回复涉及前景切换(QQ Alt+Tab / 微信 SendMessage 点击) # → 对端平台冷却 30s,避免另一平台截到相同回复内容又触发自动回复 other = "qq" if platform == "wechat" else "wechat" self._foreground_cooldown_until[other] = time.time() + 30.0 return True except Exception as e: logger.error(f"[{platform}] 回复失败: {e}\n{traceback.format_exc()}") # 进入冷却期,60 秒内不重试同一条消息 self._failed_send_cooldown[msg_hash] = time.time() # 加入 seen_messages 防止下一轮 OCR 读到相同文本又触发 self._seen_messages.add(text.strip()) return False finally: # 恢复原始剪贴板 if old is not None: try: wc.OpenClipboard() wc.EmptyClipboard() wc.SetClipboardText(old, wc.CF_UNICODETEXT) wc.CloseClipboard() except Exception: pass # ── 核心:读消息 ────────────────────────────────────────── def _read_messages(self, window, platform: str = "") -> list: """每轮重新定位 ListControl。""" if platform == "qq": # QQ NT: OCR 读屏优先(零依赖,不切焦点,不碰剪贴板) texts = self._ocr_read_qq() if texts: logger.debug(f"[qq] 消息来源: OCR → {len(texts)} 条") return texts # 截图未变→跳过所有 fallback(clipboard/UIA 只会读旧数据) if self._ocr_unchanged.get("qq"): return [] # TIM (Electron) 安全:OC R 空时跳过所有 fallback。 # 剪贴板路径调 _ensure_foreground → AttachThreadInput → 崩 TIM; # UIA / Win32 读 Electron 窗口也无意义(结构不稳定)。 if self._app_variant.get("qq") == "tim": return [] # OCR 空 → 剪贴板兜底(窗口置前→滚底→全选→复制,内部自带重试) texts = self._read_via_clipboard_once(platform) if texts and len(texts) > 5: logger.debug(f"[qq] 消息来源: 剪贴板 → {len(texts)} 条") return texts # 剪贴板也空 → UIA 最后兜底(可能返回陈旧数据) fresh_win = self._find_qq_window_fresh() if fresh_win: texts = self._read_message_list(fresh_win) if texts: logger.debug(f"[qq] 消息来源: UIA消息列表 → {len(texts)} 条") return texts texts = self._read_conversation_list(fresh_win) if texts: logger.debug(f"[qq] 消息来源: UIA会话列表 → {len(texts)} 条") return texts try: rect = fresh_win.BoundingRectangle boundary = rect.left + rect.width() * 0.40 for lst in self._list_controls(fresh_win): try: cr = lst.BoundingRectangle if cr.left >= boundary: texts = self._extract_last_line(lst) if texts: logger.debug(f"[S-C] ListControl(x={cr.left}) → {len(texts)} items") return texts except Exception: pass except Exception: pass try: texts = self._collect_filtered_texts(fresh_win, "qq", max_depth=5) if texts: _before = len(texts) texts = [t for t in texts if t not in _UI_EXACT_BLOCKLIST] if texts: logger.debug(f"[S-D] 全窗文本 → {_before} items ({_before - len(texts)} UI过滤)") return texts return [] except Exception: pass # Win32 兜底 texts = self._read_win32_texts(platform) if texts: logger.debug(f"[qq] 消息来源: Win32 → {len(texts)} 条") return texts return [] # 微信: OCR 读屏优先(同 QQ 策略,UIA 树不暴露消息内容) texts = self._ocr_read_wechat() if texts: return texts # 截图未变→跳过 fallback if self._ocr_unchanged.get("wechat"): return [] # 未提供 UIA 控件(_poll 跳过了 _fresh_window 避免 Electron 冻结) # 此时惰性创建,仅在 OCR 失败后 fallback 时才需要 if window is None: window = self._fresh_window(platform) if window is None: return [] # 微信: UIA 兜底(可能读到陈旧数据或 UI 文字) texts = self._read_message_list(window) if texts: return texts # 兜底:会话列表 texts = self._read_conversation_list(window) if texts: return texts try: rect = window.BoundingRectangle boundary = rect.left + rect.width() * 0.40 for lst in self._list_controls(window): try: cr = lst.BoundingRectangle if cr.left >= boundary: texts = self._extract_last_line(lst) if texts: logger.debug(f"[S-C] ListControl(x={cr.left}) → {len(texts)} items") return texts except Exception: pass except Exception: pass try: texts = self._collect_filtered_texts(window, "wechat", max_depth=5) if texts: # Smart-Dump 产出 UI 文本不经过 _process_ocr_output 过滤链 # → 手动过 UI 块列表 _before = len(texts) texts = [t for t in texts if t not in _UI_EXACT_BLOCKLIST] if texts: logger.debug(f"[S-D] 全窗文本 → {_before} items ({_before - len(texts)} UI过滤)") return texts return [] except Exception: pass return [] def _read_message_list(self, window) -> list: # 只接受窗口右半侧(>45%)的 ListControl,避开侧栏/文件列表用同名控件 try: wr = window.BoundingRectangle boundary = wr.left + wr.width() * 0.45 except Exception: boundary = 0 for name in ["消息", "Message"]: try: lst = window.ListControl(Name=name) if lst.Exists(maxSearchSeconds=0.2): try: cr = lst.BoundingRectangle if cr.left < boundary: continue # 左侧的"消息"列表(文件/笔记预览)跳过 except Exception: pass self._scroll_to_bottom(lst) texts = self._extract_last_line(lst) if texts: logger.debug(f"[S-A] ListControl({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"") return texts except Exception: pass return [] def _read_conversation_list(self, window) -> list: try: wr = window.BoundingRectangle boundary = wr.left + wr.width() * 0.45 except Exception: boundary = 0 for name in ["会话", "聊天", "Chat"]: try: lst = window.ListControl(Name=name) if lst.Exists(maxSearchSeconds=0.2): try: cr = lst.BoundingRectangle if cr.left < boundary: continue except Exception: pass self._scroll_to_bottom(lst) texts = self._extract_second_line(lst) if texts: logger.debug(f"[S-B] 会话({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"") return texts except Exception: pass return [] def _scroll_to_bottom(self, lst): """滚动 ListControl 到底部,触发应用加载新消息。""" try: scroll = lst.GetScrollPattern() if scroll: scroll.SetScrollPercent(100, 0) except Exception: pass @staticmethod def _extract_last_line(lst) -> list: texts = [] try: for item in lst.GetChildren(): try: name = item.Name if name and name.strip(): lines = [l.strip() for l in name.split("\n") if l.strip()] if not lines: continue # NFKC 标准化(去除零宽字符后比较) norm = unicodedata.normalize('NFKC', lines[-1]) # 跳过末尾按钮文字(微信消息项的"关闭"等)和时间戳 ui_known = {"关闭", "发送", "接收", "取消", "确定", "更多"} if len(lines) >= 2 and (norm in ui_known or re.match(r'^\d{1,2}:\d{2}', norm)): texts.append(lines[-2]) else: texts.append(lines[-1]) except Exception: pass except Exception: pass return texts @staticmethod def _extract_deep_text(lst) -> list: """深度递归提取 ListControl 内所有控件的 Name 文本(最多3层)。""" texts = [] def walk(ctrl, depth): if depth > 3: return try: name = ctrl.Name if name and name.strip(): texts.append(name.strip()) except Exception: pass try: for child in ctrl.GetChildren(): walk(child, depth + 1) except Exception: pass try: for item in lst.GetChildren(): walk(item, 0) except Exception: pass return texts @staticmethod def _extract_second_line(lst) -> list: texts = [] try: for item in lst.GetChildren(): try: name = item.Name if name and name.strip(): lines = [l.strip() for l in name.split("\n") if l.strip()] if len(lines) >= 2: texts.append(lines[1]) elif lines: texts.append(lines[-1]) except Exception: pass except Exception: pass return texts @staticmethod def _list_controls(window): results = [] try: for ctrl in window.GetChildren(): results.extend(_walk_list_controls(ctrl, 0)) except Exception: pass return results @staticmethod def _collect_filtered_texts(window, platform: str = "", max_depth=14) -> list: """深度遍历 + 位置过滤(排除搜索框/标题栏区域)。""" items = [] # [(text, rect_top, rect_left, depth)] def walk(ctrl, depth): if depth > max_depth: return try: cr = ctrl.BoundingRectangle if cr.width() < 20 or cr.height() < 20: return except Exception: return try: name = ctrl.Name if name and name.strip(): t = name.strip() if len(t) >= 2 and not t.isdigit(): items.append((t, cr.top, cr.left, depth)) except Exception: pass try: for child in ctrl.GetChildren(): walk(child, depth + 1) except Exception: pass try: for child in window.GetChildren(): walk(child, 1) except Exception: pass if not items: return [] try: w_rect = window.BoundingRectangle w_width = w_rect.width() w_height = w_rect.height() except Exception: w_width = 1390 w_height = 953 if platform == "qq": filtered = [ t for t, top, left, d in items if top > w_height * 0.08 ] seen = set() deduped = [] for t in filtered: if t not in seen: seen.add(t) deduped.append(t) return deduped return [t for t, _, _, _ in items] # ── 单次轮询 ───────────────────────────────────────────── def _poll(self, platform: str): # 自动回复发送中跳过轮询(防止窗口焦点被抢) if self._sending_event.is_set(): return # 对端平台冷却:刚切过前台(回复发送),给调试覆盖层消失时间 cooldown = self._foreground_cooldown_until.get(platform, 0.0) if time.time() < cooldown: return # 冷却期:回复后 5 秒内跳过 poll,防止反馈回环 last_reply = self._last_reply_time.get(platform, 0.0) if time.time() - last_reply < 5.0: return counter = self._poll_count.get(platform, 0) + 1 self._poll_count[platform] = counter # 1. 确保窗口句柄 # qq 路径纯 Win32+psutil,直接调用不超时;wechat 用 UIA(可能挂死)才套子线程 if platform == "qq": hwnd = self._ensure_hwnd(platform) else: hwnd = self._ensure_hwnd_with_timeout(platform, timeout=12.0) if not hwnd: if counter % 10 == 0 or (counter == 1 and platform == "qq"): logger.debug(f"[{platform}] 窗口未找到") return # QQ 在托盘隐藏(IsWindowVisible=False)时跳过本轮。 # 任何尝试恢复隐藏窗口的操作(SW_SHOWNORMAL 等)在 QQ NT 上都会触发托盘预览弹窗, # 不会恢复主聊天窗口。用户保持 QQ 窗口最小化到任务栏即可。 if platform == "qq" and hwnd and win32gui.IsWindow(hwnd) and not win32gui.IsWindowVisible(hwnd): return # 2. 从 HWND 创建全新 Control(绕过缓存) # QQ: 需要 UIA 控件做多种 fallback 读取(ListControl、全窗爬文本等) # 微信( Electron ): ControlFromHandle 构建 UIA 控件树冻结 3-5s, # 而微信 OCR 读到消息就直接返回,不需要 UIA。只在 OCR 空/fallback 时创建。 if platform == "qq": win = self._fresh_window(platform) if not win: logger.debug(f"[{platform}] 创建窗口控件失败") return else: win = None # 惰性创建:OCR 失败后需要 fallback 时再建 # 3. 读消息(Windows OCR 同步阻塞,可能耗时 2-5s) texts = self._read_messages(win, platform) # ⚠️ OCR 返回后立即检查急停标志:急停可能在 OCR 阻塞期间触发, # Windows OCR 无法被中断,但后续文本处理可以立即跳过。 if not self._running: return if not texts: # 截图未变→不计入空读,不触发清缓存 if self._ocr_unchanged.get(platform): return empty = self._empty_reads.get(platform, 0) + 1 self._empty_reads[platform] = empty if counter % 5 == 0: logger.debug(f"[{platform}] 空读 #{empty}") if empty >= 5: self._cached_hwnd.pop(platform, None) self._cached_pid.pop(platform, None) self._empty_reads[platform] = 0 logger.info(f"[{platform}] 连续空读,清窗口缓存重试") return self._empty_reads[platform] = 0 # 冻结计数器(不在 early-return 里定义,保证所有路径都能访问) stale = 0 # 4a. 全文本签名快速跳过 — OCR 输出与上轮完全相同 → 无变化,跳过 sig = "||".join([t.strip() for t in texts if t.strip()]) if sig == self._last_sig.get(platform, ""): stale = self._stale_count.get(platform, 0) + 1 self._stale_count[platform] = stale return # 4. 用全局 _seen_messages 去重(跨平台、跨轮次防回环) # 精确匹配 + 模糊匹配(OCR 噪声导致精确匹配失效) seen = self._seen_messages replied = self._replied_texts new_items = [] for t in texts: ts = t.strip() if not ts: continue # === URL-only 兜底:无中文且含 URL → 碎片 === if not re.search(r'[\u4e00-\u9fff]', ts) and re.search(r'https?\S', ts, re.IGNORECASE): logger.debug(f"[{platform}] Poll过滤: URL-only碎片 ts={ts!r}") # === Bot自身回复过滤:pipe 表格格式(|参数1|参数2|参数3|结果|) === pipe_count = ts.count('|') if pipe_count >= 3 and len(ts) > 30: logger.debug(f"[{platform}] Poll过滤: bot表格(pipe={pipe_count}) ts={ts!r}") continue # === 纯非中文乱码:OCR 阅读到终端 log 或 URL 啃碎,中文群必有中文 === if not re.search(r'[\u4e00-\u9fff]', ts) and len(ts) > 15 and not ts.startswith('/'): logger.debug(f"[{platform}] Poll过滤: 无中文乱码 ts={ts!r}") continue # === CJK 比例过低乱码:EasyOCR 把 URL/代码认成罕见汉字混一堆字母 === if len(ts) > 15 and re.search(r'[A-Za-z]', ts): cjk = sum(1 for c in ts if '\u4e00' <= c <= '\u9fff') if cjk > 0 and cjk / max(len(ts), 1) < 0.15: logger.debug(f"[{platform}] Poll过滤: CJK比例过低乱码 ts={ts!r}") continue # === 斜线英文命令碎片:"/sdfl undo撤销导入" 命令帮助碎片 === if re.match(r'^/[A-Za-z]', ts): logger.debug(f"[{platform}] Poll过滤: 斜线英文命令 ts={ts!r}") continue # === 消息中段含 /英文命令(如"怎么办?/printer玩家名")→ 指令碎片拼接 === if re.search(r'(?<=[\s??。!,,])/[A-Za-z]{2,}', ts): logger.debug(f"[{platform}] Poll过滤: 斜线命令碎片(中段) ts={ts!r}") continue # === 下划线+斜线:OCR 左对齐气泡残影 "_ /sdfl undo" === if re.match(r'^_[ /]', ts): logger.debug(f"[{platform}] Poll过滤: 下划线斜线残影 ts={ts!r}") continue # === OCR 误读斜线为 I/J:"/sdfl"→"Isdfl", "_ /sdfl"→下划线前缀 === # 常见变体:/sdfl, _ /sdfl, Isdfl, sdfl undo... if 'sdfl' in ts.lower() or 'sdf1' in ts.lower(): logger.debug(f"[{platform}] Poll过滤: 变形命令(sdfl) ts={ts!r}") continue # === OCR 斜线→I 变形:/skin→Iskin, /reg→Ireg 等 === # 特征:CJK 上下文中出现 I+小写字母(正常中文聊天不会这样写) # 用户不会发"使用指令Iskin"这种文本,安全砍掉不进知识库 if re.search(r'[\u4e00-\u9fff]I[a-z]{2,}', ts): logger.debug(f"[{platform}] Poll过滤: 斜线I变形 ts={ts!r}") continue if re.match(r'^[A-Z][a-z]{4,}', ts) and re.search(r'[\u4e00-\u9fff]', ts) and len(ts) > 10: logger.debug(f"[{platform}] Poll过滤: 变形命令(大写+字母) ts={ts!r}") continue # === 结构化数据面板:QQ/TIM 指令面板(指令:/rtp 权限类型:玩家 图标:标题:)=== colon_kv = re.findall(r'[\u4e00-\u9fff]{1,6}[::](?:[A-Za-z0-9/]|[\u4e00-\u9fff])', ts) if len(colon_kv) >= 3: logger.debug(f"[{platform}] Poll过滤: 结构化面板 ts={ts!r}") continue # === 偏旁碎片过滤:氵、亻、讠、扌等 CJK 偏旁不是完整汉字 === # 从 unicode_radicals.md 加载偏旁字符集,统计出现次数 # ≥1 个偏旁字符 → 判定为 OCR 把汉字拆碎了,整段丢弃 radical_cnt = sum(1 for c in ts if c in _RADICAL_CHARS) if radical_cnt >= 1: logger.debug(f"[{platform}] Poll过滤: 偏旁碎片(radical={radical_cnt}) ts={ts!r}") continue # === 系统配置内容过滤:SMTP/邮件地址/代码片段不是用户消息 === if re.search(r'@example|smtp|\.py\b|\.bat\b|\.sh\b|//.*\.\w{2,4}\b', ts, re.IGNORECASE): logger.debug(f"[{platform}] Poll过滤: 系统配置内容 ts={ts!r}") continue if re.search(r'@\w+\.\w{2,4}[,.\s]', ts) and re.search(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', ts): logger.debug(f"[{platform}] Poll过滤: 邮件地址格式 ts={ts!r}") continue # === 游戏UI乱码:CJK+下划线英文、CJK+大写英文组合 → 非聊天文本 === if re.search(r'[\u4e00-\u9fff]', ts) and re.search(r'[A-Za-z]', ts) and len(ts) > 10: if re.search(r'[a-zA-Z]+_[a-zA-Z]+', ts): logger.debug(f"[{platform}] Poll过滤: 游戏UI乱码(下划线英文) ts={ts!r}") continue if re.search(r'[\u4e00-\u9fff][A-Z][a-z]{1,}', ts): logger.debug(f"[{platform}] Poll过滤: 游戏UI乱码(CJK+大写英文) ts={ts!r}") continue # === 绿色气泡 OCR 乱码:| 管道符混中文(正常聊天从不混用)=== if '|' in ts and re.search(r'[\u4e00-\u9fff]', ts) and len(ts) > 5: logger.debug(f"[{platform}] Poll过滤: 管道符碎片(绿色气泡) ts={ts!r}") continue # === JSON/YAML 碎片兜底:OCR 截屏混入配置面板 === if re.match(r'^["\']?[a-zA-Z_][\w]*["\']?\s*:\s*["\d\[\{tfn]', ts): logger.debug(f"[{platform}] Poll过滤: JSON行 ts={ts!r}") continue if re.match(r'^[a-zA-Z_][\w]*\s*:\s*["\w\d]', ts) and \ not re.match(r'^(https?|ftp)://', ts) and \ not re.match(r'^[a-zA-Z_][\w]*\s*:\s*/', ts): logger.debug(f"[{platform}] Poll过滤: YAML行 ts={ts!r}") continue # === 纯问句碎片(如"怎么办?" "好不好?")无实质内容 === _pure_q = re.match(r'^(怎么办|好不好|行不行|能不能|可以吗|是不是|有没有|是什么|怎么样|在哪里)\s*[??。!]*\s*$', ts) if _pure_q: logger.debug(f"[{platform}] Poll过滤: 纯问句碎片 ts={ts!r}") continue # === 问句+乱码碎片拼接(如"怎么办?/printer玩家名")=== if re.match(r'^(怎么办|好不好|行不行|能不能|可以吗|是什么|怎么样)', ts): rest = re.sub(r'^(怎么办|好不好|行不行|能不能|可以吗|是什么|怎么样)\s*[??。!]*\s*', '', ts) if rest and (re.search(r'/[A-Za-z]{2,}', rest) or re.search(r'[a-zA-Z]{3,}', rest) or len(rest) < 3): logger.debug(f"[{platform}] Poll过滤: 问句+乱码拼接 ts={ts!r}") continue # === 安全阀:UI 子串过滤(兜底,防任何路径漏过)=== if any(sub in ts for sub in _UI_SUBSTR_BLOCKLIST): logger.debug(f"[{platform}] Poll过滤: UI子串 ts={ts!r}") continue # === 安全阀:UI 块列表过滤(兜底,防任何路径漏过)=== if ts in _UI_EXACT_BLOCKLIST: logger.debug(f"[{platform}] Poll过滤: UI黑名单 line={ts!r}") seen.add(ts) continue # _replied_texts 优先检查(永不裁切,100% 拦住自己的回复) if ts in replied: continue # _replied_texts 模糊匹配:OCR 噪声导致精确匹配失效(长文本尤甚) if self._fuzzy_duplicate(ts, replied, threshold=0.75): logger.debug(f"[{platform}] 回复模糊跳过: {ts[:50]}") continue # _replied_texts 子串包含:OCR 提取的 md 表格细胞值(如"依赖插件可选/必选") # 是 bot 回复表的一部分,不是用户提问。长度>5才检查避免误杀短消息。 # OCR 常把 ,转 , 等标点差异,做双向子串检查(原始+归一化标点) if len(ts) > 5: if any(ts in r for r in replied): logger.debug(f"[{platform}] 回复子串包含跳过({len(ts)}c): {ts[:50]}") continue # 归一化标点后重试(OCR 噪声:,→, 。→. !→! ?→?) norm_ts = ts.replace(',', ',').replace('.', '。').replace('!', '!').replace('?', '?') if norm_ts != ts and any(norm_ts in r for r in replied): logger.debug(f"[{platform}] 回复子串包含跳过(归一化,{len(ts)}c): {ts[:50]}") continue # _replied_texts 长公共子串检测:OCR 截取的碎片开头/结尾有噪声,精确/子串匹配失效 # 先试原始文本(含字母数字),再试 CJK-only(进一步降阈值) if len(ts) > 8: import difflib matched_r = False # 第一轮:原始文本匹配(阈值 6,OCR 噪声导致连续匹配可能中断) for r in replied: if len(r) < 5: continue sm = difflib.SequenceMatcher(None, ts, r) if sm.find_longest_match(0, len(ts), 0, len(r)).size >= 6: matched_r = True break # 第二轮:CJK-only 降阈值到 5(去除标点空格干扰后更稳定) if not matched_r: cjk_ts = ''.join(c for c in ts if '\u4e00' <= c <= '\u9fff') if len(cjk_ts) >= 5: for r in replied: r_cjk = ''.join(c for c in r if '\u4e00' <= c <= '\u9fff') if len(r_cjk) < 5: continue sm = difflib.SequenceMatcher(None, cjk_ts, r_cjk) if sm.find_longest_match(0, len(cjk_ts), 0, len(r_cjk)).size >= 5: matched_r = True break if matched_r: logger.debug(f"[{platform}] 回复公共子串跳过: {ts[:50]}") continue if ts in seen: continue # 模糊匹配:OCR 输出每次微小差异(勖彐碎片、行首数字、尾部截断) # 精确匹配命中不了 → 用 difflib 相似度去重 if self._fuzzy_duplicate(ts, seen, threshold=0.80): logger.debug(f"[{platform}] 模糊重复跳过: {ts[:50]}") continue # 超长文本(>200字符)跳过——大概率是堆积历史或 bot 自己的回复,不是单条新消息 if len(ts) > 200: logger.debug(f"[{platform}] 超长文本跳过({len(ts)}c): {ts[:50]}...") continue if self._is_system_message(ts): continue # Markdown 文档过滤:bot 回复以 md 格式为主(#标题、[链接](url)、-列表项), # 用户日常对话不用 md 语法。拦截 md 格式文本避免 bot 旧回复被重新处理。 if self._is_markdown_document(ts): logger.debug(f"[{platform}] md文档跳过: {ts[:50]}") continue # 纯装饰符号(单字符非汉字非字母数字)跳过 if len(ts) == 1 and not re.match(r'[\u4e00-\u9fff\w]', ts): continue # 只有符号/空格/emoji 的非消息文本(<3 字符且长度等于标点去掉后长度) clean = re.sub(r'[\s\u2000-\u206f\u2190-\u2bff\U0001F000-\U0001FFFF✿♡♥❤★☆]', '', ts) if len(ts) <= 3 and len(clean) == 0: continue seen.add(ts) new_items.append(ts) # _seen_messages 上限 500 条,超了裁到 300 # 防止集合无限膨胀 → 新消息模糊匹配时被历史噪声误杀 if len(seen) > 500: # 保留后 300 条(最新) self._seen_messages = set(list(seen)[-300:]) # 单次 poll 最多处理 3 条新消息,防止系统消息海量涌入触发回环 MAX_NEW_ITEMS = 3 new_items = new_items[:MAX_NEW_ITEMS] # 过滤纯时间/日期文本 → 不应进入 KB 匹配(OCR 时间戳分割遗留的噪音片段) # 这些文本在 KB 子串匹配阶段会命中文件内任何含相同数字的内容 new_items = [ it for it in new_items if not re.match(r'^\d{1,2}[::]\d{2}(?:[::]\d{2})?$', it.strip()) and not re.match(r'^\d{4}[/-]\d{1,2}', it.strip()) ] # ⚠️ 文本处理后再次检查急停:防止急停在过滤阶段触发后仍触发回复 if not self._running: return # 更新 last_texts 与签名 self._last_texts[platform] = list(texts) self._last_sig[platform] = sig self._stale_count[platform] = 0 if new_items: # 清理过期冷却条目(TTL 60 秒) now = time.time() self._failed_send_cooldown = { k: v for k, v in self._failed_send_cooldown.items() if now - v < 60.0 } # ⭐ 取最后一条(屏幕最下方 = 最新消息) # OCR 从上到下读屏,旧消息排前面,新消息排后面。 # 原"只处理第一条"逻辑把旧历史消息当新消息处理, # 真正的用户新消息被"同poll第条跳过"挡掉。 item = new_items[-1] if len(new_items) > 1: logger.debug(f"[{platform}] 同poll多条({len(new_items)}条),取最后(最新): {item[:40]}") # 其余加入 seen 防止下次重复 for skipped in new_items[:-1]: seen.add(skipped) # ⭐ 首次成功轮询(上次无消息→本次有消息):拦截历史基线 is_baseline = self._last_new_msg_time.get(platform, 0) == 0 if is_baseline: # 基线轮:日志标记但不触发回复,添加到 seen 防止下次再来 logger.info(f"[{platform}] 基线↺(不回复): {item[:60]}") seen.add(item) self._last_new_msg_time[platform] = time.time() else: self._last_new_msg_time[platform] = time.time() self._last_msg[platform] = item engine_tag = self._last_chosen_engine.get(platform, "") logger.info(f"[{platform}] 新消息[{engine_tag}]: {item[:60]}") if self._on_message: self._on_message(platform, item) if platform == "qq": if stale == 3: logger.debug(f"[QQ] 冻结 #{stale},强制刷新") self._force_uia_refresh(platform) elif stale == 6: logger.debug(f"[QQ] 冻结 #{stale},重建窗口引用") self._cached_hwnd.pop(platform, None) self._cached_pid.pop(platform, None) else: if stale == 5: logger.debug(f"[{platform}] 冻结 #{stale},尝试强制刷新") self._force_uia_refresh(platform) elif stale == 10: logger.debug(f"[{platform}] 冻结 #{stale},重建窗口引用") self._cached_hwnd.pop(platform, None) self._cached_pid.pop(platform, None) def _clean_old_ocr_debug(dir_path: str, keep: int = 20, prefix: str = "ocr_debug_"): """清理 ocr_debug_*.png 调试截图,按 prefix 分别保留最近 keep 张。 分前缀清理:微信轮询频率远高于 QQ,若共享一个 6 张上限池,微信的密集保存 会把 QQ 的调试截图顶掉,导致"QQ 截图没保存"。各前缀独立保留 keep 张, 既控制总量又保证两种截图都能留存可查。 """ try: files = [f for f in os.listdir(dir_path) if f.startswith(prefix) and f.endswith(".png")] if len(files) <= keep: return files.sort() # 按字母序 = 时间序 for f in files[:-keep]: try: os.remove(os.path.join(dir_path, f)) except OSError: pass except Exception: pass def _walk_list_controls(ctrl, depth): if depth > 6: return [] results = [] try: cname = ctrl.ControlTypeName except Exception: return results if cname == "ListControl": results.append(ctrl) return results try: for child in ctrl.GetChildren(): results.extend(_walk_list_controls(child, depth + 1)) except Exception: pass return results