fix: 彻底重写消息抓取—深度4递归遍历右侧半区文本+UI树调试转储
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user