Chapter 04

鼠标与键盘控制实现

用 pyautogui 实现精确的鼠标键盘模拟,配合安全等待策略

pyautogui 基础

安装与安全设置

pip install pyautogui

# 在有 GUI 的 Linux 环境中,可能还需要
pip install python3-xlib  # X11 后端
# 或
pip install pyautoguix  # 更好的 Linux 支持
import pyautogui
import time

# 重要安全设置
pyautogui.FAILSAFE = True   # 将鼠标移到屏幕左上角可以中断脚本
pyautogui.PAUSE = 0.1       # 每次操作后默认暂停 0.1 秒

# 获取屏幕分辨率
screen_width, screen_height = pyautogui.size()
print(f"屏幕分辨率: {screen_width}x{screen_height}")

鼠标控制完整指南

移动与点击

import pyautogui
import time


class MouseController:
    """封装 pyautogui 鼠标操作,添加安全检查和日志"""

    def move_to(self, x: int, y: int, duration: float = 0.3):
        """移动鼠标到指定坐标"""
        pyautogui.moveTo(x, y, duration=duration)

    def left_click(self, x: int, y: int, wait_after: float = 0.3):
        """左键单击"""
        pyautogui.click(x, y, button='left')
        time.sleep(wait_after)

    def right_click(self, x: int, y: int):
        """右键单击(通常用于打开上下文菜单)"""
        pyautogui.click(x, y, button='right')
        time.sleep(0.3)

    def double_click(self, x: int, y: int):
        """双击(打开文件/激活输入框等)"""
        pyautogui.doubleClick(x, y)
        time.sleep(0.5)  # 双击后等待更长时间

    def drag(
        self,
        start_x: int, start_y: int,
        end_x: int, end_y: int,
        duration: float = 0.5
    ):
        """拖拽操作"""
        pyautogui.drag(
            end_x - start_x,
            end_y - start_y,
            duration=duration,
            button='left'
        )
        time.sleep(0.3)

    def scroll(self, x: int, y: int, clicks: int, direction: str = "up"):
        """滚动(正数向上,负数向下)"""
        if direction == "down":
            clicks = -abs(clicks)
        else:
            clicks = abs(clicks)
        pyautogui.scroll(clicks, x=x, y=y)
        time.sleep(0.3)

键盘输入实现

文本输入与特殊按键

class KeyboardController:
    """键盘输入控制器"""

    # 特殊按键映射(Computer Use API → pyautogui)
    KEY_MAP = {
        "Return": "enter",
        "BackSpace": "backspace",
        "Delete": "delete",
        "Tab": "tab",
        "Escape": "escape",
        "Up": "up",
        "Down": "down",
        "Left": "left",
        "Right": "right",
        "Home": "home",
        "End": "end",
        "Page_Up": "pageup",
        "Page_Down": "pagedown",
        "F1": "f1",
        "ctrl+c": ["ctrl", "c"],
        "ctrl+v": ["ctrl", "v"],
        "ctrl+a": ["ctrl", "a"],
        "ctrl+z": ["ctrl", "z"],
        "ctrl+s": ["ctrl", "s"],
    }

    def press_key(self, key: str):
        """按下特殊键或组合键"""
        mapped = self.KEY_MAP.get(key, key)
        if isinstance(mapped, list):
            # 组合键
            pyautogui.hotkey(*mapped)
        elif '+' in key and key not in self.KEY_MAP:
            # 处理未预定义的组合键(如 "ctrl+shift+i")
            keys = key.lower().split('+')
            pyautogui.hotkey(*keys)
        else:
            pyautogui.press(mapped)
        time.sleep(0.1)

    def type_text(self, text: str, interval: float = 0.02):
        """输入文本(逐字符,支持中文)"""
        # pyautogui 的 typewrite 不支持中文
        # 对于中文和特殊字符,使用剪贴板
        if any(ord(c) > 127 for c in text):
            self._type_via_clipboard(text)
        else:
            pyautogui.typewrite(text, interval=interval)

    def _type_via_clipboard(self, text: str):
        """通过剪贴板输入文本(支持 Unicode)"""
        import pyperclip
        original = pyperclip.paste()  # 保存原始剪贴板内容
        try:
            pyperclip.copy(text)
            pyautogui.hotkey('ctrl', 'v')
            time.sleep(0.1)
        finally:
            pyperclip.copy(original)  # 恢复原始剪贴板内容

