fix QQ消息读取: 跳过ListControl(UIA冻结)直接全窗深度遍历+Win32回退
- QQ NT TXGuiFoundation UIA树冻结,ListControl始终返回缓存数据 - QQ路径改为: 先force_scroll_bottom, 然后全窗深度遍历(max_depth=8) - 添加Win32回退(EnumChildWindows+GetWindowText)绕过UIA - QQ冻结检测更激进(stale=3刷新, stale=6重建引用) - 微信改动: 标题栏点击(Win10/11安全)->非侵入式刷新 - .gitignore添加*.log
This commit is contained in:
@@ -26,6 +26,9 @@ local_config.json
|
||||
# Debug dumps
|
||||
.debug/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
+131
-14
@@ -8,6 +8,7 @@ from typing import Optional, Callable, List
|
||||
|
||||
import uiautomation as auto
|
||||
import win32gui
|
||||
import win32con
|
||||
|
||||
logging.getLogger("comtypes").setLevel(logging.WARNING)
|
||||
logger = logging.getLogger("msgbot.monitor")
|
||||
@@ -169,25 +170,89 @@ class MessageMonitor:
|
||||
|
||||
def _force_uia_refresh(self, platform: str):
|
||||
"""通过交互强制窗口更新 UIA 控件树。"""
|
||||
# 方式1: 向窗口发送非客户区点击(不影响焦点)
|
||||
hwnd = self._cached_hwnd.get(platform)
|
||||
if hwnd:
|
||||
try:
|
||||
win32gui.PostMessage(hwnd, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN, HTCAPTION
|
||||
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 _read_win32_texts(self, platform: str) -> list:
|
||||
"""Win32 API 直接读窗口文本(绕过 UIA 缓存)。"""
|
||||
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()) >= 4:
|
||||
texts.append(text.strip())
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
try:
|
||||
main_text = win32gui.GetWindowText(hwnd)
|
||||
if main_text and len(main_text.strip()) >= 4:
|
||||
texts.append(main_text.strip())
|
||||
win32gui.EnumChildWindows(hwnd, enum_callback, None)
|
||||
except Exception:
|
||||
pass
|
||||
return texts
|
||||
|
||||
# ── 核心:读消息 ──────────────────────────────────────────
|
||||
|
||||
def _read_messages(self, window, platform: str = "") -> list:
|
||||
"""每轮重新定位 ListControl。"""
|
||||
if platform == "qq":
|
||||
# QQ NT: 消息列表 UIA 不更新,走会话列表(摘要文本实时)
|
||||
texts = self._read_conversation_list(window)
|
||||
# QQ NT: UIA 树冻结,ListControl 始终返回缓存数据
|
||||
# 跳过所有 ListControl 路径,走全窗深度遍历 + Win32 回退
|
||||
self._force_scroll_bottom(platform)
|
||||
texts = self._collect_all_texts(window, max_depth=8)
|
||||
if texts:
|
||||
logger.debug(f"[QQ] 全窗深度遍历 → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
||||
return texts
|
||||
# 微信/默认: 优先读"消息"(当前选中对话的最新消息)
|
||||
# Win32 回退:EnumChildWindows 直接读窗口文本(绕过 UIA)
|
||||
texts = self._read_win32_texts(platform)
|
||||
if texts:
|
||||
logger.debug(f"[QQ] Win32 窗口文本 → {len(texts)} items")
|
||||
return texts
|
||||
return []
|
||||
|
||||
# 微信: 优先读"消息"(ScrollPattern 滚底后实时)
|
||||
texts = self._read_message_list(window)
|
||||
if texts:
|
||||
return texts
|
||||
@@ -233,12 +298,28 @@ class MessageMonitor:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _read_message_list_deep(self, window) -> list:
|
||||
"""QQ专用:从 ListControl('消息') 深度递归提取子控件文本。"""
|
||||
for name in ["消息", "Message"]:
|
||||
try:
|
||||
lst = window.ListControl(Name=name)
|
||||
if lst.Exists(maxSearchSeconds=0.2):
|
||||
self._scroll_to_bottom(lst)
|
||||
texts = self._extract_deep_text(lst)
|
||||
if texts:
|
||||
logger.debug(f"[S-Q] ListControl({name}) 深层 → {len(texts)} items")
|
||||
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
|
||||
@@ -272,6 +353,33 @@ class MessageMonitor:
|
||||
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 = []
|
||||
@@ -383,13 +491,22 @@ class MessageMonitor:
|
||||
# 相同签名 → 冻结计数
|
||||
stale = self._stale_count.get(platform, 0) + 1
|
||||
self._stale_count[platform] = stale
|
||||
# 连续 5 次相同 → 尝试强制刷新 UIA
|
||||
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)
|
||||
if platform == "qq":
|
||||
# QQ: UIA 冻结更快,更激进
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
def _walk_list_controls(ctrl, depth):
|
||||
|
||||
Reference in New Issue
Block a user