Windows GUI 自动化 · 纯 ctypes 方案
核心结论:不依赖任何第三方自动化库,只用 ctypes + PIL 即可完整驱动 Windows GUI。
截图回读靠多模态看图能力闭环验证。
0. 什么时候用 / 不用
用:
- 目标软件只有 GUI 入口(安装向导、文件对话框、托盘/圆盘菜单、自绘 UI)。
- 已实测排除命令行:传参数、拖放关联、直接改配置目录,都无效。
- 需要"帮着点三下"类的小操作。
不用:
- 有 CLI / API / 配置文件可直接达成 → 优先走那些(更快更稳)。
- 网页类任务 → 用浏览器自动化或直接抓取。
动手前先穷尽非 GUI 路径,GUI 模拟永远是最后手段(脆、慢、会占用户鼠标)。
1. 起手式:必须设 DPI 感知
不设必错位。 高 DPI 机器上 GetSystemMetrics 返回逻辑像素(如 1536×1080),
而 SetCursorPos/窗口矩形用物理像素(1920×1080),差 1.25 倍 → 点偏。
import ctypes
u32 = ctypes.windll.user32
try: ctypes.windll.shcore.SetProcessDpiAwareness(2) # PER_MONITOR_AWARE_V2,首选
except Exception: u32.SetProcessDPIAware() # 兜底
print(u32.GetSystemMetrics(0), u32.GetSystemMetrics(1)) # 应与物理分辨率一致
2. 基本动作
import time
def click(x, y, button='left', pause=0.30):
u32.SetCursorPos(x, y); time.sleep(pause)
down, up = (0x0002, 0x0004) if button == 'left' else (0x0008, 0x0010)
u32.mouse_event(down, 0, 0, 0, 0); time.sleep(0.07)
u32.mouse_event(up, 0, 0, 0, 0)
def key(vk, mods=()): # vk 用虚拟键码
for m in mods: u32.keybd_event(m, 0, 0, 0)
u32.keybd_event(vk, 0, 0, 0); u32.keybd_event(vk, 0, 0x0002, 0)
for m in reversed(mods): u32.keybd_event(m, 0, 0x0002, 0)
CTRL, ALT, SHIFT = 0x11, 0x12, 0x10
VK_A, VK_C, VK_V, VK_H, VK_RETURN, VK_ESCAPE = 0x41, 0x43, 0x56, 0x48, 0x0D, 0x1B
- 拖拽 =
SetCursorPos起点 →LEFTDOWN→ 若干次SetCursorPos中间点(每步 sleep 20~40ms)→LEFTUP。 - 双击 = 两次
click,间隔 150~250ms(太慢变两次单击)。 - 右键菜单:
click(x, y, 'right')。
3. 找窗口
from ctypes import wintypes
def find_windows(pred):
out = []
CB = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
@CB
def cb(h, l):
cls = ctypes.create_unicode_buffer(256); u32.GetClassNameW(h, cls, 256)
n = u32.GetWindowTextLengthW(h)
b = ctypes.create_unicode_buffer(n + 1); u32.GetWindowTextW(h, b, n + 1)
r = wintypes.RECT(); u32.GetWindowRect(h, ctypes.byref(r))
if pred(cls.value, b.value, h):
out.append((h, cls.value, b.value, (r.left, r.top, r.right, r.bottom)))
return True
u32.EnumWindows(cb, 0)
return out
常用 class 名参照:
| class | 是什么 |
|---|---|
| #32770 | 系统对话框(文件/文件夹选择、消息框、UAC) |
| SDL_app | SDL 窗口(大量桌面应用/游戏/自绘 UI) |
| Chrome_WidgetWin_1 | Chromium 系(Electron/Edge/Chrome) |
| CabinetWClass | 资源管理器 |
| Shell_TrayWnd | 任务栏 |
| tooltips_class32 / SysShadow | 悬停提示、阴影(忽略掉) |
判位置用 GetWindowRect 取绝对屏幕坐标,点击坐标 = 窗口左上 + 相对偏移。
相对偏移从截图量出来最可靠(见 §5)。
4. 剪贴板:输入中文/长路径的唯一稳法
keybd_event 只能打 ASCII 且受输入法干扰。中文、长路径、"含空格的路径"一律走剪贴板。
k32 = ctypes.windll.kernel32
k32.GlobalAlloc.restype = ctypes.c_void_p
k32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t]
k32.GlobalLock.restype = ctypes.c_void_p
k32.GlobalLock.argtypes = [ctypes.c_void_p]
k32.GlobalUnlock.argtypes= [ctypes.c_void_p]
u32.SetClipboardData.restype = ctypes.c_void_p
u32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
def set_clipboard(text):
u32.OpenClipboard(None); u32.EmptyClipboard()
data = text.encode('utf-16le') + b'\x00\x00'
h = k32.GlobalAlloc(0x0002, len(data)) # GMEM_MOVEABLE
p = k32.GlobalLock(h); ctypes.memmove(p, data, len(data)); k32.GlobalUnlock(h)
u32.SetClipboardData(13, h); u32.CloseClipboard() # CF_UNICODETEXT
> ⚠️ 不设 argtypes/restype 会 OSError: access violation writing 0x0 ——
> 64 位下句柄被截断成 32 位。这是本方案最容易踩的一脚。
然后 set_clipboard(path) → 点击输入框 → key(VK_A, [CTRL]) → key(VK_V, [CTRL])。
5. 验证闭环(本方案的精髓)
不要盲点。每次点击前后都截图看。
from PIL import ImageGrab
def shot(path, box=None):
im = ImageGrab.grab(all_screens=True) # 多显示器需要 all_screens
if box: im = im.crop(box) # 只留关心的区域,读图更清楚
im.save(path); return path
流程:截图 → 看图 → 判断元素位置 → 点击 → 再截图确认。
- 全屏图 1920×1080 直接看会糊,务必 crop 到目标窗口范围再读。
- 长图(教程 PNG 之类)按高度切 6~8 段 + 放大 1.6 倍再读。
- 图片坐标 = 物理像素坐标,与 SetCursorPos 同一坐标系 → 看图就能换算点击点。
6. 六个必踩的坑
- 首次点击只用于激活窗口,不会被控件接收 → 目标窗口非前台时连点两次,或先
SetForegroundWindow(hwnd)再 sleep 0.6s。 - DPI 未设 → 整体点偏 1.25 倍(见 §1)。
- 剪贴板 argtypes 未设 → access violation(见 §4)。
- 路径分隔符:系统"选择文件夹"框拒收正斜杠,
D:/a/b报「文件夹名无效」,必须D:\a\b。 - 别点在图标/按钮上:列表卡片常带悬浮小图标(表情/文件夹/删除),点卡片空白或大图区域。
- 恢复鼠标:开始前
GetCursorPos存原位置,结束后SetCursorPos还回去,少打扰用户。
另:动用户鼠标前先告知(它会自己动,用户会以为闹鬼)。
7. 实战配方:自绘 UI(圆盘/环形菜单)
NanoVG/Nuklear/SDL 这类自绘菜单不是 Win32 控件,拿不到句柄,只能按坐标点。
- 先右键触发根窗口,枚举窗口看是否多出一个 SDL_app 的菜单窗口。
- 环形菜单:从截图量出圆心 (cx, cy) 与外圈半径 R,第 k 个扇区(12 等分,从正上顺时针)
的角度 θ = -90° + k*30°,点 (cx + R*cosθ*0.75, cy + R*sinθ*0.75)。
- 先点最安全的项试水(如"设置"),确认坐标系没跑偏,再点目标项。
- 图标语义靠看截图认(齿轮=设置、水滴=透明度、跑步=动作…),别猜。
系统对话框(#32770)是另一回事——那是真 Win32 控件,可靠得多:
优先利用它的「文件夹:」/「文件名:」输入框 + 确定按钮,比在自绘 UI 上点坐标稳 10 倍。
8. 参考流程:带 GUI 导入功能的软件(典型链路)
许多桌面软件的"导入素材"只有 GUI 入口,标准链路如下:
右键程序主体 → 弹出圆盘/环形菜单(自绘窗口,class 常为 SDL_app)
点菜单里的齿轮/设置项 → 打开设置窗口
点左栏的目标分类 → 切换到对应面板
点虚线框「点击或拖入文件夹」 → 弹出系统对话框 #32770「选择文件夹」
点「文件夹:」框 → Ctrl+A → Ctrl+V → 粘入绝对路径(反斜杠!)
点「选择文件夹」 → 导入完成,素材目录下出现规范化包
点新素材卡片 → 切换生效
- 全程纯 ctypes,无第三方依赖。
- 关键判据全部落在文件系统证据上(素材目录变化、状态文件),不靠"看起来成功了"。
9. 做完必查(不要只看截图)
截图是"当时的画面",持久化状态要去文件里核:
配置文件 / 注册表 / 安装目录 / 进程列表。
例:切换素材后要确认该软件的状态文件里对应字段已变——这才证明重启后仍生效。