0%

Windows平台模拟键鼠输入

模拟键鼠输入 - Python

模拟键鼠输入在Python中多种不同的库,这里就用pynput库。

安装(pynput · PyPI):

1
pip install pynput

模拟键盘输入:

1
2
3
4
5
6
from pynput.keyboard import Controller


controller = Controller()
controller.press("f")
controller.release("f")

模拟鼠标输入:

1
2
3
4
5
6
7
from pynput import mouse


mouse_controller = mouse.Controller()
mouse_controller.position = position # 可以设定按压的位置,即把鼠标移动到position位置
mouse_controller.press(mouse.Button.left)
mouse_controller.release(mouse.Button.left)

监听键盘输入:

1
2
3
4
5
6
7
8
9
10
11
12
from pynput.keyboard import Key, Listener, Controller
from pynput.keyboard._win32 import KeyCode

def on_release(key):
if key == Key.pause:
running = False
print("pause")
elif key == KeyCode.from_char("A"):
print("A")

listener = Listener(on_release=on_release)
listener.start()

如果是ascii字符,则会直接输出一个字符;如果是shift、ctrl、pause这种特殊按键,则会输出一个Key.xxx

有了这些之后就可以结合手动信号量来写一个自动按键脚本了,比如下面的就是一个在原神中自动按f并且鼠标自动点对话按钮的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import time
from pynput.keyboard import Key, Listener, Controller
from pynput.keyboard._win32 import KeyCode
from pynput import mouse
import threading

controller = Controller()
mouse_controller = mouse.Controller()

flag = False
running = True
position = (1219, 720) # (1382, 807)
go_mouse_press = False
print('''
~: 开启自动按键 + 自动单击
=: 把当前位置设置为鼠标自动单击的位置
HOME: 按下~后会开启自动单击
PAUSE: 关闭程序
''')
print("start! flag=", flag, "mouse:", go_mouse_press, "position:", position)
# ~: 开启自动按键 + 自动单击
# =: 把当前位置设置为鼠标自动单击的位置
# HOME: 按下~后会开启自动单击
# PAUSE: 关闭程序
event = threading.Event()

def on_release(key):
global event
global flag
global position
global running
global go_mouse_press
if key == Key.pause:
running = False
print("stop!")
elif key == KeyCode.from_char("`"):
flag = not flag
if flag:
event.set()
else:
event.clear()
print("flag: ", flag)
elif key == Key.home:
go_mouse_press = not go_mouse_press
print("mouse: ", go_mouse_press)
elif key == KeyCode.from_char("="):
position = mouse_controller.position
print("new position: ", position)

listener = Listener(on_release=on_release)
listener.start()


while running:
while event.wait():
time.sleep(0.1)
controller.press("f")
controller.release("f")
if go_mouse_press:
mouse_controller.position = position
mouse_controller.press(mouse.Button.left)
mouse_controller.release(mouse.Button.left)

关于pynput

如果你试过用C#或者js的事件监听,你会发现如果当前页面失去焦点,那么键鼠的事件监听将会失效。但是python的pynput库则可以在python脚本程序最小化时也能成功监听,这是因为它在windows下是直接调用了win32 api的钩子函数监听了低级的键盘事件(SetWindowsHookExA function (winuser.h) - Win32 apps | Microsoft Learn)。

同理,它的模拟输入也是调用了SendInputSendInput function (winuser.h) - Win32 apps | Microsoft Learn)。

另外,pynput实现了多个操作系统的键鼠监听与模拟输入,因此并不仅局限于windows平台。(其他主流的python键鼠库也基本是这样的)

键鼠监听和模拟键鼠输入 - C#(user32.dll)

知道以上信息后,可以用C#调用user32.dll来做到键鼠输入的监听和模拟输出。

注意,SetWindowsHookEx监听只能在C#窗体应用中才能用,控制台应用不可使用。

