Files
msgbot/monitor.py
T
youpaishidifu af3b730106 修复过滤过宽+裁剪范围
1. _is_system_message 中系统模式匹配从 in 改为 startswith
   否则 OCR 捕获的多行合一文本只要包含答:就被整行丢弃
   用户消息你叫啥名字啊?混在文本里一起被杀
2. cap_left 从 25% → 35%
   25% 覆盖到联系人列表,OCR 读出多面板混合文本
   35% 从会话列表偏右到消息面板,减少噪声
3. 保留上轮加的前台切换和 debug 日志
2026-07-26 13:25:21 +08:00

1530 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""消息监控 — 全列表指纹对比 + 窗口冻结检测 + ControlFromHandle 强制刷新"""
import time
import logging
import threading
import traceback
from typing import Optional, Callable, List
import uiautomation as auto
import win32gui
import win32con
import win32api
import win32process
import re
import ctypes
import difflib
import os
from datetime import datetime
from PIL import ImageGrab
from winrt.windows.media.ocr import OcrEngine
from winrt.windows.globalization import Language
from winrt.windows.graphics.imaging import SoftwareBitmap, BitmapPixelFormat
logging.getLogger("comtypes").setLevel(logging.WARNING)
logger = logging.getLogger("msgbot.monitor")
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 = {}
# 连续相同签名计数(用于冻结检测)
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 = {} # platform → timestamp
# OCR 引擎(Windows 原生,lazy init)
self._ocr_engine = None
# 上次 OCR 原始文本(用于增量检测)
self._ocr_last_text: dict = {}
# OCR 垃圾连续计数(连续垃圾超过阈值清缓存重找)
self._ocr_garbage_count: int = 0
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 start(self):
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="monitor")
self._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
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
while self._running:
try:
if self._wechat_enabled and not self._sending_event.is_set():
self._poll("wechat")
if self._qq_enabled:
# QQ 降频:每 4 周期 poll 一次(~8s,OCR 计算略重)
self._qq_skip_counter += 1
if self._qq_skip_counter >= 4 and not self._sending_event.is_set():
self._qq_skip_counter = 0
self._poll("qq")
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 _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)
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)
self._cached_hwnd[platform] = top
self._cached_pid[platform] = win32process.GetWindowThreadProcessId(top)[1]
logger.info(f"[{platform}] 窗口已定位: {name} handle={top}")
return top
except Exception:
pass
elif platform == "qq":
for name in ["QQ", "TIM"]:
try:
w = auto.WindowControl(Name=name)
if w.Exists(maxSearchSeconds=0.3):
rect = w.BoundingRectangle
if rect.width() > 200 and rect.height() > 100:
h = w.NativeWindowHandle
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
self._cached_hwnd[platform] = top
self._cached_pid[platform] = win32process.GetWindowThreadProcessId(top)[1]
self._app_variant[platform] = "qq_nt" if name == "QQ" else "tim"
logger.info(f"[{platform}] 窗口已定位(名称): {name} top={top}")
return top
except Exception:
pass
# QQ NT/TIM → TXGuiFoundation 类名
try:
root = auto.GetRootControl()
for child in root.GetChildren():
try:
if (child.ClassName or "") == "TXGuiFoundation":
rect = child.BoundingRectangle
if rect.width() > 200 and rect.height() > 100:
h = child.NativeWindowHandle
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
cname = str(child.Name or "")
self._app_variant[platform] = "tim" if "TIM" in cname.upper() else "qq_nt"
self._cached_hwnd[platform] = top
self._cached_pid[platform] = win32process.GetWindowThreadProcessId(top)[1]
logger.info(f"[{platform}] 窗口已定位(TXGuiFoundation): {child.Name} top={top}")
return top
except Exception:
pass
except Exception:
pass
return None
# ── 强制刷新 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_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.25)
if self._check_foreground(root_hwnd):
return True
# Method 2: SwitchToThisWindow(权限要求低)
try:
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
time.sleep(0.2)
if self._check_foreground(root_hwnd):
return True
except Exception:
pass
# Method 3: SetWindowPos 置顶再取消(最后手段)
if i >= 2:
try:
HWND_TOPMOST = -1
HWND_NOTOPMOST = -2
SWP_NOSIZE = 1
SWP_NOMOVE = 2
ctypes.windll.user32.SetWindowPos(
root_hwnd, HWND_TOPMOST, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE
)
time.sleep(0.1)
ctypes.windll.user32.SetWindowPos(
root_hwnd, HWND_NOTOPMOST, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE
)
time.sleep(0.15)
if self._check_foreground(root_hwnd):
return True
except Exception:
pass
time.sleep(0.15)
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 词内空格保留)
"""
# 所有紧邻 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
)
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')
if cjk_count == 0 and len(text.strip()) > 20:
return True
return False
def _validate_qq_window(self, hwnd: int) -> bool:
"""验证窗口是否确实是 QQ/TIM 进程。
检查项(全过才返回 True):
1. 窗口存在且可见
2. 窗口未最小化
3. 所属进程名称匹配 QQ.exe / TIM.exe
"""
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.72) -> 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
def _ocr_read_qq(self) -> list:
"""OCR 截屏 QQ 消息区域 -> Windows OCR 提取文本。
包含窗口验证 + 垃圾过滤 + 连续垃圾清缓存。
"""
hwnd = self._cached_hwnd.get("qq")
if not hwnd:
return []
if not self._validate_qq_window(hwnd):
self._ocr_garbage_count += 1
if self._ocr_garbage_count == 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 self._ocr_garbage_count >= 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 = 0
return []
engine = self._ocr_ensure_engine()
if not engine:
return []
try:
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% 消息面板
# 先切 QQ 到前台再截屏,避免 CMD 窗口遮挡→OCR 读到终端文字
self._ensure_foreground(hwnd)
time.sleep(0.15)
# 左边界 35%,从消息面板左侧开始(联系人/会话列表 / 消息面板分界约 55%)
# 但消息正文在面板左侧,因此稍左移 35% 覆盖消息文字左侧边缘
cap_left = int(left + win_w * 0.35)
cap_top = top + max(40, int(win_h * 0.04)) # 标题栏下方
cap_right = int(left + win_w * 0.95) # 跳过滚动条
cap_bottom = bottom - max(100, int(win_h * 0.10)) # 输入框上方
if cap_right <= cap_left or cap_bottom <= cap_top:
return []
im = ImageGrab.grab(bbox=(cap_left, cap_top, cap_right, cap_bottom))
if im.width < 50 or im.height < 50:
return []
# 保存截屏到程序根目录用于调试(保留最近 20 张)
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
path = os.path.join(script_dir, f"ocr_debug_{ts}.png")
im.save(path)
# 清理旧文件:只保留最近 20 张
_clean_old_ocr_debug(script_dir, keep=20)
except Exception as e:
logger.debug(f"[ocr] 保存截屏失败: {e}")
if im.mode != 'RGBA':
im = im.convert('RGBA')
sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height)
sb.copy_from_buffer(im.tobytes())
result = engine.recognize_async(sb).get()
raw_text = result.text.strip()
if not raw_text:
return []
if self._is_ocr_console_garbage(raw_text):
self._ocr_garbage_count += 1
if self._ocr_garbage_count >= 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 = 0
return []
self._ocr_garbage_count = 0
self._ocr_last_text["qq"] = raw_text
ocr_lines = [l.strip() for l in raw_text.split('\n') if l.strip()]
# ⭐ debug: 记录 OCR 原始行内容,定位过滤导致空白的原因
for i, rl in enumerate(ocr_lines):
logger.info(f"[ocr] raw[{i}] raw_line<{rl!r}>")
texts = []
for line in ocr_lines:
line = self._normalize_ocr_text(line)
if not line:
continue
# ⭐ QQ NT 界面固定噪声:整行匹配直接跳过
if line in ("查看更多", "收起", "选择会话开始聊天"):
continue
# 连续纯标点+符号(emoji、装饰字符等)
if re.match(r'^[✿♡♥❤★☆✦✧❀❁❂❃❄❅❆❇❈❉❊❋➠➡➢➣➤.·•●○◦◎◉◍◐◑◒◓◔◕◖◗◢◣◤◥☺☻☼♠♣♦♤♧♡♢♔♕♖♗♘♙🕐-🕧\s]{1,3}$', line):
continue
# 去噪:去掉行首未读红点数字(如 "0")和行尾删除按钮误识别(勖彐 等)
# 否则同一消息每次 OCR 输出微小差异 → 精确去重失效 → 重复触发
line = re.sub(r'^0+', '', line)
line = re.sub(r'[勖彐"\'\s\u2000-\u206f]+$', '', line)
if not line:
continue
# 半角全角数字混杂的界面碎片(如 "1.0s 0.35后监控应用设置")
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
# ⭐ 纯中文短文本不一定是消息正文——但 1-15 过滤太宽会杀真消息
# 改为只过滤单字符纯中文,多字符的 UI 噪声由 _is_system_message 处理
if re.match(r'^[一-鿿]{1}$', 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
texts.append(line)
logger.debug(f"[qq] OCR -> {len(ocr_lines)} raw, {len(texts)} filtered, "
f"last=\"{texts[-1][:50] if texts else ''}\"")
return texts
except Exception as e:
logger.debug(f"[qq] OCR失败: {e}")
return []
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 (全选) — 仅当目标窗口仍在焦点
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")
# ── 过滤:只返回"像用户消息"的行 ──
sender_pattern = re.compile(r'^[\u4e00-\u9fff·A-Za-z0-9_\-]{1,10}$')
for line in raw_lines:
# 跳过:系统消息、纯时间、纯数字、无意义短行
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
# 通过全部检查 → 保留
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+<key>: 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 _is_system_message(text: str) -> bool:
"""过滤机器人/系统消息,不作为用户消息处理。"""
if not text:
return True
# 系统前缀/标记
system_markers = [
"✅", "❌", "📝", "🔍", "📄", "➡", "♡", "✿",
"[WECHAT]", "[QQ]", "[wechat]", "[qq]",
]
for m in system_markers:
if text.startswith(m):
return True
# ⭐ QQ NT 界面噪声文字(按钮/提示/碎片,非用户消息)
qq_ui_noise = [
"查看更多", "可查看在其他设备产生的聊天记录",
"收藏", "转发", "多选", "引用", "回复",
"选择会话开始聊天",
]
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
def _paste_and_send(self, platform: str, text: str) -> bool:
"""粘贴文本到输入框并发送(剪贴板 + SendInput)。"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd:
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()
# ── 更激进的窗口唤醒 ──
# 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) 试图 SetForegroundWindow + Alt 键锁(让当前线程获得用户输入资格)
win32gui.SetForegroundWindow(root_hwnd)
VK_MENU = 0x12
KEYEVENTF_KEYUP = 0x0002
ctypes.windll.user32.keybd_event(VK_MENU, 0, 0, 0) # Alt down
ctypes.windll.user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) # Alt up
time.sleep(0.2)
# 4) 再 SetForegroundWindow + SwitchToThisWindow
win32gui.SetForegroundWindow(root_hwnd)
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
time.sleep(0.3)
# 5) 验证 foreground — 不成功则用 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}] 前台唤醒失败(fg={fg_now}, target={root_hwnd}),SendMessage 兜底")
# SendMessage 方式:直接发消息给窗口,不依赖前台
rect = win32gui.GetWindowRect(hwnd)
if rect:
left, top, right, bottom = rect
w = right - left
h = bottom - top
# QQ NT 输入框在右侧消息面板底部
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.1)
# Ctrl+A (全选)
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, 0x41, 0 | (1 << 29)) # Ctrl 修饰
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, 0x41, 0)
time.sleep(0.05)
# Ctrl+V (粘贴)
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, 0x56, 0 | (1 << 29))
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, 0x56, 0)
time.sleep(0.1)
# Enter (发送)
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, 0x0D, 0)
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, 0x0D, 0)
time.sleep(0.5)
logger.info(f"[{platform}] 已回复(SendMessage): {text[:60]}")
else:
return False
else:
# ── 前台成功:PostMessage 点击输入框 + 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.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
time.sleep(0.2)
# keybd_event 发送(窗口已在前台)
VK_A = 0x41
VK_V = 0x56
VK_BACK = 0x08
VK_RETURN = 0x0D
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)
self._last_reply_time[platform] = time.time()
return True
except Exception as e:
logger.error(f"[{platform}] 回复失败: {e}\n{traceback.format_exc()}")
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:
return texts
# OCR 空 → 剪贴板兜底(窗口置前→滚底→全选→复制,内部自带重试)
texts = self._read_via_clipboard_once(platform)
if texts and len(texts) > 5:
return texts
# 剪贴板也空 → UIA 最后兜底(可能返回陈旧数据)
fresh_win = self._find_qq_window_fresh()
if fresh_win:
texts = self._read_message_list(fresh_win)
if texts:
return texts
texts = self._read_conversation_list(fresh_win)
if 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:
logger.debug(f"[S-D] 全窗文本 → {len(texts)} items")
return texts
except Exception:
pass
# Win32 兜底
texts = self._read_win32_texts(platform)
if texts:
return texts
return []
# 微信: 优先读"消息"(ScrollPattern 滚底后实时)
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:
logger.debug(f"[S-D] 全窗文本 → {len(texts)} items, last=\"{texts[-1][:50]}\"")
return texts
except Exception:
pass
return []
def _read_message_list(self, window) -> list:
for name in ["消息", "Message"]:
try:
lst = window.ListControl(Name=name)
if lst.Exists(maxSearchSeconds=0.2):
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:
for name in ["会话", "聊天", "Chat"]:
try:
lst = window.ListControl(Name=name)
if lst.Exists(maxSearchSeconds=0.2):
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 lines:
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
# 冷却期:回复后 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. 确保窗口句柄
hwnd = self._ensure_hwnd(platform)
if not hwnd:
if counter % 10 == 0:
logger.debug(f"[{platform}] 窗口未找到")
return
# 2. 从 HWND 创建全新 Control(绕过缓存)
win = self._fresh_window(platform)
if not win:
logger.debug(f"[{platform}] 创建窗口控件失败")
return
# 3. 读消息
texts = self._read_messages(win, platform)
if not texts:
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
# 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
# _replied_texts 优先检查(永不裁切,100% 拦住自己的回复)
if ts in replied:
continue
if ts in seen:
continue
# 模糊匹配:OCR 输出每次微小差异(勖彐碎片、行首数字、尾部截断)
# 精确匹配命中不了 → 用 difflib 相似度去重
if self._fuzzy_duplicate(ts, seen, threshold=0.72):
logger.debug(f"[{platform}] 模糊重复跳过: {ts[:50]}")
continue
if self._is_system_message(ts):
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 — _replied_texts 已拦截回复回环,截断会误放历史消息
# 极端情况(>50万行)才手动清,几乎不会出现
# 单次 poll 最多处理 3 条新消息,防止系统消息海量涌入触发回环
MAX_NEW_ITEMS = 3
new_items = new_items[:MAX_NEW_ITEMS]
# 更新 last_texts 供其他逻辑参考(但去重不依赖它)
self._last_texts[platform] = list(texts)
if new_items:
# 有纯粹的新文本 → 逐一报告
sig = "||".join([t.strip() for t in texts if t.strip()])
self._last_sig[platform] = sig
self._stale_count[platform] = 0
for item in new_items:
self._last_msg[platform] = item
logger.info(f"[{platform}] 新消息: {item[:60]}")
if self._on_message:
self._on_message(platform, item)
else:
# 无新文本 → 用全签名判断是否内容变化(emoji 刷新等)
sig = "||".join([t.strip() for t in texts if t.strip()])
old_sig = self._last_sig.get(platform, "")
if sig != old_sig:
self._last_sig[platform] = sig
self._stale_count[platform] = 0
else:
# 相同签名 → 冻结计数
stale = self._stale_count.get(platform, 0) + 1
self._stale_count[platform] = stale
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):
"""清理 ocr_debug_*.png 文件,只保留最近 keep 张。"""
try:
files = [f for f in os.listdir(dir_path) if f.startswith("ocr_debug_") and f.endswith(".png")]
if len(files) <= keep:
return
files.sort() # 按字母序 = 时间序
for f in files[:-keep]:
os.remove(os.path.join(dir_path, f))
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