pynputでGUI操作自動化をGUI操作ベースに実現する

# はじめに
## 概要
同じ操作を何回もする必要がある場合，PC操作を自動化する需要が生じます．しかしGUI操作の自動化スクリプトを組むにはクリックする位置を一々プログラムに起こす必要があり，かなりの労力を要します．そこで，PCのGUI操作を繰り返すようなプログラムを実現できることが望ましいです．本記事では，`pynput`というpythonのモジュールを用いてキーボード入力とマウス操作を監視し，操作を自動化する方法についてご紹介します．

## 謝辞
本記事のプログラムは[ChatGPT](https://chatgpt.com/)氏と共同で作成したものになります．この場を借りて，心よりお礼申し上げます．

## 開発環境
私の環境について書いておきます．別のOSでの動作確認はしていませんが，`pynput`が使えれば良いのでWindowsでも動くと思います．
```
OS: Ubuntu 22.04.4 LTS
Python: 3.10.10
```
必要なモジュールをインストールしておいてください．
```shell
pip install pynput
```

# PC操作を保存する
pynputモジュールを用いてキーボード操作・マウス操作を監視します．やっていることはシンプルで，それぞれの操作に対応するコールバックを定義し，その中で操作の情報（マウス位置やキーボード種類，タイムスタンプなど）をディクショナリにし，保存するというものです．
```python
from pynput import mouse, keyboard
import json
import time
import os
import sys

def save(basename):
    # イベントを記録するリスト
    events = []
    start_time = time.time()  # 記録開始時刻

    # マウスイベントのコールバック
    def on_mouse_move(x, y):
        events.append({
            "type": "mouse_move",
            "x": x,
            "y": y,
            "timestamp": time.time() - start_time,
            "time_to_move": 0.0
        })

    def on_mouse_click(x, y, button, pressed):
        events.append({
            "type": "mouse_click",
            "x": x,
            "y": y,
            "button": str(button),
            "pressed": pressed,
            "timestamp": time.time() - start_time
        })

    def on_mouse_scroll(x, y, dx, dy):
        events.append({
            "type": "mouse_scroll",
            "x": x,
            "y": y,
            "dx": dx,
            "dy": dy,
            "timestamp": time.time() - start_time
        })

    # キーボードイベントのコールバック
    def on_key_press(key):
        if key == keyboard.Key.esc:
            stop_flag = True
            return False
        else:
            events.append({
                "type": "key_press",
                "key": str(key), # ここをstrにするとecsのような特殊キーに対応できないので変更する
                "timestamp": time.time() - start_time
            })

    def on_key_release(key):
        events.append({
            "type": "key_release",
            "key": str(key),
            "timestamp": time.time() - start_time
        })

    # リスナーを起動
    with mouse.Listener(on_move=on_mouse_move, on_click=on_mouse_click, on_scroll=on_mouse_scroll) as mouse_listener, \
        keyboard.Listener(on_press=on_key_press, on_release=on_key_release) as keyboard_listener:
        print("Recording... Press 'Esc' to stop.")
        keyboard_listener.join()

    # 結果を保存
    save_path = os.path.join("log", basename)
    if not save_path.endswith(".json"):
        save_path = save_path + ".json"
    os.makedirs(os.path.dirname(save_path), exist_ok=True)
    with open(save_path, "w") as f:
        json.dump(events, f, indent=2)

    print(f"Recording finished and saved to {save_path}")

if __name__ == '__main__':
    save(sys.argv[1])
```

# ログを読み出してGUI操作を再生する
保存しておいたjsonファイルから情報を読み出して，順番に再生します．再生する部分はif文で分岐を作ってtypeごとに対応する関数を呼び出しています．また，Escキーを押すことで中断する仕様にしています．
```python
import json
import time
import sys
from pynput.mouse import Controller as MouseController, Button
from pynput.keyboard import Controller as KeyboardController, Key, KeyCode, Listener

# デバイスコントローラーの初期化
mouse = MouseController()
keyboard = KeyboardController()

stop_flag = False

def get_key_from_str(key_str):
    if key_str.startswith('<') and key_str.endswith('>'):
        # 数値をキーコードとして解釈
        key_code = int(key_str.strip('<>'))
        return KeyCode.from_vk(key_code)  # キーコードをKeyCodeオブジェクトに変換
    return eval(key_str)

def on_press(key):
    global stop_flag
    if key == Key.esc:
        print("Esc key is pressed, stopping...")
        stop_flag = True
        return False  # Listenerを停止

def play(json_path="log.json"):
    global stop_flag
    if stop_flag:
        return

    # 記録したイベントを読み込む
    with open(json_path, "r") as f:
        events = json.load(f)

    # イベントを再生
    start_time = time.time()
    
    # キー入力の監視を開始
    with Listener(on_press=on_press) as listener:
        for event in events:
            # stop_flagが立ったら再生を中止
            if stop_flag:
                print("Playback stopped due to Esc key press.")
                listener.stop()
                return
            
            # 次のイベントまでの待機時間を計算
            current_time = time.time() - start_time
            wait_time = event["timestamp"] - current_time
            if wait_time > 0:
                time.sleep(wait_time)

            print(event)
            # イベントを再生
            if event["type"] == "mouse_move":
                mouse.position = (event["x"], event["y"])  # time_to_move だけの時間をかけて移動する
            elif event["type"] == "mouse_click":
                button = Button.left if event["button"] == "Button.left" else Button.right
                if event["pressed"]:
                    mouse.press(button)
                else:
                    mouse.release(button)
            elif event["type"] == "mouse_scroll":
                mouse.scroll(event["dx"], event["dy"])
            elif event["type"] == "key_press":
                key = get_key_from_str(event["key"])  # evalでキーオブジェクトを復元
                keyboard.press(key)
            elif event["type"] == "key_release":
                key = get_key_from_str(event["key"])
                keyboard.release(key)
            else:
                raise NotImplementedError(f'{event["type"]=}')
    
    print("Replay finished!")

if __name__ == '__main__':
    for json_path in sys.argv[1:]:
        print(f"{json_path=}")
        play(json_path)
```

# カスタムする
スプレッドシートの操作で入力文字列を変えたい場合など，操作を繰り返すたびに少し動作を変えたい場合があると思います．その場合は再生の部分のスクリプトを変更することができます．
色々と実装を考えたのですが，jsonを直接いじるのは面倒です．そこで，はじめに操作するときにいくつかのjsonファイルに分割しておくことにしましょう．つまり，以下のようになります．
```python
import pyperclip

from replay import play # 先程定義した関数

if __name__ == '__main__':
    moji_list = ['aaa', 'bbb', 'ccc']
    for moji in moji_list:
        pyperclip.copy('hogehoge')
        play('log/step0.json')
        pyperclip.copy(moji)
        play('log/step1.json')
```

# 注意点
本記事で提供するプログラムはas isの状態で提供されていますため，利用する場合は自己責任にてお願いいたします．万が一バグがございましたらコメント欄にてご指摘いただけますと幸いです．
また，今回のプログラムは画面を一切見ないでキーボード操作・マウス操作を真似するだけなので，意図した通りに自動化するには少し工夫が要ります．考えうる主要な要素を列挙しますので，参考にしてください．（こちらで網羅できている保証はありませんので，悪しからず）
- 再生開始時には操作開始時とPCの状態を揃えておく
    - キーボードの半角/全角，開いているアプリ，などなど
    - また，操作終了時にも開始時と揃うようにしておくと，反復実行がしやすくなります
- GUI操作よりもショートカットキーを用いる
    - タブ指定の位置がずれることがあります
- RTAしない
    - 操作遅延が毎回一定とは限らないため，操作が先行してしまうと意図せぬ挙動を引き起こします
- 現状分かっているバグ
    - Cmd+数字によるアプリ切り替えはうまく動かないようです．pynputの問題か，このプログラムの問題かは不明です．

# おわりに
正直，こんなプログラム組むより根気よく繰り返したほうが楽だった気がします．