fix: 彻底重写消息抓取—深度4递归遍历右侧半区文本+UI树调试转储

This commit is contained in:
youpaishidifu
2026-07-24 20:50:20 +08:00
parent 2563077bbc
commit db31102a43
3 changed files with 150 additions and 92 deletions
+7
View File
@@ -19,6 +19,13 @@ env/
config.json config.json
local_config.json local_config.json
# User data
知识库/
.knowledge_base/
# Debug dumps
.debug/
# OS # OS
Thumbs.db Thumbs.db
.DS_Store .DS_Store
+7 -1
View File
@@ -9,11 +9,17 @@ os.chdir(os.path.dirname(os.path.abspath(__file__)))
# 配置日志 # 配置日志
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.DEBUG,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%H:%M:%S", datefmt="%H:%M:%S",
) )
# 文件日志(持久化,便于排查)
_fh = logging.FileHandler("msgbot.log", encoding="utf-8", mode="w")
_fh.setLevel(logging.DEBUG)
_fh.setFormatter(logging.Formatter("%(asctime)s [%(name)s] %(levelname)s: %(message)s"))
logging.getLogger().addHandler(_fh)
logger = logging.getLogger("msgbot") logger = logging.getLogger("msgbot")
+136 -91
View File
@@ -1,17 +1,21 @@
"""消息监控 — 通过 uiautomation 监听微信/QQ 新消息""" """消息监控 — 通过 uiautomation 深度遍历微信/QQ 右侧区域文本"""
import time import time
import logging import logging
import threading import threading
import os
from typing import Optional, Callable from typing import Optional, Callable
import uiautomation as auto import uiautomation as auto
logger = logging.getLogger("msgbot.monitor") logger = logging.getLogger("msgbot.monitor")
# 调试日志文件路径
DEBUG_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".debug")
class MessageMonitor: class MessageMonitor:
"""多线程消息监控器 — 右侧消息区加权 + 空读自动重扫""" """多线程消息监控器 — 深度遍历右侧半区文本 + 一次UI树调试转储"""
def __init__(self, on_message: Optional[Callable[[str, str], None]] = None): def __init__(self, on_message: Optional[Callable[[str, str], None]] = None):
self._running = False self._running = False
@@ -21,16 +25,18 @@ class MessageMonitor:
self._wechat_enabled = True self._wechat_enabled = True
self._qq_enabled = False self._qq_enabled = False
# 内容指纹 # 内容指纹(最近一条消息文本)
self._last_msg = {"wechat": "", "qq": ""} self._last_msg = {"wechat": "", "qq": ""}
# 窗口缓存 # 窗口缓存
self._cached_window: dict = {} self._cached_window: dict = {}
# 消息列表控件缓存
self._cached_list: dict = {} # 连续空读计数
# 连续空读计数(超阈值则强制重扫)
self._empty_reads: dict = {} self._empty_reads: dict = {}
# 首次调试转储标记
self._debug_dumped: set = set()
def set_poll_interval(self, seconds: float): def set_poll_interval(self, seconds: float):
self._poll_interval = max(0.5, seconds) self._poll_interval = max(0.5, seconds)
@@ -55,8 +61,9 @@ class MessageMonitor:
self._thread.join(timeout=3) self._thread.join(timeout=3)
self._thread = None self._thread = None
self._cached_window.clear() self._cached_window.clear()
self._cached_list.clear()
self._empty_reads.clear() self._empty_reads.clear()
self._last_msg.clear()
self._debug_dumped.clear()
logger.info("消息监控已停止") logger.info("消息监控已停止")
@property @property
@@ -69,127 +76,165 @@ class MessageMonitor:
while self._running: while self._running:
try: try:
if self._wechat_enabled: if self._wechat_enabled:
self._ensure_window("wechat") self._ensure_window("wechat", ["微信", "WeChat"])
self._poll("wechat") win = self._cached_window.get("wechat")
if win:
win.SetFocus() # 确保窗口 UIA 树填充
time.sleep(0.2)
texts = self._collect_right_texts(win)
if texts:
self._dump_debug("wechat", win, texts)
self._check_new("wechat", texts)
else:
self._empty_reads["wechat"] = self._empty_reads.get("wechat", 0) + 1
if self._qq_enabled: if self._qq_enabled:
self._ensure_window("qq") self._ensure_window("qq", ["QQ", "TIM"])
self._poll("qq") win = self._cached_window.get("qq")
if win:
win.SetFocus()
time.sleep(0.2)
texts = self._collect_right_texts(win)
if texts:
self._dump_debug("qq", win, texts)
self._check_new("qq", texts)
else:
self._empty_reads["qq"] = self._empty_reads.get("qq", 0) + 1
except Exception as e: except Exception as e:
logger.debug(f"监控轮询异常: {e}") logger.debug(f"监控轮询异常: {e}")
time.sleep(self._poll_interval) time.sleep(self._poll_interval)
# ── 窗口缓存管理 ──────────────────────────────────────── # ── 窗口缓存 ─────────────────────────────────────────────
def _ensure_window(self, platform: str): def _ensure_window(self, platform: str, names: list):
"""缓存窗口,不缓存消息列表(每次 poll 重找,但直取右半侧)"""
win = self._cached_window.get(platform) win = self._cached_window.get(platform)
if win and win.Exists(maxSearchSeconds=0.1): if win and win.Exists(maxSearchSeconds=0.1):
return return
self._cached_window.pop(platform, None) self._cached_window.pop(platform, None)
self._cached_list.pop(platform, None) for name in names:
names = {"wechat": ["微信", "WeChat"], "qq": ["QQ", "TIM"]}
for name in names.get(platform, []):
w = auto.WindowControl(Name=name) w = auto.WindowControl(Name=name)
if w.Exists(maxSearchSeconds=0.3): if w.Exists(maxSearchSeconds=0.5):
self._cached_window[platform] = w # 验证窗口尺寸
return try:
rect = w.BoundingRectangle
if rect.width() > 200 and rect.height() > 100:
self._cached_window[platform] = w
return
except Exception:
pass
# ── 定位右侧消息区域(窗口直接子控件,右侧加权 30x) ─ # ── 核心:深度遍历右侧半区文本 ───────────────────────
def _find_right_panel(self, window): def _collect_right_texts(self, window, max_depth=4):
"""在窗口直接子控件中找最右侧且包含最多文本的控件。""" """从窗口右侧半区收集所有可见文本(深度限制 max_depth)。"""
texts = []
try: try:
win_rect = window.BoundingRectangle rect = window.BoundingRectangle
win_cx = win_rect.left + win_rect.width() // 2 mid_x = rect.left + rect.width() // 2
best_ctrl = None
best_score = 0 def walk(ctrl, depth):
if depth > max_depth:
return
try:
cr = ctrl.BoundingRectangle
# 跳过极小控件
if cr.width() < 20 or cr.height() < 20:
return
# 右侧半区(聊天消息区域必定在右半侧)
if cr.left >= mid_x - 20:
name = ctrl.Name
if name and name.strip():
texts.append(name.strip())
for child in ctrl.GetChildren():
walk(child, depth + 1)
except Exception:
pass
for child in window.GetChildren(): for child in window.GetChildren():
try: walk(child, 1)
rect = child.BoundingRectangle
# 跳过太小的控件
if rect.width() < 80 or rect.height() < 40:
continue
# 右侧权重:聊天消息区域必定在右半侧
on_right = rect.left >= win_cx - 30
right_weight = 30.0 if on_right else 1.0
# 只取一层子 Name,不递归全树
texts = 0
for sub in child.GetChildren():
if sub.Name and sub.Name.strip():
texts += 1
score = texts * right_weight
if score > best_score:
best_score = score
best_ctrl = child
except Exception:
continue
if best_ctrl and best_score >= 2:
return best_ctrl
except Exception: except Exception:
pass pass
return None return texts
# ── 单次轮询 ──────────────────────────────────────────── # ── 当前消息与指纹对比 ────────────────────────────────
def _poll(self, platform: str):
win = self._cached_window.get(platform)
if not win:
return
# 重扫次数限制:最多每 10 秒重扫一次,避免空转
now = time.time()
last_scan = getattr(self, f"_last_scan_{platform}", 0)
if now - last_scan < 10:
# 用缓存的老控件,但验证有效性
list_ctrl = self._cached_list.get(platform)
if list_ctrl and list_ctrl.Exists(maxSearchSeconds=0.1):
texts = self._get_texts_fast(list_ctrl)
else:
texts = []
else:
# 可以做一次重扫
list_ctrl = self._find_right_panel(win)
self._cached_list[platform] = list_ctrl
setattr(self, f"_last_scan_{platform}", now)
texts = self._get_texts_fast(list_ctrl) if list_ctrl else []
def _check_new(self, platform: str, texts: list):
if not texts: if not texts:
# 空读积累,连续 3 次空读强制重扫下一次 # 空读计数
empty = self._empty_reads.get(platform, 0) + 1 empty = self._empty_reads.get(platform, 0) + 1
self._empty_reads[platform] = empty self._empty_reads[platform] = empty
if empty >= 3: logger.debug(f"[{platform}] 空读 #{empty}")
self._cached_list.pop(platform, None)
setattr(self, f"_last_scan_{platform}", 0) # 允许立即重扫
return return
# 有文本时重置空读计数器 # 清空读计数
self._empty_reads[platform] = 0 self._empty_reads[platform] = 0
# 取最新一条文本
latest = texts[-1].strip() latest = texts[-1].strip()
if not latest: if not latest:
return return
# 内容指纹比较 old = self._last_msg.get(platform, "")
if latest != self._last_msg.get(platform, ""): if latest != old:
self._last_msg[platform] = latest self._last_msg[platform] = latest
logger.info(f"[{platform}] {latest}") logger.info(f"[{platform}] {latest}")
if self._on_message: if self._on_message:
self._on_message(platform, latest) self._on_message(platform, latest)
def _get_texts_fast(self, ctrl) -> list: # ── 调试:首次运行时转储 UI 树到文件 ───────────────────
"""从控件快速提取子文本(只取一层 Name)。"""
texts = [] def _dump_debug(self, platform: str, window, texts: list):
if platform in self._debug_dumped:
return
self._debug_dumped.add(platform)
os.makedirs(DEBUG_LOG_DIR, exist_ok=True)
path = os.path.join(DEBUG_LOG_DIR, f"ui_dump_{platform}.txt")
wr = None
try: try:
for child in ctrl.GetChildren(): wr = window.BoundingRectangle
if child.Name and child.Name.strip():
texts.append(child.Name.strip())
except Exception: except Exception:
pass pass
return texts win_mid_x = (wr.left + wr.width() // 2) if wr else 0
lines = []
lines.append(f"=== UI Tree Dump ({platform}) ===")
lines.append(f"Window: {window.Name}")
lines.append(f"Rect: left={wr.left} top={wr.top} w={wr.width()} h={wr.height()}" if wr else "Rect: N/A")
lines.append(f"Mid-X: {win_mid_x}")
lines.append("")
def walk(ctrl, depth=0):
indent = " " * depth
try:
cr = ctrl.BoundingRectangle
on_right = cr.left >= win_mid_x - 20
name = (ctrl.Name or "")[:60]
ctype = ctrl.ControlTypeName
lines.append(
f"{indent}[{ctype}] x={cr.left} y={cr.top} w={cr.width()} h={cr.height()} "
f"right={on_right} name=\"{name}\""
)
except Exception:
lines.append(f"{indent}[?] (inaccessible)")
try:
for child in ctrl.GetChildren():
walk(child, depth + 1)
except Exception:
pass
for child in window.GetChildren():
walk(child, 1)
lines.append("")
lines.append(f"=== Collected Texts ({len(texts)}) ===")
for i, t in enumerate(texts):
lines.append(f" [{i}] {t}")
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
logger.info(f"[{platform}] UI树已转储到 {path}")