221 lines
6.8 KiB
Python
221 lines
6.8 KiB
Python
# -*- 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)
|