清理旧代码+修复TIM抓错搜索框
- 删除旧 _collect_all_texts(被 _collect_filtered_texts 替代) - 删除 _read_message_list_deep(旧QQ路径,已废弃) - 微信fallback改走 _collect_filtered_texts - 添加 _click_message_area:剪贴板前先点击中右区域聚焦消息区 - TIM Electron 通过鼠标点击消息区再Ctrl+A→Ctrl+C - 添加 win32api import(用于 SetCursorPos+mouse_event)
This commit is contained in:
+206
@@ -0,0 +1,206 @@
|
||||
"""诊断:dump TIM 窗口控件树,找消息区路径"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import uiautomation as auto
|
||||
import win32gui
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
log = logging.getLogger("debug")
|
||||
|
||||
OUTPUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tim_dump.txt")
|
||||
|
||||
|
||||
def find_tim_window():
|
||||
"""找 TIM 主窗口(TXGuiFoundation 类名)"""
|
||||
root = auto.GetRootControl()
|
||||
for child in root.GetChildren():
|
||||
try:
|
||||
cn = child.ClassName or ""
|
||||
name = child.Name or ""
|
||||
rect = child.BoundingRectangle
|
||||
if cn == "TXGuiFoundation" and rect.width() > 200 and rect.height() > 100:
|
||||
log.info(f"找到 TIM: Name={name} ClassName={cn} rect=({rect.left},{rect.top},{rect.right},{rect.bottom})")
|
||||
return child
|
||||
except:
|
||||
pass
|
||||
# 也试试 "TIM" 名称
|
||||
try:
|
||||
w = auto.WindowControl(Name="TIM")
|
||||
if w.Exists(maxSearchSeconds=0.5):
|
||||
log.info(f"找到 TIM(ByName): {w.Name} ClassName={w.ClassName}")
|
||||
return w
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def dump_tree(ctrl, depth=0, max_depth=5, file=None):
|
||||
"""递归 dump 控件树"""
|
||||
if depth > max_depth:
|
||||
return
|
||||
indent = " " * depth
|
||||
try:
|
||||
cn = ctrl.ControlTypeName or "?"
|
||||
name = (ctrl.Name or "")[:80]
|
||||
cls = ctrl.ClassName or ""
|
||||
rect = ctrl.BoundingRectangle
|
||||
has_scroll = False
|
||||
try:
|
||||
sp = ctrl.GetScrollPattern()
|
||||
has_scroll = sp is not None
|
||||
except:
|
||||
pass
|
||||
line = f"{indent}{cn} Class={cls} Name=\"{name}\" rect=({rect.left},{rect.top},{rect.right},{rect.bottom}) scroll={has_scroll}"
|
||||
if file:
|
||||
file.write(line + "\n")
|
||||
else:
|
||||
print(line)
|
||||
except Exception as e:
|
||||
line = f"{indent}[error] {e}"
|
||||
if file:
|
||||
file.write(line + "\n")
|
||||
else:
|
||||
print(line)
|
||||
return
|
||||
|
||||
try:
|
||||
for child in ctrl.GetChildren():
|
||||
dump_tree(child, depth + 1, max_depth, file)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def dump_flat_texts(window, max_depth=6):
|
||||
"""模拟 _collect_all_texts 的输出"""
|
||||
texts = []
|
||||
def walk(ctrl, depth):
|
||||
if depth > max_depth:
|
||||
return
|
||||
try:
|
||||
cr = ctrl.BoundingRectangle
|
||||
if cr.width() < 20 or cr.height() < 20:
|
||||
return
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
name = ctrl.Name
|
||||
if name and name.strip():
|
||||
texts.append((depth, name.strip()[:100]))
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
for child in ctrl.GetChildren():
|
||||
walk(child, depth + 1)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
for child in ctrl.GetChildren():
|
||||
walk(child, 1)
|
||||
except:
|
||||
pass
|
||||
return texts
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("TIM 控件树诊断")
|
||||
print("=" * 60)
|
||||
|
||||
win = find_tim_window()
|
||||
if not win:
|
||||
print("未找到 TIM 窗口")
|
||||
return
|
||||
|
||||
hwnd = win.NativeWindowHandle
|
||||
print(f"\nHWND: {hwnd}")
|
||||
print(f"ClassName: {win.ClassName}")
|
||||
print(f"Name: {win.Name}")
|
||||
print()
|
||||
|
||||
# 1. 深度 4 级控件树
|
||||
print("-" * 60)
|
||||
print("控件树 (depth<=4):")
|
||||
print("-" * 60)
|
||||
with open(OUTPUT, "w", encoding="utf-8") as f:
|
||||
f.write("TIM 控件树诊断\n")
|
||||
f.write(f"HWND={hwnd} ClassName={win.ClassName} Name={win.Name}\n\n")
|
||||
f.write("--- 控件树 (depth<=4) ---\n")
|
||||
dump_tree(win, 0, 4, f)
|
||||
|
||||
# 2. 当前 _collect_all_texts 抓到的全部文本
|
||||
print("\n" + "-" * 60)
|
||||
print("全窗深度遍历文本 (depth<=6):")
|
||||
print("-" * 60)
|
||||
all_texts = dump_flat_texts(win, 6)
|
||||
with open(OUTPUT, "a", encoding="utf-8") as f:
|
||||
f.write("\n--- 全窗文本 (depth<=6) ---\n")
|
||||
for d, t in all_texts:
|
||||
f.write(f" d={d} \"{t}\"\n")
|
||||
for d, t in all_texts:
|
||||
print(f" d={d} \"{t}\"")
|
||||
|
||||
# 3. 找 ListControl
|
||||
print("\n" + "-" * 60)
|
||||
print("ListControl 列表:")
|
||||
print("-" * 60)
|
||||
def find_list(ctrl, depth=0, max_d=8):
|
||||
if depth > max_d:
|
||||
return
|
||||
try:
|
||||
if ctrl.ControlTypeName == "ListControl":
|
||||
name = (ctrl.Name or "")[:60]
|
||||
rect = ctrl.BoundingRectangle
|
||||
print(f" depth={depth} Name=\"{name}\" rect=({rect.left},{rect.top},{rect.right},{rect.bottom})")
|
||||
# dump items
|
||||
for item in ctrl.GetChildren():
|
||||
try:
|
||||
iname = (item.Name or "")[:80]
|
||||
irec = item.BoundingRectangle
|
||||
print(f" item: \"{iname}\" rect=({irec.left},{irec.top},{irec.right},{irec.bottom})")
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
for child in ctrl.GetChildren():
|
||||
find_list(child, depth + 1, max_d)
|
||||
except:
|
||||
pass
|
||||
with open(OUTPUT, "a", encoding="utf-8") as f:
|
||||
f.write("\n--- ListControl ---\n")
|
||||
find_list(win)
|
||||
|
||||
# 4. 找所有 Text 或 Edit 控件(搜索框特征)
|
||||
print("\n" + "-" * 60)
|
||||
print("EditControl / 搜索框:")
|
||||
print("-" * 60)
|
||||
def find_edit(ctrl, depth=0, max_d=8):
|
||||
if depth > max_d:
|
||||
return
|
||||
try:
|
||||
ct = ctrl.ControlTypeName or ""
|
||||
if "Edit" in ct or "Text" in ct:
|
||||
name = (ctrl.Name or "")[:60]
|
||||
cls = ctrl.ClassName or ""
|
||||
rect = ctrl.BoundingRectangle
|
||||
print(f" depth={depth} type={ct} class={cls} Name=\"{name}\" rect=({rect.left},{rect.top},{rect.right},{rect.bottom})")
|
||||
with open(OUTPUT, "a", encoding="utf-8") as f:
|
||||
f.write(f" depth={depth} type={ct} class={cls} Name=\"{name}\"\n")
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
for child in ctrl.GetChildren():
|
||||
find_edit(child, depth + 1, max_d)
|
||||
except:
|
||||
pass
|
||||
find_edit(win, 0, 8)
|
||||
|
||||
print(f"\n完整诊断已保存到: {OUTPUT}")
|
||||
print("请把该文件内容发给我分析")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+137
-26
@@ -9,6 +9,7 @@ from typing import Optional, Callable, List
|
||||
import uiautomation as auto
|
||||
import win32gui
|
||||
import win32con
|
||||
import win32api
|
||||
|
||||
logging.getLogger("comtypes").setLevel(logging.WARNING)
|
||||
logger = logging.getLogger("msgbot.monitor")
|
||||
@@ -208,6 +209,35 @@ class MessageMonitor:
|
||||
except Exception as e:
|
||||
logger.debug(f"[{platform}] 滚动到底失败: {e}")
|
||||
|
||||
def _click_message_area(self, platform: str):
|
||||
"""点击窗口消息区(中右区域)聚焦消息列表,避免焦点在搜索框。
|
||||
|
||||
TIM Electron 消息区 UIA 不可遍历,剪贴板法会复制搜索框内容。
|
||||
先点击消息区确保焦点在消息列表。
|
||||
"""
|
||||
hwnd = self._cached_hwnd.get(platform)
|
||||
if not hwnd:
|
||||
return
|
||||
try:
|
||||
rect = win32gui.GetWindowRect(hwnd)
|
||||
if not rect:
|
||||
return
|
||||
left, top, right, bottom = rect
|
||||
width = right - left
|
||||
height = bottom - top
|
||||
if width < 100 or height < 100:
|
||||
return
|
||||
# 消息区大致在中右区域
|
||||
click_x = left + int(width * 0.65)
|
||||
click_y = top + int(height * 0.45)
|
||||
win32api.SetCursorPos((click_x, click_y))
|
||||
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, click_x, click_y, 0, 0)
|
||||
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, click_x, click_y, 0, 0)
|
||||
time.sleep(0.3)
|
||||
logger.debug(f"[{platform}] 已点击消息区 ({click_x},{click_y})")
|
||||
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)
|
||||
@@ -233,23 +263,89 @@ class MessageMonitor:
|
||||
pass
|
||||
return texts
|
||||
|
||||
def _read_via_clipboard(self, platform: str) -> list:
|
||||
"""剪贴板法:聚焦窗口→Ctrl+A→Ctrl+C→读剪贴板(适用 Electron 应用)。
|
||||
|
||||
稳妥可靠但会切焦点+清用户剪贴板(自动恢复原内容)。
|
||||
"""
|
||||
hwnd = self._cached_hwnd.get(platform)
|
||||
if not hwnd or not win32gui.IsWindow(hwnd):
|
||||
return []
|
||||
|
||||
import win32clipboard as wc
|
||||
|
||||
old = None
|
||||
try:
|
||||
wc.OpenClipboard()
|
||||
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
|
||||
old = wc.GetClipboardData(wc.CF_UNICODETEXT)
|
||||
wc.CloseClipboard()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
texts = []
|
||||
try:
|
||||
win32gui.SetForegroundWindow(hwnd)
|
||||
time.sleep(0.3)
|
||||
|
||||
# Ctrl+A (全选)
|
||||
auto.SendKeys('{Ctrl}a')
|
||||
time.sleep(0.15)
|
||||
# Ctrl+C (复制)
|
||||
auto.SendKeys('{Ctrl}c')
|
||||
time.sleep(0.3)
|
||||
|
||||
wc.OpenClipboard()
|
||||
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
|
||||
content = wc.GetClipboardData(wc.CF_UNICODETEXT)
|
||||
if content:
|
||||
texts = [l.strip() for l in content.strip().split('\n') if l.strip()]
|
||||
wc.CloseClipboard()
|
||||
|
||||
logger.debug(f"[{platform}] 剪贴板读取 → {len(texts)} lines")
|
||||
except Exception as e:
|
||||
logger.debug(f"[{platform}] 剪贴板失败: {e}")
|
||||
finally:
|
||||
if old is not None:
|
||||
try:
|
||||
wc.OpenClipboard()
|
||||
wc.EmptyClipboard()
|
||||
wc.SetClipboardText(old, wc.CF_UNICODETEXT)
|
||||
wc.CloseClipboard()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return texts
|
||||
|
||||
# ── 核心:读消息 ──────────────────────────────────────────
|
||||
|
||||
def _read_messages(self, window, platform: str = "") -> list:
|
||||
"""每轮重新定位 ListControl。"""
|
||||
if platform == "qq":
|
||||
# QQ NT: UIA 树冻结,ListControl 始终返回缓存数据
|
||||
# 跳过所有 ListControl 路径,走全窗深度遍历 + Win32 回退
|
||||
# TIM/QQ NT: Electron 应用,UIA 树很深且消息区内容不可遍历
|
||||
# 策略A: 深度 UIA 遍历 + 过滤搜索框区域(非侵入)
|
||||
# 策略B: 剪贴板法(可靠但切焦点)
|
||||
# 策略C: Win32 EnumChildWindows 回退
|
||||
self._force_scroll_bottom(platform)
|
||||
texts = self._collect_all_texts(window, max_depth=8)
|
||||
|
||||
# A: 深度 UIA 遍历(depth=12),按位置过滤搜索框区域
|
||||
texts = self._collect_filtered_texts(window, platform)
|
||||
if texts:
|
||||
logger.debug(f"[QQ] 全窗深度遍历 → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
||||
logger.debug(f"[QQ] 深度遍历 → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
||||
return texts
|
||||
# Win32 回退:EnumChildWindows 直接读窗口文本(绕过 UIA)
|
||||
|
||||
# B: 聚焦消息区 → 剪贴板法(Electron 最可靠)
|
||||
self._click_message_area(platform)
|
||||
texts = self._read_via_clipboard(platform)
|
||||
if texts:
|
||||
return texts
|
||||
|
||||
# C: Win32 回退
|
||||
texts = self._read_win32_texts(platform)
|
||||
if texts:
|
||||
logger.debug(f"[QQ] Win32 窗口文本 → {len(texts)} items")
|
||||
return texts
|
||||
|
||||
return []
|
||||
|
||||
# 微信: 优先读"消息"(ScrollPattern 滚底后实时)
|
||||
@@ -276,7 +372,7 @@ class MessageMonitor:
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
texts = self._collect_all_texts(window, max_depth=5)
|
||||
texts = self._collect_filtered_texts(window, "wechat", max_depth=5)
|
||||
if texts:
|
||||
logger.debug(f"[S-D] 全窗文本 → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
||||
return texts
|
||||
@@ -298,21 +394,6 @@ 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:
|
||||
@@ -410,8 +491,10 @@ class MessageMonitor:
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _collect_all_texts(window, max_depth=4):
|
||||
texts = []
|
||||
def _collect_filtered_texts(window, platform: str = "", max_depth=14) -> list:
|
||||
"""深度遍历 + 位置过滤(排除搜索框/标题栏区域)。"""
|
||||
items = [] # [(text, rect_top, rect_left, depth)]
|
||||
|
||||
def walk(ctrl, depth):
|
||||
if depth > max_depth:
|
||||
return
|
||||
@@ -420,11 +503,13 @@ class MessageMonitor:
|
||||
if cr.width() < 20 or cr.height() < 20:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
try:
|
||||
name = ctrl.Name
|
||||
if name and name.strip():
|
||||
texts.append(name.strip())
|
||||
t = name.strip()
|
||||
if len(t) >= 2 and not t.isdigit():
|
||||
items.append((t, cr.top, cr.left, depth))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
@@ -432,12 +517,38 @@ class MessageMonitor:
|
||||
walk(child, depth + 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
for child in window.GetChildren():
|
||||
walk(child, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return texts
|
||||
|
||||
if not items:
|
||||
return []
|
||||
|
||||
try:
|
||||
w_rect = window.BoundingRectangle
|
||||
w_width = w_rect.width()
|
||||
w_height = w_rect.height()
|
||||
except Exception:
|
||||
w_width = 1390
|
||||
w_height = 953
|
||||
|
||||
if platform == "qq":
|
||||
filtered = [
|
||||
t for t, top, left, d in items
|
||||
if top > w_height * 0.08
|
||||
]
|
||||
seen = set()
|
||||
deduped = []
|
||||
for t in filtered:
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
deduped.append(t)
|
||||
return deduped
|
||||
|
||||
return [t for t, _, _, _ in items]
|
||||
|
||||
# ── 单次轮询 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user