统一操作接口

将 Computer Use API 动作映射到操作

class ComputerController:
    """统一的计算机控制接口,对接 Computer Use API 的动作格式"""

    def __init__(self):
        self.mouse = MouseController()
        self.keyboard = KeyboardController()
        self.screenshot = ScreenshotTool()

    def execute_action(self, action: str, **params) -> dict:
        """执行 Computer Use API 的动作,返回结果字典"""
        if action == "screenshot":
            img_bytes = self.screenshot.take_screenshot()
            return {
                "type": "image",
                "source": {
                    "type": "base64",
                    "media_type": "image/jpeg",
                    "data": base64.b64encode(img_bytes).decode()
                }
            }
        elif action == "left_click":
            x, y = params["coordinate"]
            self.mouse.left_click(x, y)
            return {"type": "text", "text": f"Left clicked at ({x}, {y})"}
        elif action == "right_click":
            x, y = params["coordinate"]
            self.mouse.right_click(x, y)
            return {"type": "text", "text": f"Right clicked at ({x}, {y})"}
        elif action == "double_click":
            x, y = params["coordinate"]
            self.mouse.double_click(x, y)
            return {"type": "text", "text": f"Double clicked at ({x}, {y})"}
        elif action == "type":
            text = params["text"]
            self.keyboard.type_text(text)
            return {"type": "text", "text": f"Typed: {repr(text)}"}
        elif action == "key":
            key = params["text"]
            self.keyboard.press_key(key)
            return {"type": "text", "text": f"Pressed key: {key}"}
        elif action == "scroll":
            x, y = params["coordinate"]
            direction = params.get("direction", "up")
            amount = params.get("amount", 3)
            self.mouse.scroll(x, y, amount, direction)
            return {"type": "text", "text": f"Scrolled {direction} at ({x}, {y})"}
        else:
            raise ValueError(f"Unknown action: {action}")

操作等待策略

防止操作过快导致失败

AI Agent 执行操作的速度往往比人类快很多,这会导致一些问题:按钮还没加载完就被点击、文本框还没聚焦就开始输入、页面还没跳转就执行下一步操作。

import time
from typing import Callable


def wait_for_stable_screen(
    screenshot_fn: Callable,
    timeout: float = 5.0,
    stable_duration: float = 0.5,
    check_interval: float = 0.2
) -> bool:
    """
    等待屏幕内容稳定(不再变化)。

    适用场景:
    - 页面加载后等待渲染完成
    - 点击后等待动画结束
    - 弹窗出现后等待内容加载

    Returns:
        True 如果屏幕已稳定,False 如果超时
    """
    start_time = time.time()
    last_screenshot = None
    stable_start = None

    while time.time() - start_time < timeout:
        current = screenshot_fn()
        current_hash = hashlib.md5(current).hexdigest()

        if last_screenshot == current_hash:
            if stable_start is None:
                stable_start = time.time()
            elif time.time() - stable_start >= stable_duration:
                return True
        else:
            stable_start = None
            last_screenshot = current_hash

        time.sleep(check_interval)

    return False  # 超时


# 使用示例
controller = ComputerController()
controller.mouse.left_click(960, 540)  # 点击某按钮
wait_for_stable_screen(controller.screenshot.take_screenshot)  # 等待页面稳定
FAILSAFE 保护

始终保持 pyautogui.FAILSAFE = True(这是默认值)。当脚本失控时,将鼠标迅速移到屏幕左上角(0, 0)会触发 FailSafeException,立即中止脚本。在测试阶段,这个功能可能救你于水火之中。

深入理解坐标系统

屏幕坐标系基础

在操作系统中,屏幕坐标系的原点(0, 0)在屏幕左上角,X 轴向右增大,Y 轴向下增大。这与数学坐标系不同(数学坐标系 Y 轴向上增大)。理解这一点对于正确计算点击位置至关重要:

