# -*- coding: utf-8 -*- """微信 4.x UI 自动化(发送消息) 微信 4.x 主窗口是完全自绘的(MMUIRenderSubWindow*,不同版本后缀不同),UIA 树不可用, 因此采用坐标模拟:激活窗口 → 点击搜索框 → 输入联系人关键词 → Enter 打开会话 → 点击输入框 → 输入文本 → Enter 发送。 坐标是相对窗口的逻辑坐标(自绘 UI 版本差异可能导致偏移,可调整常量)。 验证通过后,可通过读回数据库确认消息是否发送成功(sender_id=2)。 """ from __future__ import annotations import ctypes import time from ctypes import wintypes _user32 = ctypes.WinDLL("user32", use_last_error=True) _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) _user32.FindWindowW.restype = wintypes.HWND _user32.FindWindowW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR] _user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)] _user32.GetWindowRect.restype = wintypes.BOOL _user32.SetForegroundWindow.argtypes = [wintypes.HWND] _user32.SetForegroundWindow.restype = wintypes.BOOL _user32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] _user32.ShowWindow.restype = wintypes.BOOL _user32.SetCursorPos.argtypes = [ctypes.c_int, ctypes.c_int] _user32.SetCursorPos.restype = wintypes.BOOL _user32.GetDpiForWindow.argtypes = [wintypes.HWND] _user32.GetDpiForWindow.restype = wintypes.UINT # WeChat 4.x 主窗口类名 MAIN_WND_CLASS = "Qt51514QWindowIcon" def _restore_keep_maximize(hwnd: int): """取消最小化并显示窗口,同时保留其最大化状态。 ``ShowWindow(hwnd, SW_RESTORE)`` 会把最大化窗口恢复为普通大小(窗口被 缩小)。先用 ``GetWindowPlacement`` 记录最大化状态:原为最大化则用 ``SW_SHOWMAXIMIZED`` 恢复,否则才走 ``SW_RESTORE``。 """ if not hwnd or not _user32.IsWindow(hwnd): return class _POINT(ctypes.Structure): _fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)] class _WINDOWPLACEMENT(ctypes.Structure): _fields_ = [ ("length", wintypes.UINT), ("flags", wintypes.UINT), ("showCmd", wintypes.UINT), ("ptMinPosition", _POINT), ("ptMaxPosition", _POINT), ("rcNormalPosition", wintypes.RECT), ] wp = _WINDOWPLACEMENT() wp.length = ctypes.sizeof(_WINDOWPLACEMENT) if _user32.GetWindowPlacement(hwnd, ctypes.byref(wp)): if wp.showCmd == 3: # SW_SHOWMAXIMIZED _user32.ShowWindow(hwnd, 3) # 保持/恢复最大化 return _user32.ShowWindow(hwnd, 9) # SW_RESTORE INPUT_EVENT = 0x0002 KEYEVENTF_KEYUP = 0x0002 KEYEVENTF_UNICODE = 0x0004 VK_RETURN = 0x0D WM_KEYUP = 0x0101 def _ensure_dpi_aware(): try: _kernel32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4)) # PER_MONITOR_AWARE_V2 except Exception: pass class KEYBDINPUT(ctypes.Structure): _fields_ = [ ("wVk", wintypes.WORD), ("wScan", wintypes.WORD), ("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_size_t), ] class MOUSEINPUT(ctypes.Structure): _fields_ = [ ("dx", wintypes.LONG), ("dy", wintypes.LONG), ("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_size_t), ] class _INPUT_UNION(ctypes.Union): _fields_ = [ ("ki", KEYBDINPUT), ("mi", MOUSEINPUT), ] class _INPUT(ctypes.Structure): _fields_ = [ ("type", wintypes.DWORD), ("union", _INPUT_UNION), ] def _send_input(scan: int = 0, unicode_char: str = "", flags: int = 0): inp = _INPUT() inp.type = INPUT_EVENT inp.union.ki.wVk = 0 inp.union.ki.wScan = ord(unicode_char) if unicode_char else scan inp.union.ki.dwFlags = flags | KEYEVENTF_UNICODE if unicode_char else flags inp.union.ki.time = 0 inp.union.ki.dwExtraInfo = 0 _user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(_INPUT)) def type_text(text: str, delay: float = 0.01): """用 Unicode 输入事件键入文本(含中文)""" for ch in text: _send_input(unicode_char=ch, flags=0) _send_input(unicode_char=ch, flags=KEYEVENTF_KEYUP) time.sleep(delay) def press_enter(): _send_input(scan=VK_RETURN, flags=0) _send_input(scan=VK_RETURN, flags=KEYEVENTF_KEYUP) def click(x: int, y: int): _user32.SetCursorPos(x, y) time.sleep(0.08) _user32.mouse_event(0x0002, 0, 0, 0, 0) # LEFTDOWN _user32.mouse_event(0x0004, 0, 0, 0, 0) # LEFTUP def find_main_window(): """返回主窗口句柄(或 None)。先按类名,失败再按标题兜底(兼容不同版本类名)。""" hwnd = _user32.FindWindowW(MAIN_WND_CLASS, None) if hwnd: return hwnd return _user32.FindWindowW(None, "微信") or None def window_rect(hwnd): r = wintypes.RECT() _user32.GetWindowRect(hwnd, ctypes.byref(r)) return (r.left, r.top, r.right, r.bottom) def dpi_scale(hwnd) -> float: return _user32.GetDpiForWindow(hwnd) / 96.0 class WeChatUI: """微信 4.x 发送工具(坐标模拟)""" # 逻辑坐标(相对窗口左上角),自绘 UI 版本差异可在此调整 SEARCH_BOX = (140, 60) # 顶部搜索框 INPUT_BOX = (0.55, 0.88) # 输入框(相对窗口宽/高比例) def __init__(self, hwnd=None): _ensure_dpi_aware() self.hwnd = hwnd or find_main_window() if not self.hwnd: raise RuntimeError("未找到微信主窗口") def activate(self): _restore_keep_maximize(self.hwnd) _user32.SetForegroundWindow(self.hwnd) time.sleep(0.5) def _pt(self, lx, ly): """逻辑坐标 → 屏幕物理坐标""" left, top, right, bottom = window_rect(self.hwnd) w, h = right - left, bottom - top if isinstance(lx, float): lx = w * lx if isinstance(ly, float): ly = h * ly return int(left + lx), int(top + ly) def open_chat(self, keyword: str): """通过顶部搜索框打开会话(搜索 → Enter 选中第一项)""" self.activate() x, y = self._pt(*self.SEARCH_BOX) click(x, y) time.sleep(0.6) type_text(keyword) time.sleep(0.8) press_enter() time.sleep(1.0) def send(self, text: str): """向当前打开的会话发送文本""" self.activate() x, y = self._pt(*self.INPUT_BOX) click(x, y) time.sleep(0.4) type_text(text) time.sleep(0.2) press_enter() time.sleep(0.6) def send_to(self, keyword: str, text: str): """搜索并打开会话后发送""" self.open_chat(keyword) self.send(text)