窗口查找改纯PID+OCR垃圾过滤+md短标题修复

This commit is contained in:
youpaishidifu
2026-07-27 18:18:36 +08:00
parent a0e932276a
commit 5ecfa09017
3 changed files with 85 additions and 101 deletions
+83 -101
View File
@@ -34,6 +34,7 @@ import win32process
import re
import ctypes
import difflib
import psutil
import os
from datetime import datetime
import signal
@@ -206,100 +207,62 @@ class MessageMonitor:
except Exception:
pass
elif platform == "qq":
# 1. Win32 FindWindow 直搜 TXGuiFoundation(旧版 QQ 主窗口)
# ⚠️ 必须检查 IsWindowVisible:QQ NT 在托盘时有一个不可见的 TXGuiFoundation
# 托盘辅助窗口(空标题, IsWindowVisible=False),FindWindow 可能返回这个。
# 不可见的窗口被缓存后,_poll 里 IsWindowVisible 检查会跳过所有后续 OCR。
# 只按进程名找窗口,不管标题是什么。
# 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
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:
hwnd = win32gui.FindWindow("TXGuiFoundation", None)
if hwnd:
top = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
if win32gui.IsWindow(top) and win32gui.IsWindowVisible(top):
rect = win32gui.GetWindowRect(top)
if (rect[2] - rect[0]) > 200 and (rect[3] - rect[1]) > 100:
title = win32gui.GetWindowText(top)
self._app_variant[platform] = "tim" if "TIM" in title.upper() else "qq_nt"
self._cached_hwnd[platform] = top
self._cached_pid[platform] = win32process.GetWindowThreadProcessId(top)[1]
logger.info(f"[{platform}] 窗口已定位(Win32 TXGuiFoundation): {title} top={top}")
return top
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
logger.info(f"[{platform}] 窗口已定位(UIA TXGuiFoundation): top={top}")
return top
except Exception:
continue
except Exception:
pass
# 2. Win32 EnumWindows 兜底 — 类名+标题搜索
# QQ NT (Electron) 类名 Chrome_WidgetWin_1, 传统 QQ 类名 TXGuiFoundation
qq_titles = ("QQ", "TIM")
def _enum_callback(hwnd, results):
try:
cls = win32gui.GetClassName(hwnd)
title = win32gui.GetWindowText(hwnd)
title_upper = title.upper()
# 类名匹配(传统 QQ / TIM)
if cls == "TXGuiFoundation":
results.append(hwnd)
return True
# QQ NT Electron: 类名含 Chrome,标题含 QQ/TIM
if "Chrome" in cls and ("QQ" in title_upper or "TIM" in title_upper):
results.append(hwnd)
return True
# 标题完全匹配 QQ / TIM
if title_upper in qq_titles:
results.append(hwnd)
return True
except Exception:
pass
return True
try:
found = []
win32gui.EnumWindows(_enum_callback, found)
logger.debug(f"[{platform}] EnumWindows 候选: {len(found)} windows: "
f"{[(win32gui.GetWindowText(h)[:30], hex(h)) for h in found]}")
for hwnd in found:
top = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
if not (win32gui.IsWindow(top) and win32gui.IsWindowVisible(top)):
cls = win32gui.GetClassName(top)
title = win32gui.GetWindowText(top)
logger.debug(f"[{platform}] Enum 候选跳过(不可见): cls={cls} title={title[:30]} vis={win32gui.IsWindowVisible(top)}")
continue
rect = win32gui.GetWindowRect(top)
if not ((rect[2] - rect[0]) > 200 and (rect[3] - rect[1]) > 100):
cls = win32gui.GetClassName(top)
title = win32gui.GetWindowText(top)
logger.debug(f"[{platform}] Enum 候选跳过(尺寸): cls={cls} title={title[:30]} rect={rect}")
continue
cls = win32gui.GetClassName(top)
title = win32gui.GetWindowText(top)
title_upper = title.upper().strip()
# _enum_callback 只加了 QQ/TIM 相关窗口,所以 found 里都是有效候选
# 优先选标题含 QQ/TIM 的(传统模式),其次任何 TXGuiFoundation 或 Chrome 都接受
if "TIM" in title_upper:
self._app_variant[platform] = "tim"
else:
self._app_variant[platform] = "qq_nt"
self._cached_hwnd[platform] = top
self._cached_pid[platform] = win32process.GetWindowThreadProcessId(top)[1]
if "Chrome" in cls:
logger.info(f"[{platform}] 窗口已定位(Electron Chrome): {title} top={top}")
else:
logger.info(f"[{platform}] 窗口已定位(Win32 Enum): {title} top={top}")
return top
except Exception:
pass
# 3. UIA 按窗口名搜索(兜底,可能慢但不挂死)
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}] 窗口已定位(UIA 名称): {name} top={top}")
return top
except Exception:
pass
return None
def _ensure_hwnd_with_timeout(self, platform: str, timeout: float = 12.0) -> Optional[int]:
@@ -844,9 +807,15 @@ class MessageMonitor:
except Exception as e:
logger.debug(f"[ocr] EasyOCR 识别失败: {e}")
if win_text and easy_text:
logger.debug(f"[ocr] Windows OCR: {win_text[:100]!r}...")
logger.debug(f"[ocr] EasyOCR: {easy_text[:100]!r}...")
# 引擎原始输出逐行标记日志(用于长期对比两引擎能力)
if win_text:
for i, l in enumerate(win_text.split('\n')):
if l.strip():
logger.info(f"[ocr] [windows/原始] line[{i}]: {l.strip()[:120]!r}")
if easy_text:
for i, l in enumerate(easy_text.split('\n')):
if l.strip():
logger.info(f"[ocr] [easyocr/原始] line[{i}]: {l.strip()[:120]!r}")
raw_text, chosen_engine = self._pick_ocr_text(win_text, easy_text, im)
if not raw_text:
@@ -942,9 +911,15 @@ class MessageMonitor:
except Exception as e:
logger.debug(f"[wechat] EasyOCR 识别失败: {e}")
if win_text and easy_text:
logger.debug(f"[wechat] Windows OCR: {win_text[:100]!r}...")
logger.debug(f"[wechat] EasyOCR: {easy_text[:100]!r}...")
# 引擎原始输出逐行标记日志(用于长期对比两引擎能力)
if win_text:
for i, l in enumerate(win_text.split('\n')):
if l.strip():
logger.info(f"[wechat] [windows/原始] line[{i}]: {l.strip()[:120]!r}")
if easy_text:
for i, l in enumerate(easy_text.split('\n')):
if l.strip():
logger.info(f"[wechat] [easyocr/原始] line[{i}]: {l.strip()[:120]!r}")
raw_text, chosen_engine = self._pick_ocr_text(win_text, easy_text, im)
if not raw_text:
@@ -981,7 +956,7 @@ class MessageMonitor:
ocr_lines.append(l)
engine_label = self._last_chosen_engine.get(platform, "")
for i, rl in enumerate(ocr_lines):
logger.info(f"[ocr] raw[{i}][{engine_label}] raw_line<{rl!r}>")
logger.info(f"[ocr] [{engine_label}/后处理] line[{i}]: {rl[:120]!r}")
# —— 时间戳分割 ——
expanded = []
@@ -1039,6 +1014,8 @@ class MessageMonitor:
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):
@@ -1059,6 +1036,9 @@ class MessageMonitor:
continue
if re.match(r'^[一-鿿]{1}$', line):
continue
# OCR 碎片:≤8 字符且无中文 → 时间戳/checkbox 残片
if len(line) <= 8 and not re.search(r'[\u4e00-\u9fff]', line):
continue
if re.match(r'^\d{1,2}[::\s]\d{1,2}', line):
continue
if re.match(r'^\d{4}[/-]\d{1,2}', line):
@@ -1412,11 +1392,13 @@ class MessageMonitor:
Bot 回复用 md 格式(# 标题、[链接](url)、列表等),用户日常对话不用。
借此区分 bot 旧回复和历史堆积 vs 用户新消息。
"""
if not text or len(text) < 10:
if not text:
return False
# 行首是 # 标题(# ## ### #### 等)
if re.match(r'^#{1,6}\s', text):
# # 行首标题:短文本也检测(OCR 把 ##协议修 切成 5 字符片段)
if re.match(r'^#{1,6}(?:\s|\[|[\u4e00-\u9fff])', text):
return True
if len(text) < 10:
return False
# ##直接跟汉字(OCR 丢失 ##后空格时常见:##文档导航)
if re.match(r'^#{1,6}[\u4e00-\u9fff]', text):
return True