屏幕坐标系示意图(1920×1080 屏幕) (0,0)───────────────────────── X → │ 左上角(0,0) 中间(960,0) 右上角(1920,0) │ │ 左中(0,540) 中心(960,540) │ │ 左下(0,1080) 右下(1920,1080) ↓ Y 特殊位置: - Taskbar (Windows 底部):约 y = 1050-1080 - macOS Dock (底部):约 y = 950-1080 - 浏览器地址栏:约 y = 40-80(取决于浏览器) - 标签栏:约 y = 0-40

鼠标加速度与 duration 参数

pyautogui 的 moveToclick 函数有 duration 参数,控制鼠标移动的速度。这个参数有微妙的影响:

duration = 0(瞬移)
鼠标立即跳到目标位置。对于大多数 GUI 交互没问题,但某些应用(如游戏、拖拽操作)需要鼠标移动轨迹,瞬移会导致动作失败。
duration = 0.1~0.3(快速移动)
推荐值。既足够快,又模拟真实鼠标移动路径(Bezier 曲线),适合需要悬停触发的 UI 元素。
duration > 0.5(慢速移动)
用于精确拖拽(resize 窗口、画布绘制等)。慢速移动让应用有时间响应鼠标位置变化。

处理高 DPI 屏幕的坐标偏差

import sys
import pyautogui


def get_scale_factor() -> float:
    """
    获取当前显示器的缩放因子。
    macOS Retina 屏的缩放因子为 2.0,Windows 125% 缩放为 1.25。
    pyautogui 在 macOS 上返回的是逻辑坐标(已除以 DPR),
    但在某些版本中可能需要手动处理。
    """
    if sys.platform == "darwin":
        try:
            import AppKit
            screen = AppKit.NSScreen.mainScreen()
            # backingScaleFactor 就是 DPR(1.0 或 2.0)
            return float(screen.backingScaleFactor())
        except ImportError:
            return 2.0  # macOS 大多数是 Retina 屏
    elif sys.platform == "win32":
        import ctypes
        # 获取 Windows 的 DPI 感知值
        user32 = ctypes.windll.user32
        user32.SetProcessDPIAware()
        hdc = user32.GetDC(0)
        gdi32 = ctypes.windll.gdi32
        logical_dpi = gdi32.GetDeviceCaps(hdc, 88)  # LOGPIXELSX
        user32.ReleaseDC(0, hdc)
        return logical_dpi / 96.0  # Windows 标准 DPI 为 96
    return 1.0  # Linux 默认


class DPIAwareController:
    """
    考虑 DPI 缩放的鼠标控制器。
    关键原则:Claude 的坐标总是基于它看到的截图坐标。
    如果截图已经缩放到逻辑分辨率,那么坐标直接使用即可。
    如果截图是物理分辨率,则需要除以 DPR 才能得到鼠标坐标。
    """

    def __init__(self, screenshot_is_logical: bool = True):
        """
        Args:
            screenshot_is_logical: True 表示截图已缩放到逻辑分辨率,
                                   False 表示截图是物理分辨率
        """
        self.dpr = get_scale_factor()
        self.screenshot_is_logical = screenshot_is_logical

    def click(self, claude_x: int, claude_y: int):
        """
        根据 Claude 返回的坐标执行点击。
        如果截图是逻辑分辨率,坐标直接用(pyautogui 自动处理 DPR)。
        如果截图是物理分辨率,坐标需要除以 DPR。
        """
        if self.screenshot_is_logical:
            # 截图是逻辑分辨率,坐标直接匹配 pyautogui 坐标系
            pyautogui.click(claude_x, claude_y)
        else:
            # 截图是物理分辨率,需要转换
            logical_x = claude_x / self.dpr
            logical_y = claude_y / self.dpr
            pyautogui.click(logical_x, logical_y)

键盘输入的字符集问题

为什么不能直接用 typewrite 输入中文