监听步骤:

  1. 声明钩子函数SetWindowsHookEx和其需要的回调函数(SetWindowsHookExA function (winuser.h) - Win32 apps | Microsoft Learn

    1
    2
    3
    4
    5
    6
    // 定义名为LowLevelKeyboardProc的委托用于回调处理
    private delegate IntPtr LowLevelKeyboardProc
    LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);

    [DllImport("user32.dll")]
    private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc callback, IntPtr hInstance, uint threadId);

    在该函数中有4个参数:

  2. 用系统api GetModuleHandle获取当前进程实例句柄

    1
    2
    3
    4
    5
    [DllImport("kernel32.dll")]
    private static extern IntPtr GetModuleHandle(string lpModuleName);
    // 使用方法
    // using System.Diagnostics;
    // GetModuleHandle(Process.GetCurrentProcess().MainModule.ModuleName)
  3. 声明CallNextHookEx函数
    这个函数需要在回调函数中调用以将键盘事件传递下去。(CallNextHookEx function (winuser.h) - Win32 apps | Microsoft Learn

    1
    2
    [DllImport("user32.dll")]
    private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);

    其中:hhk可以忽略,这里的hhk原本指的是钩子的句柄,也就是SetWindowsHookEx的返回值。你可以直接传入一个空指针,也可以就按原意传入hook句柄。其他的参数只要把hook回到函数中的入参原原本本传进去即可。

  4. 使用MapVirtualKeyAVkCode转为char。(MapVirtualKeyA function (winuser.h) - Win32 apps | Microsoft Learn

    1
    2
    [DllImport("user32.dll")]
    public static extern IntPtr MapVirtualKeyA(IntPtr uCode, uint uMapType);

    这里的uMapType取2就是MAPVK_VK_TO_CHAR了。

  5. 编写回调函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
    {
    if (nCode >= 0 && wParam == (IntPtr)WM_KEYUP)
    {
    // 只响应监控WM_KEYUP事件
    int vkCode = Marshal.ReadInt32(lParam);
    // 根据文档可以知道,这个结构体第一个32位即虚拟码vkCode
    // 第二个32位即扫描码scanCode
    //int scanCode = Marshal.ReadInt32(lParam, 4); // 4表示偏移4个字节
    // 如果你想用扫描码,可以参考:https://learn.microsoft.com/en-us/previous-versions/visualstudio/visual-studio-6.0/aa299374(v=vs.60)?redirectedfrom=MSDN
    Trace.WriteLine(vkCode);

    // vkCode转char
    char c = (char)MapVirtualKeyA(vkCode, 2);
    Trace.WriteLine(c);
    // 如果不是字符,那么MapVirtualKeyA会返回0
    }
    // 这里的_hookID: IntPtr _hookID = SetWindowsHookEx(...)
    return CallNextHookEx(_hookID, nCode, wParam, lParam);
    }
  6. 取消钩子

    1
    2
    3
    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool UnhookWindowsHookEx(IntPtr hhk);

    hhk即上文提到的钩子句柄。

  7. 完整代码(忽略类)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    using System;
    using System.Collections.Generic;
    using System.Diagnostics;
    using System.Runtime.InteropServices;


    private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
    private static LowLevelKeyboardProc lowLevelKeyboardProc = mycallback; // 回调
    private const int WH_KEYBOARD_LL = 13;
    private const int WM_KEYUP = 0x0101;
    private static IntPtr _hookID = SetHook(mycallback); // 钩子句柄, SetHook定义在下面

    // 声明dll函数
    [DllImport("user32.dll")]
    private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc callback, IntPtr hInstance, uint threadId);

    [DllImport("kernel32.dll")]
    private static extern IntPtr GetModuleHandle(string lpModuleName);

    [DllImport("user32.dll")]
    private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode,
    IntPtr wParam, IntPtr lParam);

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool UnhookWindowsHookEx(IntPtr hhk);

    [DllImport("user32.dll")]
    public static extern IntPtr PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);

    [DllImport("user32.dll")]
    public static extern IntPtr MapVirtualKeyA(IntPtr uCode, uint uMapType);

    // 定义回调
    private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
    {
    if (nCode >= 0 && wParam == (IntPtr)WM_KEYUP)
    {
    // 只响应监控WM_KEYUP事件
    int vkCode = Marshal.ReadInt32(lParam);
    Trace.WriteLine(vkCode);

    // vkCode转char
    char c = (char)MapVirtualKeyA(vkCode, 2);
    Trace.WriteLine(c);
    // 如果不是字符,那么MapVirtualKeyA会返回0
    }
    return CallNextHookEx(_hookID, nCode, wParam, lParam);
    }

    // 注册钩子
    private static IntPtr SetHook(LowLevelKeyboardProc proc)
    {
    return SetWindowsHookEx(WH_KEYBOARD_LL,
    proc,
    GetModuleHandle(Process.GetCurrentProcess().MainModule.ModuleName),
    0);
    }

模拟输出步骤:

  1. 定义SendInput函数
  2. 将待模拟的输入转换为VkKey
    转VkKey有两种方式,一种是直接查表(Virtual-Key Codes (Winuser.h) - Win32 apps | Microsoft Learn),另一种是调用VkKeyScanAVkKeyScanA function (winuser.h) - Win32 apps | Microsoft Learn

