Files
wechatauto-replica/wechatauto/uia_driver.py
T

1301 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""wechatauto.uia_driver —— 基于 UI Automation 的微信 4.x 混合驱动引擎(可选路径)
背景
----
微信 4.1.12+ 的聊天区域使用自绘渲染,冷启动时对 UIAutomation 只暴露
``Qt51514QWindowIcon`` 空壳(2 个子节点),看不到任何 ``mmui::`` 控件——
这是旧版 wxauto 的 UIA 方案失效的原因。
实测(微信 4.1.12.26 / WMPF)发现:**热激活 Qt accessibility gate 后**
UIA 树会立即物化为 ``mmui::MainWindow``,其中:
* 搜索框 ``mmui::XValidatorTextEdit``Name=「搜索」)可定位;
* 输入关键词后下拉 ``AutomationId=search_list`` 暴露结果列表
(真实结果 aid 前缀 ``search_item_*``),点击可打开会话;
* 打开会话后 ``AutomationId=chat_input_field`` 输入框可定位,
其 ``.Name`` 即当前聊天对象(三重校验依据)。
相比坐标 + OCR``wechatauto.guia``),UIA 路径更准(无 OCR 抖动/生僻字
问题)、更快(无放大/多轮投票)。本模块将其封装为与 guia 相同的调用面,
供 :meth:`WeChatGUI.open_chat` / 发送方法**优先尝试、失败自动降级到 OCR**
(见 guia.py 的混合驱动逻辑)。
热激活安全性:只写运行中 Weixin 进程的 1 个 Qt accessibility byte
(从 Weixin.dll 扫描 RVA,字节码模式匹配 + qt.accessibility.core 引用
距离过滤),不注入代码、不重启进程;该 byte 本就是系统读屏器会写入的
状态位。微信升级后扫描逻辑通常仍有效,硬编码版本表仅作兜底。
使用前提:``pip install uiautomation pywin32``。
"""
from __future__ import annotations
import ctypes
import os
import re
import struct
import time
from ctypes import wintypes
from functools import lru_cache
from typing import List, Optional, Tuple
import uiautomation as auto
try:
import win32gui, win32con, win32process, win32api
_HAS_WIN32 = True
except Exception: # pragma: no cover
_HAS_WIN32 = False
from wechatauto.logger import wxlog
# ---------------------------------------------------------------------------
# 控件锚点
# ---------------------------------------------------------------------------
MAIN_CLASS = "mmui::MainWindow"
LOGIN_CLASS = "mmui::LoginWindow"
MAIN_NAMES = ("微信", "Weixin")
LOGIN_BTN_NAMES = ("进入微信", "登录", "进入")
LOGIN_OUTLINE_CLASS = "mmui::XOutlineButton"
SEARCH_EDIT_CLASS = "mmui::XValidatorTextEdit"
SEARCH_EDIT_NAME = "搜索"
SESSION_LIST_AID = "session_list"
SEARCH_LIST_AID = "search_list"
RESULT_AID_PREFIX = "search_item_" # 真实可打开结果的 aid 前缀
CHAT_INPUT_AID = "chat_input_field" # 输入框;其 .Name == 当前聊天对象
DEFAULT_EXE = r"C:\Program Files\Tencent\Weixin\Weixin.exe"
# 搜索结果分区标题(aid 为空且名字命中此集合的才算分区头,其余空 aid 视为建议项)
SECTION_HEADERS = {
"最常使用", "最近使用", "联系人", "群聊", "公众号", "服务号", "订阅号",
"聊天记录", "收藏", "功能", "小程序", "最近使用过的小程序", "视频号",
"企业微信联系人", "朋友圈", "搜索网络结果", "企业微信",
}
# ---------------------------------------------------------------------------
# 无障碍“屏幕阅读器”系统标志 / Qt accessibility gate
# ---------------------------------------------------------------------------
SPI_GETSCREENREADER = 0x0046
SPI_SETSCREENREADER = 0x0047
SPIF_SENDCHANGE = 0x02
# 微信内置 Qt accessibility gateQAccessible 查询前会检查一个运行时 active
# byte。冷启动未启用读屏时该 byte 为 0WM_GETOBJECT 只能拿到 Qt 外壳;
# 热写为 1 后当前进程立即返回 mmui provider,无需重启微信。优先从 Weixin.dll
# 自动扫描该 byte 的 RVA,下面的版本表仅作扫描失败时的兜底。
QACCESSIBLE_ACTIVE_RVA_BY_VERSION = {
"4.1.11.22": 0x0A1E7DB8,
}
QACCESSIBLE_CORE_STRING = b"qt.accessibility.core"
QACCESSIBLE_GATE_PATTERN = re.compile(
rb"\x48\x85\xc9\x0f\x84....\x80\x3d(?P<disp>.{4})"
rb"\x00\x0f\x84",
re.DOTALL,
)
IMAGE_SCN_MEM_EXECUTE = 0x20000000
IMAGE_SCN_MEM_WRITE = 0x80000000
PROCESS_VM_OPERATION = 0x0008
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_QUERY_INFORMATION = 0x0400
TH32CS_SNAPMODULE = 0x00000008
TH32CS_SNAPMODULE32 = 0x00000010
INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
MAX_MODULE_NAME32 = 255
MAX_PATH = 260
class MODULEENTRY32W(ctypes.Structure):
_fields_ = [
("dwSize", ctypes.c_ulong),
("th32ModuleID", ctypes.c_ulong),
("th32ProcessID", ctypes.c_ulong),
("GlblcntUsage", ctypes.c_ulong),
("ProccntUsage", ctypes.c_ulong),
("modBaseAddr", ctypes.POINTER(ctypes.c_ubyte)),
("modBaseSize", ctypes.c_ulong),
("hModule", ctypes.c_void_p),
("szModule", ctypes.c_wchar * (MAX_MODULE_NAME32 + 1)),
("szExePath", ctypes.c_wchar * MAX_PATH),
]
# ---------------------------------------------------------------------------
# 异常
# ---------------------------------------------------------------------------
class UIATimeout(Exception):
"""UIA 操作超时。"""
class UIANotAvailable(Exception):
"""UIA 树不可用(热激活失败 / 微信未运行等),调用方应降级到 OCR。"""
# ---------------------------------------------------------------------------
# 引擎
# ---------------------------------------------------------------------------
class WeChatUIA:
"""基于 UIAutomation 的微信 4.x 驱动引擎(混合驱动首选路径)。
用法(一般通过 WeChatGUI 自动切换,也可独立使用)::
from wechatauto.uia_driver import WeChatUIA
uia = WeChatUIA()
uia.ensure_window()
uia.open_chat('文件传输助手')
uia.send_text('你好')
"""
def __init__(self, timeout: float = 15.0, search_timeout: float = 2.0):
self.timeout = timeout
self._win = None
# 线程安全:WeChatBot 等宿主可能在后台线程实例化本驱动,COM 未初始化
# 时 uiautomation 会报「尚未调用 CoInitialize / 无法加载
# UIAutomationCore.dll」。CoInitializeEx 幂等,主线程重复调用无害。
try:
auto.InitializeUIAutomationInCurrentThread()
except Exception:
pass
try:
auto.SetGlobalSearchTimeout(search_timeout)
except Exception:
pass
# ------------------------------------------------------------------ 基础
@staticmethod
def is_running() -> bool:
try:
import subprocess
out = subprocess.run(["tasklist", "/fi", "imagename eq Weixin.exe",
"/nh"], capture_output=True, text=True,
timeout=10).stdout or ""
return "Weixin.exe" in out
except Exception:
return False
def wake(self) -> None:
"""weixin:// 协议唤起/显示窗口(托盘态也能拉起);失败则拉起 exe。"""
try:
os.startfile("weixin://")
return
except Exception:
pass
try:
import subprocess
subprocess.Popen([DEFAULT_EXE])
except Exception:
wxlog.warning("拉起微信失败")
# ------------------------------------------------------------------ 热激活
@staticmethod
def _set_screen_reader_flag(enable: bool) -> None:
try:
ctypes.windll.user32.SystemParametersInfoW(
SPI_SETSCREENREADER, 1 if enable else 0, 0, SPIF_SENDCHANGE)
except Exception:
pass
@staticmethod
def _pid_from_hwnd(hwnd: int) -> Optional[int]:
if _HAS_WIN32:
try:
return win32process.GetWindowThreadProcessId(hwnd)[1]
except Exception:
return None
pid = wintypes.DWORD()
try:
ctypes.windll.user32.GetWindowThreadProcessId(
wintypes.HWND(hwnd), ctypes.byref(pid))
return int(pid.value) or None
except Exception:
return None
@staticmethod
def _process_modules(pid: int):
kernel32 = ctypes.windll.kernel32
kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
kernel32.Module32FirstW.argtypes = [wintypes.HANDLE, ctypes.POINTER(MODULEENTRY32W)]
kernel32.Module32FirstW.restype = wintypes.BOOL
kernel32.Module32NextW.argtypes = [wintypes.HANDLE, ctypes.POINTER(MODULEENTRY32W)]
kernel32.Module32NextW.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
snapshot = kernel32.CreateToolhelp32Snapshot(
TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid)
if snapshot == INVALID_HANDLE_VALUE:
return
try:
entry = MODULEENTRY32W()
entry.dwSize = ctypes.sizeof(entry)
if not kernel32.Module32FirstW(snapshot, ctypes.byref(entry)):
return
while True:
base = ctypes.cast(entry.modBaseAddr, ctypes.c_void_p).value or 0
yield base, int(entry.modBaseSize), entry.szModule, entry.szExePath
if not kernel32.Module32NextW(snapshot, ctypes.byref(entry)):
break
finally:
kernel32.CloseHandle(snapshot)
@classmethod
def _weixin_dll_module(cls, pid: int):
for base, size, name, path in cls._process_modules(pid) or ():
if name.lower() == "weixin.dll":
return base, size, path
return None
@staticmethod
def _pe_sections(data: bytes):
try:
pe_off = struct.unpack_from("<I", data, 0x3C)[0]
if data[pe_off:pe_off + 4] != b"PE\0\0":
return []
coff = pe_off + 4
count = struct.unpack_from("<H", data, coff + 2)[0]
opt_size = struct.unpack_from("<H", data, coff + 16)[0]
sec_off = coff + 20 + opt_size
sections = []
for i in range(count):
off = sec_off + i * 40
name = data[off:off + 8].split(b"\0", 1)[0].decode("ascii", "ignore")
virtual_size, virtual_address, raw_size, raw_ptr = struct.unpack_from(
"<IIII", data, off + 8)
characteristics = struct.unpack_from("<I", data, off + 36)[0]
sections.append({
"name": name,
"rva": virtual_address,
"vsize": virtual_size,
"raw_size": raw_size,
"raw_ptr": raw_ptr,
"chars": characteristics,
})
return sections
except Exception:
return []
@staticmethod
def _section_for_rva(sections, rva: int):
for sec in sections:
size = max(sec["vsize"], sec["raw_size"])
if sec["rva"] <= rva < sec["rva"] + size:
return sec
return None
@staticmethod
def _offset_to_rva(sections, offset: int) -> Optional[int]:
for sec in sections:
if sec["raw_ptr"] <= offset < sec["raw_ptr"] + sec["raw_size"]:
return sec["rva"] + offset - sec["raw_ptr"]
return None
@staticmethod
def _rip_xrefs_to_rva(data: bytes, sections, target_rva: int) -> List[int]:
xrefs: List[int] = []
for sec in sections:
if not (sec["chars"] & IMAGE_SCN_MEM_EXECUTE):
continue
start = sec["raw_ptr"]
end = min(len(data), start + sec["raw_size"])
raw = data[start:end]
for i in range(0, max(0, len(raw) - 7)):
if 0x40 <= raw[i] <= 0x4F and raw[i + 1] == 0x8D and (raw[i + 2] & 0xC7) == 0x05:
disp = struct.unpack_from("<i", raw, i + 3)[0]
insn_rva = sec["rva"] + i
if insn_rva + 7 + disp == target_rva:
xrefs.append(insn_rva)
if raw[i] == 0x8D and (raw[i + 1] & 0xC7) == 0x05:
disp = struct.unpack_from("<i", raw, i + 2)[0]
insn_rva = sec["rva"] + i
if insn_rva + 6 + disp == target_rva:
xrefs.append(insn_rva)
return xrefs
@staticmethod
@lru_cache(maxsize=8)
def _scan_qaccessible_active_rva(dll_path: str) -> Optional[int]:
try:
with open(dll_path, "rb") as f:
data = f.read()
except OSError:
return None
sections = WeChatUIA._pe_sections(data)
if not sections:
return None
core_off = data.find(QACCESSIBLE_CORE_STRING)
core_rva = WeChatUIA._offset_to_rva(sections, core_off) if core_off >= 0 else None
core_xrefs = (WeChatUIA._rip_xrefs_to_rva(data, sections, core_rva)
if core_rva is not None else [])
candidates: List[Tuple[int, int]] = []
for match in QACCESSIBLE_GATE_PATTERN.finditer(data):
match_rva = WeChatUIA._offset_to_rva(sections, match.start())
disp_rva = WeChatUIA._offset_to_rva(sections, match.start("disp"))
if match_rva is None or disp_rva is None:
continue
match_sec = WeChatUIA._section_for_rva(sections, match_rva)
if not match_sec or not (match_sec["chars"] & IMAGE_SCN_MEM_EXECUTE):
continue
cmp_rva = disp_rva - 2 # 80 3d <disp32> 00
disp = struct.unpack("<i", match.group("disp"))[0]
target_rva = cmp_rva + 7 + disp
target_sec = WeChatUIA._section_for_rva(sections, target_rva)
if not target_sec or not (target_sec["chars"] & IMAGE_SCN_MEM_WRITE):
continue
if core_xrefs:
distance = min(abs(match_rva - xref) for xref in core_xrefs)
# 真正的 QAccessible gate 与 qt.accessibility.core 日志分类
# 处于同一局部 Qt accessibility 代码岛内
if distance > 0x20000:
continue
else:
distance = 0x7FFFFFFF
candidates.append((distance, target_rva))
if not candidates:
return None
candidates.sort(key=lambda item: item[0])
return candidates[0][1]
@staticmethod
def _qaccessible_active_rva(dll_path: str) -> Optional[int]:
scanned = WeChatUIA._scan_qaccessible_active_rva(dll_path)
if scanned is not None:
return scanned
version = os.path.basename(os.path.dirname(dll_path))
return QACCESSIBLE_ACTIVE_RVA_BY_VERSION.get(version)
@staticmethod
def _read_process_byte(handle, address: int) -> Optional[int]:
ctypes.windll.kernel32.ReadProcessMemory.argtypes = [
wintypes.HANDLE, ctypes.c_void_p, ctypes.c_void_p,
ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]
ctypes.windll.kernel32.ReadProcessMemory.restype = wintypes.BOOL
buf = (ctypes.c_ubyte * 1)()
read = ctypes.c_size_t(0)
ok = ctypes.windll.kernel32.ReadProcessMemory(
handle, ctypes.c_void_p(address), buf, 1, ctypes.byref(read))
return int(buf[0]) if ok and read.value == 1 else None
@staticmethod
def _write_process_byte(handle, address: int, value: int) -> bool:
ctypes.windll.kernel32.WriteProcessMemory.argtypes = [
wintypes.HANDLE, ctypes.c_void_p, ctypes.c_void_p,
ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]
ctypes.windll.kernel32.WriteProcessMemory.restype = wintypes.BOOL
buf = (ctypes.c_ubyte * 1)(value & 0xFF)
written = ctypes.c_size_t(0)
ok = ctypes.windll.kernel32.WriteProcessMemory(
handle, ctypes.c_void_p(address), buf, 1, ctypes.byref(written))
return bool(ok and written.value == 1)
def _hot_activate_accessibility(self, hwnd: int) -> bool:
"""运行中热激活 Qt accessibility gate,不重启微信进程。"""
pid = self._pid_from_hwnd(hwnd)
if not pid:
return False
mod = self._weixin_dll_module(pid)
if not mod:
wxlog.warning("热激活 UIA 失败:PID %s 未找到 Weixin.dll。", pid)
return False
base, _size, dll_path = mod
rva = self._qaccessible_active_rva(dll_path)
if rva is None:
wxlog.warning("热激活 UIA 失败:不支持的 Weixin.dll 版本路径 %s。", dll_path)
return False
access = (PROCESS_QUERY_INFORMATION | PROCESS_VM_READ |
PROCESS_VM_WRITE | PROCESS_VM_OPERATION)
kernel32 = ctypes.windll.kernel32
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
handle = kernel32.OpenProcess(access, False, pid)
if not handle:
wxlog.warning("热激活 UIA 失败:无法打开 Weixin.exe PID %s。", pid)
return False
try:
address = int(base) + int(rva)
current = self._read_process_byte(handle, address)
if current == 1:
return True
if current is None:
wxlog.warning("热激活 UIA 失败:无法读取 active byte。")
return False
if not self._write_process_byte(handle, address, 1):
wxlog.warning("热激活 UIA 失败:无法写入 active byte。")
return False
wxlog.info("已热激活微信 UIA 树:PID=%s Weixin.dll+0x%x: %s -> 1",
pid, rva, current)
return True
finally:
kernel32.CloseHandle(handle)
def _wake_accessibility(self) -> bool:
ok = False
for hwnd in self._wechat_hwnds():
ok = self._hot_activate_accessibility(hwnd) or ok
if ok:
self._win = None
time.sleep(0.2)
return ok
# ------------------------------------------------------------------ 窗口定位
def _wechat_hwnds(self):
if not _HAS_WIN32:
return []
res = []
def cb(h, _):
try:
if win32gui.IsWindowVisible(h) and win32gui.GetWindowText(h) in MAIN_NAMES:
# 只保留加载了 Weixin.dll 的主进程窗口,过滤掉无 DLL 的
# 辅助进程窗口(其热激活必然失败,只会产生噪音警告)
pid = self._pid_from_hwnd(h)
if pid and self._weixin_dll_module(pid):
l, t, r, b = win32gui.GetWindowRect(h)
res.append(((r - l) * (b - t), h))
except Exception:
pass
return True
try:
win32gui.EnumWindows(cb, None)
except Exception:
pass
res.sort(reverse=True)
return [h for _, h in res]
def _anchor(self, hwnd):
try:
return auto.ControlFromHandle(hwnd)
except Exception:
return None
def _find_main(self):
for h in self._wechat_hwnds():
c = self._anchor(h)
if c is not None and (c.ClassName or "") == MAIN_CLASS:
return c
return None
def _login_window(self):
for h in self._wechat_hwnds():
c = self._anchor(h)
if c is not None and (c.ClassName or "") == LOGIN_CLASS:
return c
return None
# ------------------------------------------------------------------ 前台/登录
@staticmethod
def _force_foreground(hwnd) -> bool:
if not _HAS_WIN32 or not hwnd:
return False
try:
if win32gui.IsIconic(hwnd):
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
except Exception:
pass
if win32gui.GetForegroundWindow() == hwnd:
return True
cur = win32api.GetCurrentThreadId()
fg = win32gui.GetForegroundWindow()
fgt = win32process.GetWindowThreadProcessId(fg)[0] if fg else 0
try:
if fgt:
win32process.AttachThreadInput(cur, fgt, True)
except Exception:
pass
ok = False
for _ in range(3):
try:
win32gui.ShowWindow(hwnd, win32con.SW_SHOW)
win32gui.BringWindowToTop(hwnd)
win32gui.SetForegroundWindow(hwnd)
if win32gui.GetForegroundWindow() == hwnd:
ok = True
break
except Exception:
pass
time.sleep(0.1)
try:
if fgt:
win32process.AttachThreadInput(cur, fgt, False)
except Exception:
pass
return ok
def _activate(self, win) -> None:
if not self._force_foreground(win.NativeWindowHandle):
try:
win.ShowWindow(9)
except Exception:
pass
try:
win.SetActive()
except Exception:
pass
time.sleep(0.25)
def _wait_main(self, timeout: float, allow_login: bool = True,
allow_accessibility_wake: bool = True):
deadline = time.time() + max(timeout, 15)
last_wake = time.time()
accessibility_woke = False
while time.time() < deadline:
if allow_login:
self._auto_login()
w = self._find_main()
if w is not None:
self._win = w
self._activate(w)
return w
if (allow_accessibility_wake and not accessibility_woke
and self._login_window() is None and self._wechat_hwnds()
and time.time() - last_wake > 3):
self._wake_accessibility()
accessibility_woke = True
last_wake = time.time()
continue
if self._login_window() is None and time.time() - last_wake > 6:
self.wake()
last_wake = time.time()
time.sleep(0.8)
raise UIATimeout("等待微信主窗口超时(客户端未就绪)。")
def ensure_window(self, wake: bool = True, timeout: Optional[float] = None) -> bool:
"""确保可访问的主窗口存在并置前,返回是否成功。
优先 ControlFromHandle 按句柄锚定拿 mmui 树;若树未物化(只剩 Qt
外壳)则对当前 Weixin 进程热激活,不重启微信。任一环节失败返回
False,由调用方降级到 OCR。
"""
timeout = timeout or self.timeout
w = self._find_main()
if w is not None:
self._win = w
self._activate(w)
return True
if not wake:
return False
try:
if self._login_window() is not None:
self._auto_login()
elif self._wechat_hwnds():
self._wake_accessibility()
else:
self._set_screen_reader_flag(True)
self.wake()
self._wait_main(timeout)
return self._win is not None
except UIATimeout:
return False
except Exception as e:
wxlog.debug("ensure_window 失败:%s", e)
return False
def _auto_login(self) -> bool:
lw = self._login_window()
if lw is None:
return False
self._force_foreground(lw.NativeWindowHandle)
time.sleep(0.3)
for nm in LOGIN_BTN_NAMES:
btn = lw.ButtonControl(Name=nm)
if btn.Exists(0.2, 0.1):
btn.Click()
time.sleep(1.2)
return True
ob = lw.ButtonControl(ClassName=LOGIN_OUTLINE_CLASS)
if ob.Exists(0.2, 0.1) and (ob.Name or "").strip():
ob.Click()
time.sleep(1.2)
return True
return False
# ------------------------------------------------------------------ 剪贴板
@staticmethod
def _clip_get() -> str:
try:
import pyperclip
return pyperclip.paste()
except Exception:
pass
try:
return auto.GetClipboardText()
except Exception:
return ""
@staticmethod
def _clip_set(text: str) -> None:
try:
import pyperclip
pyperclip.copy(text)
return
except Exception:
pass
try:
auto.SetClipboardText(text)
except Exception:
pass
@staticmethod
def _set_cursor(x: int, y: int) -> None:
try:
import ctypes
ctypes.windll.user32.SetCursorPos(int(x), int(y))
except Exception:
pass
@staticmethod
def _mouse_wheel(delta: int) -> None:
try:
import ctypes
ctypes.windll.user32.mouse_event(0x0800, 0, 0, int(delta), 0)
except Exception:
pass
@staticmethod
def _left_click() -> None:
try:
import ctypes
ctypes.windll.user32.mouse_event(0x0002, 0, 0, 0, 0) # down
time.sleep(0.05)
ctypes.windll.user32.mouse_event(0x0004, 0, 0, 0, 0) # up
except Exception:
pass
@staticmethod
def _right_click() -> None:
try:
import ctypes
ctypes.windll.user32.mouse_event(0x0008, 0, 0, 0, 0) # down
time.sleep(0.05)
ctypes.windll.user32.mouse_event(0x0010, 0, 0, 0, 0) # up
except Exception:
pass
# ------------------------------------------------------------------ 控件定位
def _search_box(self, win):
for kw in (dict(ClassName=SEARCH_EDIT_CLASS, Name=SEARCH_EDIT_NAME),
dict(Name=SEARCH_EDIT_NAME),
dict(ClassName=SEARCH_EDIT_CLASS)):
e = win.EditControl(**kw)
if e.Exists(1.0, 0.2):
return e
return None
def _chat_input(self, win=None):
win = win or self._win
if win is None:
return None
e = win.EditControl(AutomationId=CHAT_INPUT_AID)
return e if e.Exists(1.0, 0.2) else None
def current_chat(self) -> Optional[str]:
e = self._chat_input()
return (e.Name or None) if e else None
def _find_search_list(self, timeout: float = 3.0):
deadline = time.time() + timeout
while time.time() < deadline:
lst = auto.ListControl(searchDepth=0xFFFFFFFF, AutomationId=SEARCH_LIST_AID)
if lst.Exists(0.2, 0.1):
return lst
time.sleep(0.2)
return None
def _collect_results(self, keyword: str, settle: float = 1.5) -> List[dict]:
"""采集去重后的候选 [{name, section, aid, cell}]。"""
lst = self._find_search_list()
if lst is None:
return []
deadline = time.time() + settle
raw = []
while time.time() < deadline:
section = None
raw = []
for c in lst.GetChildren():
name = c.Name or ""
aid = c.AutomationId or ""
if not aid:
head = name.strip()
if head in SECTION_HEADERS:
section = head
continue
if not aid.startswith(RESULT_AID_PREFIX):
continue
raw.append({
"cell": c,
"name": name.split("\n", 1)[0].strip(),
"section": section,
"aid": aid,
})
if raw:
break
time.sleep(0.2)
kw = keyword.strip()
matched = [r for r in raw if r["name"] == kw]
if not matched:
matched = raw[:]
# 最常使用/最近使用中重复出现的同一 aid 去重
non_freq = {r["aid"] for r in matched
if r["section"] not in ("最常使用", "最近使用")}
pruned = [r for r in matched
if not (r["section"] in ("最常使用", "最近使用")
and r["aid"] in non_freq)]
for i, r in enumerate(pruned):
r["index"] = i
return pruned
# ------------------------------------------------------------------ 高层动作
@staticmethod
def _resolve_search_keyword(keyword: str) -> List[str]:
"""把账号/username 解析成微信搜索框能命中的关键词列表。
微信搜索框不认 wxid(系统账号),只认昵称/备注/微信号(alias)。
优先返回直接命中词(原样),再尝试 DB 映射:username 精确 → 昵称/备注/微信号。
"""
candidates = [keyword]
try:
from wechatauto.db import WeChatDB
db = WeChatDB()
for hit in db.search_contact(keyword):
for k in (hit.get("remark"), hit.get("nick_name")):
if k and k not in candidates:
candidates.append(k)
except Exception:
pass
return candidates
def open_chat(self, keyword: str, index: Optional[int] = None,
section: Optional[str] = None, retries: int = 2) -> bool:
"""搜索并打开联系人/群聊,成功后校验输入框 Name。返回是否成功。
传入 username/wxid 时自动映射为昵称/备注/微信号再搜索(微信搜索框
不认 wxid)。搜索框残留会影响命中,每次打开前先清空重试。
"""
if not self.ensure_window():
return False
win = self._win
box = self._search_box(win)
if box is None:
return False
keywords = self._resolve_search_keyword(keyword)
results = []
used_kw = keyword
for kw in keywords:
got = False
for attempt in range(max(1, retries)):
self._paste_into(box, kw, clear=True)
time.sleep(0.8)
got = self._collect_results(kw)
if got:
break
time.sleep(0.4)
if got:
results = got
used_kw = kw
break
if not results:
return False
if section:
filtered = [r for r in results
if (r["section"] or "") == section]
if filtered:
results = filtered
if len(results) > 1 and index is not None:
filtered = [r for r in results if r["index"] == index]
if filtered:
results = filtered
elif len(results) > 1:
# 有精确名称命中时优先精确命中,否则用第一个
exact = [r for r in results if r["name"] == used_kw]
if exact:
results = exact
chosen = results[0]
chosen["cell"].Click()
time.sleep(0.7)
name = self.current_chat()
if name and (name == chosen["name"] or name == used_kw):
return True
return False
def send_text(self, text: str) -> bool:
"""在已打开会话的输入框发送文本。返回是否成功。"""
if not self.ensure_window():
return False
e = self._chat_input()
if e is None:
return False
self._paste_into(e, text, clear=True)
time.sleep(0.2)
try:
e.SendKeys("{Enter}", waitTime=0.05)
except Exception:
return False
return True
def send_text_to(self, text: str, who: str) -> bool:
"""打开会话并发送文本(组合动作)。"""
if self.current_chat() == who:
return self.send_text(text)
if not self.open_chat(who):
return False
return self.send_text(text)
def voice_call(self, who: Optional[str] = None, video: bool = False) -> bool:
"""发起语音/视频通话(点击标题栏通话按钮 → 选择菜单项)。
微信 4.x 标题栏暴露 ``mmui::ChatVoIPView`` 下的 ``voip_button``
aid=voip_button)。点击后弹出 ``mmui::XMenuView`` 菜单,含
「语音通话」「视频通话」两个 ``MenuItemControl``aid=XMenuItem);
需再点击目标菜单项才真正发起通话。video=True 时选「视频通话」,
否则选「语音通话」。
"""
if not self.ensure_window():
return False
if who and not self.current_chat() == who:
if not self.open_chat(who):
return False
win = self._win
if win is None:
return False
# voip_button 控件树会动态重建,需重试定位
btn = None
for _ in range(6):
try:
btn = win.ButtonControl(AutomationId="voip_button")
if btn.Exists(0.6, 0.2):
break
except Exception:
pass
time.sleep(0.5)
if not btn or not btn.Exists(0):
return False
try:
btn.Click()
time.sleep(0.8)
except Exception:
return False
# 菜单里选择 语音/视频 通话项
target_name = "视频通话" if video else "语音通话"
for _ in range(4):
try:
item = win.MenuItemControl(AutomationId="XMenuItem", Name=target_name)
if item.Exists(0.5, 0.2):
item.Click()
time.sleep(0.5)
return True
except Exception:
pass
# 菜单可能延迟出现或树重建,重试
time.sleep(0.4)
return False
def _find_friend_row(self):
"""找一条对方(friend)消息行控件,供右键头像触发拍一拍。
消息行 rect 是全宽,方向需按行内内容重心判断(friend 内容靠左,
self 靠右)。返回 friend 消息行控件。
"""
lst = self._message_list()
if lst is None:
return None
try:
from PIL import ImageGrab as IG
import numpy as np
except Exception:
return None
# 可视区(消息列表内部矩形),过滤掉滚出可视区的行
lr = lst.BoundingRectangle
vis_top, vis_bottom = lr.top, lr.bottom
# 优先文字行,其次其他内容行(动画表情/图片/引用等)
rows = list(lst.GetChildren())
for ch in sorted(rows, key=lambda c: c.ClassName != "mmui::ChatTextItemView"):
try:
cn = ch.ClassName or ""
if not cn.startswith("mmui::Chat"):
continue
if cn in ("mmui::ChatItemView", "mmui::ChatSystemInfoItemView"):
continue
r = ch.BoundingRectangle
if r.bottom - r.top < 40:
continue
# 行必须在可视区内(含部分露出),排除被遮挡/滚出的行
if r.top >= vis_bottom or r.bottom <= vis_top:
continue
img = IG.grab(bbox=(r.left, r.top, r.right, r.bottom))
arr = np.array(img.convert("RGB"))
bg = (arr.max(axis=2) > 235) & ((arr.max(axis=2) - arr.min(axis=2)) < 22)
colc = (~bg).sum(axis=0)
total = int(colc.sum())
if total < 20:
continue
weighted = sum(x * colc[x] for x in range(len(colc))) / total
if weighted < arr.shape[1] * 0.5:
return ch
except Exception:
continue
return None
def poke(self, who: Optional[str] = None) -> bool:
"""对联系人发起「拍一拍」(右键头像 → 点击拍一拍菜单项)。
微信 4.x 的拍一拍只能通过右键聊天中对方头像触发,菜单为自绘
不暴露 UIA;因此用「右键头像 + 全屏 OCR 定位拍一拍文字」实现。
需要 winsdk OCR 可用。
"""
if not self.ensure_window():
return False
if who and not self.current_chat() == who:
if not self.open_chat(who):
return False
row = self._find_friend_row()
if row is None:
return False
try:
r = row.BoundingRectangle
except Exception:
return False
# 头像位于消息行最左侧约 40-50px 处
ax = r.left + 70
ay = (r.top + r.bottom) // 2
try:
from wechatauto.guia import ScreenOCR
import PIL.ImageGrab as IG
except Exception:
return False
for attempt in range(2):
self._set_cursor(ax, ay)
time.sleep(0.2)
self._right_click()
time.sleep(1.0)
img = IG.grab()
res = ScreenOCR.recognize(img)
for text, x, y, w, h in res:
t = (text or "").replace(" ", "")
if "拍一拍" in t or t == "拍一" or t.startswith("拍一"):
# 点击该文字中心
cx = x + w // 2
cy = y + h // 2
self._set_cursor(cx, cy)
time.sleep(0.2)
self._left_click()
time.sleep(0.5)
return True
return False
def _right_click_latest_row(self, who: Optional[str] = None) -> Optional[Tuple[int, int]]:
"""打开会话并右键最新一条消息行,返回气泡内右键坐标 (x, y);失败返回 None。"""
if not self.ensure_window():
return None
if who and not self.current_chat() == who:
if not self.open_chat(who):
return None
lst = self._message_list()
if lst is None:
return None
rows = list(lst.GetChildren())
# 取可视区内最底部(最新)的消息行;时间分隔行 ChatItemView 跳过
candidates = []
for ch in rows:
try:
cn = ch.ClassName or ""
if not cn.startswith("mmui::Chat"):
continue
if cn in ("mmui::ChatItemView", "mmui::ChatSystemInfoItemView"):
continue
r = ch.BoundingRectangle
if r.bottom - r.top < 40:
continue
candidates.append((ch, r))
except Exception:
continue
if not candidates:
return None
# 最新消息在可视区底部(消息列表打开即定位在最新),取 bottom 最大者
target = max(candidates, key=lambda t: t[1].bottom)
ch, r = target
# 消息行内取内容重心 x(self 靠右、friend 靠左),y 取行垂直中心
try:
from PIL import ImageGrab as IG
img = IG.grab(bbox=(r.left, r.top, r.right, r.bottom))
import numpy as np
arr = np.array(img.convert("RGB"))
bg = (arr.max(axis=2) > 235) & ((arr.max(axis=2) - arr.min(axis=2)) < 22)
colc = (~bg).sum(axis=0)
total = int(colc.sum())
if total >= 20:
cx = sum(x * colc[x] for x in range(len(colc))) / total
cx = int(r.left + cx)
else:
cx = (r.left + r.right) // 2
except Exception:
cx = (r.left + r.right) // 2
cy = (r.top + r.bottom) // 2
self._set_cursor(cx, cy)
time.sleep(0.2)
self._right_click()
time.sleep(1.0)
return (cx, cy)
def _uia_find_menu_item(self, name_sub: str, max_depth: int = 6):
"""在主窗口树内查找菜单项控件(UIA 方案)。
微信 4.x 右键菜单在热激活后物化为 UIA 节点:``mmui::XMenu`` 下挂
``mmui::XMenuView``Name 即菜单文字)。只遍历主窗口子树,避免
触发 Windows UIA 根遍历的系统挂起 bug。返回匹配的控件或 None。
"""
w = self._win
if w is None:
return None
found = [None]
def walk(c, d=0):
if found[0] is not None or d > max_depth:
return
try:
children = c.GetChildren()
except Exception:
return
for ch in children:
try:
nm = ch.Name or ""
cn = ch.ClassName or ""
except Exception:
nm = cn = ""
if cn == "mmui::XMenuView" and name_sub in nm:
found[0] = ch
return
walk(ch, d + 1)
try:
walk(w)
except Exception:
return None
return found[0]
def _uia_click_menu_item(self, ctrl) -> bool:
"""通过 UIA Invoke/LegacyIAccessible 或鼠标点击菜单项控件。"""
for method in ("Invoke", "Select", "Expand"):
try:
getattr(ctrl, method)()
time.sleep(0.3)
return True
except Exception:
continue
try:
r = ctrl.BoundingRectangle
cx = (r.left + r.right) // 2
cy = (r.top + r.bottom) // 2
self._set_cursor(cx, cy)
time.sleep(0.2)
self._left_click()
time.sleep(0.3)
return True
except Exception:
return False
def recall_last_message(self, who: Optional[str] = None) -> bool:
"""撤回当前会话最新一条自己发送的消息。
UIA 方案优先:右键消息行后,主窗口树内 ``mmui::XMenuView`` 已物化
菜单项(Name 含「撤回」),直接定位点击;若菜单项为「删除」(消息
超过 2 分钟撤回时限)则返回失败。UIA 不可用/未命中时降级到 OCR
(全屏识别「撤回」文字定位点击)。两者都失败返回 False。
"""
pos = self._right_click_latest_row(who)
if pos is None:
return False
# UIA 方案
item = self._uia_find_menu_item("撤回")
if item is not None:
if self._uia_click_menu_item(item):
return True
# OCR 兜底
try:
from wechatauto.guia import ScreenOCR
import PIL.ImageGrab as IG
except Exception:
return False
for attempt in range(2):
if attempt > 0:
cx, cy = pos
self._set_cursor(cx, cy)
time.sleep(0.2)
self._right_click()
time.sleep(1.0)
img = IG.grab()
res = ScreenOCR.recognize(img)
for text, x, y, w, h in res:
t = (text or "").replace(" ", "")
if "撤回" in t or t == "撤回":
cxx = x + w // 2
cyy = y + h // 2
self._set_cursor(cxx, cyy)
time.sleep(0.2)
self._left_click()
time.sleep(0.5)
return True
return False
# ------------------------------------------------------------------ 剪贴板粘贴
def _paste_into(self, ctrl, text: str, clear: bool = True) -> None:
ctrl.Click()
time.sleep(0.1)
if clear:
try:
ctrl.SendKeys("{Ctrl}a{Delete}", waitTime=0.05)
except Exception:
pass
self._clip_set(text)
try:
ctrl.SendKeys("{Ctrl}v", waitTime=0.05)
except Exception:
pass
# ------------------------------------------------------------------ 消息列表定位
def _message_list(self, win=None):
"""定位当前会话的消息列表控件(RecyclerListView / chat_message_list)。"""
win = win or self._win
if win is None:
return None
def walk(c, d=0):
if d > 15:
return None
for ch in c.GetChildren():
try:
cn = ch.ClassName or ""
except Exception:
continue
if cn == "mmui::RecyclerListView":
return ch
r = walk(ch, d + 1)
if r is not None:
return r
return None
return walk(win)
def find_in_message_list(self, predicate, match_last: bool = False,
max_scrolls: int = 40) -> Optional[Tuple]:
"""在消息列表中按谓词查找消息控件,返回 (className, name, rect)。
RecyclerListView 是虚拟化列表,只实例化可视区约 12 条;历史消息需
滚动。ScrollPattern 不可用(返回空),改用鼠标滚轮驱动列表滚动。
match_last=True 时滚到底后从底部向上找,用于取最新表情/消息。
"""
lst = self._message_list()
if lst is None:
return None
def scroll(direction: str, times: int = 1) -> None:
# direction: 'up'=滚向最新(底部), 'down'=滚向历史(更早)
# 实测(微信4.1.12.26):open_chat 后消息列表定位在底部(最新),
# 滚轮 +120 滚向历史(更早),-120 滚向最新。
try:
r = lst.BoundingRectangle
cx = (r.left + r.right) // 2
cy = (r.top + r.bottom) // 2
self._set_cursor(cx, cy)
delta = -120 if direction == 'up' else 120
for _ in range(times):
self._mouse_wheel(delta)
time.sleep(0.2)
except Exception:
pass
def visible():
out = []
for ch in lst.GetChildren():
try:
cn = ch.ClassName or ""
nm = ch.Name or ""
if cn == "mmui::ChatItemView": # 时间分隔行跳过
continue
if predicate(cn, nm):
out.append((cn, nm, ch))
except Exception:
continue
return out
# 列表定位在底部(最新)。match_last 直接取当前可视区最匹配的一条
# (最新消息已实例化),没有才向上(滚向历史)翻找。
if match_last:
got = visible()
if got:
ch = got[-1][2]
try:
r = ch.BoundingRectangle
return (got[-1][0], got[-1][1], r)
except Exception:
pass
for _ in range(max_scrolls):
got = visible()
if got:
ch = got[-1][2]
try:
r = ch.BoundingRectangle
return (got[-1][0], got[-1][1], r)
except Exception:
pass
scroll('down', 2) # 滚向历史找更早匹配
return None
# 非 match_last:从底部(最新)向历史逐屏扫描
seen = set()
for _ in range(max_scrolls):
for cn, nm, ch in visible():
try:
r = ch.BoundingRectangle
key = (r.left, r.top, r.right, r.bottom)
except Exception:
continue
if key in seen:
continue
seen.add(key)
if predicate(cn, nm):
return (cn, nm, r)
scroll('down', 2)
return None
# ------------------------------------------------------------------ 调试
def dump(self, max_depth: int = 16, max_nodes: int = 1500):
"""打印主窗口 UIA 树(控件名失效时重新勘察)。"""
if not self.ensure_window():
print("UIA 树不可用")
return
win = self._win
cnt = [0]
def walk(c, d=0):
if d > max_depth or cnt[0] > max_nodes:
return
for k in c.GetChildren():
if cnt[0] > max_nodes:
break
cnt[0] += 1
try:
print(" " * d + f"{k.ControlTypeName} class={k.ClassName!r} "
f"name={(k.Name or '')[:30]!r} aid={k.AutomationId!r}")
except Exception:
continue
walk(k, d + 1)
walk(win)