OCR 终极防御:进程验证+最小化检测+垃圾内容过滤
QQ 被杀/最小化后 OCR 截到终端窗口导致死循环回复。 三重防御: 1. _validate_qq_window() — IsWindowVisible+IsIconic+进程名(QT/TIM.exe)验证 2. _is_ocr_console_garbage() — 内容级检测:Python模块名/脚本路径/日志关键词/无中文长文本 3. 连续3次无效自动清缓存清PID防死循环 新增 _cached_pid 字典,与 _cached_hwnd 同步生命周期
This commit is contained in:
+218
-38
@@ -10,8 +10,13 @@ import uiautomation as auto
|
||||
import win32gui
|
||||
import win32con
|
||||
import win32api
|
||||
import win32process
|
||||
import re
|
||||
import ctypes
|
||||
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")
|
||||
@@ -41,7 +46,8 @@ class MessageMonitor:
|
||||
|
||||
# 窗口句柄缓存(存 HWND,不存 Control 引用)
|
||||
self._cached_hwnd: dict = {}
|
||||
|
||||
# 窗口对应进程 ID(验证窗口是否仍属同一进程)
|
||||
self._cached_pid: dict = {}
|
||||
# 连续相同签名计数(用于冻结检测)
|
||||
self._stale_count: dict = {}
|
||||
# 连续空读
|
||||
@@ -61,6 +67,14 @@ class MessageMonitor:
|
||||
# 上次回复时间戳(冷却期内跳过该平台 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)
|
||||
|
||||
@@ -85,6 +99,7 @@ class MessageMonitor:
|
||||
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()
|
||||
@@ -110,9 +125,9 @@ class MessageMonitor:
|
||||
if self._wechat_enabled and not self._sending_event.is_set():
|
||||
self._poll("wechat")
|
||||
if self._qq_enabled:
|
||||
# QQ 降频:每 3 周期 poll 一次(~6s)
|
||||
# QQ 降频:每 4 周期 poll 一次(~8s,OCR 计算略重)
|
||||
self._qq_skip_counter += 1
|
||||
if self._qq_skip_counter >= 3 and not self._sending_event.is_set():
|
||||
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:
|
||||
@@ -136,6 +151,7 @@ class MessageMonitor:
|
||||
pass
|
||||
|
||||
self._cached_hwnd.pop(platform, None)
|
||||
self._cached_pid.pop(platform, None)
|
||||
|
||||
if platform == "wechat":
|
||||
for name in ["微信", "WeChat"]:
|
||||
@@ -148,6 +164,7 @@ class MessageMonitor:
|
||||
# 确保存顶层窗口句柄(与 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:
|
||||
@@ -162,6 +179,7 @@ class MessageMonitor:
|
||||
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
|
||||
@@ -180,6 +198,7 @@ class MessageMonitor:
|
||||
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:
|
||||
@@ -391,26 +410,189 @@ class MessageMonitor:
|
||||
pass
|
||||
return texts
|
||||
|
||||
def _read_via_clipboard(self, platform: str) -> list:
|
||||
"""剪贴板法:聚焦窗口→Ctrl+A→Ctrl+C→读剪贴板(适用 Electron 应用)。
|
||||
# ── OCR 读屏(QQ 专用,Windows 原生 OCR) ──────────────────
|
||||
|
||||
稳妥可靠但会切焦点+清用户剪贴板(自动恢复原内容)。
|
||||
自动重试 1 次若首次结果为空。
|
||||
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 词内空格保留)
|
||||
"""
|
||||
texts = self._read_via_clipboard_once(platform)
|
||||
if texts:
|
||||
# 所有紧邻 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', 'qq.exe', 'tim.exe'):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
cap_left = int(left + win_w * 0.30)
|
||||
cap_top = top + 35
|
||||
cap_right = int(left + win_w * 0.55)
|
||||
cap_bottom = bottom - 80
|
||||
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 []
|
||||
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()]
|
||||
texts = []
|
||||
for line in ocr_lines:
|
||||
line = self._normalize_ocr_text(line)
|
||||
if not line:
|
||||
continue
|
||||
if self._is_system_message(line):
|
||||
continue
|
||||
if re.match(r'^[一-鿿·A-Za-z0-9_\-]{1,10}$', 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
|
||||
# 重试一次:再次 SetForegroundWindow + SendKeys
|
||||
logger.debug(f"[{platform}] 剪贴板首次空,重试")
|
||||
time.sleep(0.5)
|
||||
texts = self._read_via_clipboard_once(platform)
|
||||
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)。
|
||||
|
||||
返回前把所有原始行预塞进 _seen_messages,再过滤只返回"像用户消息"的行。
|
||||
这样 caller 不会把发送者名/时间/历史消息当新消息处理。
|
||||
去重由 _poll 的 _replied_texts + _seen_messages 负责,本函数只过滤并返回。
|
||||
回复回环防御由 _paste_and_send 写入 _replied_texts 承担。
|
||||
"""
|
||||
hwnd = self._cached_hwnd.get(platform)
|
||||
if not hwnd or not win32gui.IsWindow(hwnd):
|
||||
@@ -505,14 +687,6 @@ class MessageMonitor:
|
||||
|
||||
logger.debug(f"[{platform}] 剪贴板读取 → {len(raw_lines)} lines")
|
||||
|
||||
# ── 关键:所有原始行预塞进 _seen_messages,避免 caller 当新消息处理 ──
|
||||
self._seen_messages.update(raw_lines)
|
||||
# 回复文本的变体也预塞
|
||||
for line in raw_lines:
|
||||
line2 = line.replace('\n', ' ').rstrip('.')
|
||||
if line2 != line and line2:
|
||||
self._seen_messages.add(line2)
|
||||
|
||||
# ── 过滤:只返回"像用户消息"的行 ──
|
||||
sender_pattern = re.compile(r'^[\u4e00-\u9fff·A-Za-z0-9_\-]{1,10}$')
|
||||
for line in raw_lines:
|
||||
@@ -821,8 +995,20 @@ class MessageMonitor:
|
||||
def _read_messages(self, window, platform: str = "") -> list:
|
||||
"""每轮重新定位 ListControl。"""
|
||||
if platform == "qq":
|
||||
# QQ NT: UIA 优先(但重建窗口绕过缓存)+ 剪贴板兜底
|
||||
# 第一步:用新窗口流式重建 UIA 树(不用 ControlFromHandle 缓存)
|
||||
# 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
|
||||
texts = self._read_via_clipboard(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)
|
||||
@@ -854,16 +1040,7 @@ class MessageMonitor:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 剪贴板兜底:仅当 UIA 完全没读到内容时启用
|
||||
texts = self._read_via_clipboard_once(platform)
|
||||
# <=5 行的剪贴板很可能是输入框内容(非消息列表)→ 丢弃
|
||||
if texts and len(texts) > 5:
|
||||
return texts
|
||||
texts = self._read_via_clipboard(platform)
|
||||
if texts and len(texts) > 5:
|
||||
return texts
|
||||
|
||||
# 最终兜底
|
||||
# Win32 兜底
|
||||
texts = self._read_win32_texts(platform)
|
||||
if texts:
|
||||
return texts
|
||||
@@ -1109,6 +1286,7 @@ class MessageMonitor:
|
||||
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
|
||||
@@ -1130,8 +1308,8 @@ class MessageMonitor:
|
||||
and not self._is_system_message(ts)):
|
||||
seen.add(ts)
|
||||
new_items.append(ts)
|
||||
# 不再主动截断 _seen_messages—剪贴板全行已预塞,截断会杀掉历史信息引发回环
|
||||
# 只有极端情况(>50万行)才清,但这个场景几乎不会出现
|
||||
# 不截断 _seen_messages — _replied_texts 已拦截回复回环,截断会误放历史消息
|
||||
# 极端情况(>50万行)才手动清,几乎不会出现
|
||||
|
||||
# 单次 poll 最多处理 3 条新消息,防止系统消息海量涌入触发回环
|
||||
MAX_NEW_ITEMS = 3
|
||||
@@ -1168,6 +1346,7 @@ class MessageMonitor:
|
||||
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},尝试强制刷新")
|
||||
@@ -1175,6 +1354,7 @@ class MessageMonitor:
|
||||
elif stale == 10:
|
||||
logger.debug(f"[{platform}] 冻结 #{stale},重建窗口引用")
|
||||
self._cached_hwnd.pop(platform, None)
|
||||
self._cached_pid.pop(platform, None)
|
||||
|
||||
|
||||
def _walk_list_controls(ctrl, depth):
|
||||
|
||||
Reference in New Issue
Block a user