代码参考(C# - SendInput(Windows API)によるキー入力のサンプル(32/64bit対応) #Win32API - Qiita

主要注意input结构体的构造。

使用PostMessageW模拟输入

PostMessage函数可以通过句柄直接把消息发到对应的窗口中,这样的好处是当你切屏时键盘输入不会乱按,而且对于有些程序来说,它在后台时也会处理PostMessage发送的消息,这样你就不需要一直让目标程序处于前台了

但是对于有些游戏来说,即使你用了PostMessage,它在后台时也不会处理输入的信息,甚至对于某些游戏即使它获得焦点的情况下仍然不会处理PostMessage发送的信息(比如星穹铁道)。

使用PostMessage会比SendInput更复杂一些。PostMessageW function (winuser.h) - Win32 apps | Microsoft Learn

  1. 定义PostMessageW函数

  2. 找到目标窗口的句柄(EnumWindows function (winuser.h) - Win32 apps | Microsoft Learn
    这里我的实现比较繁琐一些。在这里定义了一个Params结构体,它会被传入EnumWindows的迭代器中,我用Params来保存handler和title的array。其中的array_length存储两个array的长度,用Append函数添加句柄和标题。只有数组能传到dll中,所以这里没用List,而是手动扩容和维护数组有效长度。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    delegate bool EnumWindowsHandler(IntPtr HWND,ref  Params Lparam);
    static EnumWindowsHandler lpEnumFunc = EnumWindowsProc;

    private struct Params
    {
    public string window_title;
    public IntPtr[] target_handler_array = new IntPtr[2];
    public String[] title_array = new String[2];
    public int array_length = 0;

    public void Append(IntPtr handler, string title)
    {
    if (array_length < target_handler_array.Length)
    {
    target_handler_array[array_length] = handler;
    title_array[array_length] = title;
    }
    else
    {
    IntPtr[] new_handler_list = new IntPtr[target_handler_array.Length * 2];
    target_handler_array.CopyTo(new_handler_list, 0);
    target_handler_array = new_handler_list;
    target_handler_array[array_length] = handler;

    string[] new_title_array = new string[title_array.Length * 2];
    title_array.CopyTo(new_title_array, 0);
    title_array = new_title_array;
    title_array[array_length] = title;
    }
    array_length++;
    }

    public Params(string window_title)
    {
    this.window_title = window_title;
    }
    }

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    static extern bool EnumWindows(EnumWindowsHandler handler, ref Params lParam);

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    static extern int GetWindowTextW(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
    private static bool EnumWindowsProc(IntPtr HWND, ref Params LPARAM)
    {
    // 每次迭代都会调用proc处理,当return false时停止迭代
    StringBuilder window_name = new StringBuilder(100);

    // getwindowtextw会把HWND句柄的title名复制到window_name参数中
    int flag = GetWindowTextW(HWND, window_name, 100);

    if (LPARAM.window_title != "")
    {
    if (flag != 0 && window_name.ToString() == LPARAM.window_title)
    {
    LPARAM.Append(HWND, window_name.ToString());
    // 找到第一个就退出
    return false;
    }
    }
    else
    {
    // 如果window_title为"",说明要遍历所有进程
    if (flag != 0)
    {
    LPARAM.Append(HWND, window_name.ToString());
    }
    }
    return true;
    }

    // 之后调用这个函数即可获取句柄+对应窗口名的数组
    static public void GetWindowsHandlerByTitle(string window_title, out IntPtr[] handler_array, out string[] title_array) {
    Params lParam = new Params(window_title);
    EnumWindows(lpEnumFunc, ref lParam);
    handler_array = lParam.target_handler_array.Take(lParam.array_length).ToArray();
    title_array = lParam.title_array.Take(lParam.array_length).ToArray();
    }
  3. 决定输入事件(Msg)

  4. 将待模拟的输入转换为VkKey(wParam)

  5. 构造lparam(lParam)
    lParam对于不同的事件来说是不一样的。在这里用的是WM_KEYDOWNWM_KEYDOWN message (Winuser.h) - Win32 apps | Microsoft Learn)和WM_KEYUPWM_KEYUP message (Winuser.h) - Win32 apps | Microsoft Learn)事件。

如果你想用Python实现PostMessage,请参考Python开发游戏自动化脚本(四)后台键鼠操作 - 知乎 (zhihu.com)

如果你想用C#实现,请参考以下步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
[DllImport("user32.dll")]
public static extern IntPtr PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);

[DllImport("user32.dll")]
public static extern IntPtr VkKeyScanA(char ch); // 上文有文档

[DllImport("user32.dll")]
public static extern IntPtr MapVirtualKeyA(IntPtr uCode, uint uMapType); // 上文有文档

public static void testClick()
{
IntPtr[] handlers;
string[] titles;
// 见上面的步骤2 (这里向原神窗口发送)
WindowsHandler.GetWindowsHandlerByTitle("原神", out handlers, out titles);

// 直接取第1个搜索到的句柄
IntPtr handler = handlers[0];
// 输入字符f
IntPtr vk_code = VkKeyScanA('f');
// 获取扫描码
IntPtr scan_code = MapVirtualKeyA(vk_code, 0);
// 发送按键按下+抬起指令

// 根据文档 16-23位是scan_code, 0-15位表示重复次数, 这里或1表示设为1, 只发送1个
PostMessageW(handler, WM_KEYDOWN, vk_code, (scan_code << 16) | 1);
// 根据文档 16-23位是scan_code, 0-15位表示重复次数, 这里或1表示设为1, 只发送1个
// 第30和31位在WM_KEYUP中要求是1所以16进制设为C
PostMessageW(handler, WM_KEYUP, vk_code, (IntPtr)((scan_code << 16) | 0XC0000001));
}