pyautogui 的 typewrite() 函数在底层是通过模拟按键(keydown/keyup 事件)实现的。键盘上的每个物理按键对应一个 keycode,typewrite 只能处理有对应 keycode 的字符(ASCII 范围)。中文、日文、emoji 等 Unicode 字符没有对应的物理按键,因此无法直接模拟。

方案1:剪贴板中转(推荐)
将文本复制到剪贴板,再模拟 Ctrl+V 粘贴。速度快,支持所有 Unicode 字符,但会污染剪贴板(需要备份恢复)。
方案2:输入法 API(macOS)
macOS 上可以通过 NSEventosascript 直接注入 Unicode 字符,不依赖剪贴板。但实现较复杂。
方案3:xdotool(Linux)
Linux 下可以使用 xdotool type --clearmodifiers "中文内容" 命令,支持 Unicode 输入。配合 subprocess 使用。
import pyperclip
import pyautogui
import time
import sys


def type_unicode_safe(text: str, restore_clipboard: bool = True):
    """
    安全输入 Unicode 文本(包括中文)。
    使用剪贴板中转,输入后恢复原剪贴板内容。

    Args:
        text: 要输入的文本
        restore_clipboard: 是否在输入后恢复原剪贴板内容
    """
    original_clipboard = ""
    if restore_clipboard:
        try:
            original_clipboard = pyperclip.paste()
        except:
            pass

    try:
        # 复制文本到剪贴板
        pyperclip.copy(text)
        time.sleep(0.05)  # 等待剪贴板操作完成

        # 粘贴
        if sys.platform == "darwin":
            pyautogui.hotkey("command", "v")  # macOS 用 Cmd+V
        else:
            pyautogui.hotkey("ctrl", "v")    # Windows/Linux 用 Ctrl+V

        time.sleep(0.1)
    finally:
        if restore_clipboard and original_clipboard:
            # 恢复原剪贴板内容(延迟执行,避免覆盖刚粘贴的内容)
            time.sleep(0.2)
            pyperclip.copy(original_clipboard)

# Linux 使用 xdotool 输入 Unicode 的方案
import subprocess

def type_via_xdotool(text: str):
    """仅适用于 Linux X11 环境"""
    subprocess.run(
        ["xdotool", "type", "--clearmodifiers", "--delay", "50", text],
        check=True
    )

操作的原子性与事务性

复合操作的设计原则

某些 GUI 操作需要多个低级操作组合才能完成(如"清空文本框并输入新内容")。设计复合操作时应遵循原子性原则——要么完全成功,要么能够恢复:

class AtomicInputController:
    """原子性输入控制器,确保操作的完整性"""

    def clear_and_type(
        self,
        x: int, y: int,
        text: str
    ) -> bool:
        """
        清空输入框并输入新内容。
        步骤:点击 → 全选 → 删除 → 输入
        这是一个复合操作,应尽量原子化执行。

        Returns:
            True 表示操作成功
        """
        try:
            # 1. 点击输入框(聚焦)
            pyautogui.click(x, y)
            time.sleep(0.1)

            # 2. 全选(跨平台处理)
            if sys.platform == "darwin":
                pyautogui.hotkey("command", "a")
            else:
                pyautogui.hotkey("ctrl", "a")
            time.sleep(0.05)

            # 3. 删除选中内容
            pyautogui.press("delete")
            time.sleep(0.05)

            # 4. 输入新内容(自动处理 Unicode)
            type_unicode_safe(text)
            return True

        except Exception as e:
            print(f"clear_and_type 失败: {e}")
            return False

    def select_all_text_in_field(self, x: int, y: int) -> str:
        """
        选中输入框中的所有文本并复制到剪贴板,返回文本内容。
        用于"读取"当前输入框的值。
        """
        pyautogui.click(x, y)
        time.sleep(0.1)
        if sys.platform == "darwin":
            pyautogui.hotkey("command", "a")
            pyautogui.hotkey("command", "c")
        else:
            pyautogui.hotkey("ctrl", "a")
            pyautogui.hotkey("ctrl", "c")
        time.sleep(0.1)
        return pyperclip.paste()
章节小结

本章系统讲解了鼠标和键盘控制的实现细节。关键要点: