1分の時短のために1日かけてRPAツールを作った話（PySimpleGUI + PyAutoGUI）

## あらまし

この記事はAdvent Calendar 2020 NTTドコモ R&D 控え室、15日目の記事です。
業務効率化のため多くの企業でRPA導入が進んでおり、RPA人材の需要が高まっているという噂を聞きました。
私もRPA人材になりたいので、手始めに自分で作ってみます。

## 経緯
- 最近のPCゲームは起動からプレイまで1分くらいかかるので辛い
    - ゲーム起動
    - オープニングムービのスキップ
    - 「続きから」を選択
    - ロードするセーブデータを選択
    - ...
- この操作を記録してワンクリックで再現してくれるプログラムを作った
    - 飲み物を用意してる間にプレイ画面まで進んでいて快適
- Pythonでアプリ作る方法の勉強をしたかったので1日かけてツール化してみた

## この記事は何？
- Pythonでマウスやキーボードの操作をコントロールできる**PyAutoGUI**というライブラリの紹介です
- Pythonで簡単にPC向けアプリが作れる**PySimpleGUI**というライブラリの紹介です
- 上記2ライブラリを組み合わせた実装例の紹介です

## RPA（Robotic Process Automation）とは

PCの単純作業に対してマウスやキーボードの操作を定義しておくと、次からは自動でプログラムが操作を代行してくれるものです。

マウスやキーボードの操作の定義はシナリオと呼ばれます。

例えばエクセルファイルをPDF化して印刷する、という作業を自動化したい場合、下記の個々の操作を定義しておけば次からはシナリオを選択するだけで印刷まで行ってくれます。

![what_is_rpa.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320446/75488c1f-4895-15cc-c66d-d959ef985dda.png)

## PySimpleGUIとは

PythonでGUIアプリを作るためのライブラリです。

他にもTkinter, PyQt, Kivy, wxPythonなどGUIアプリ用ライブラリはたくさんありますが、**とりあえず動かすまでの手軽さ**と**日本語ドキュメントの豊富さ**を考えると、今から勉強するならPySimpleGUIは最有力な選択肢だと思います。

日本語のテキストとしては[こちらの記事](https://qiita.com/KatsunoriNakamura/items/376da645e52f7ef7f9ef)、チュートリアルとしては[こちらの記事](https://qiita.com/dario_okazaki/items/656de21cab5c81cabe59)や[こちらの記事](https://qiita.com/konitech913/items/61dc715ddaad54505a29)がオススメです。

[Kivyを使ってお絵かきアプリを作る記事](https://qiita.com/dcm_fukushima/items/75f1bb7204470181a16d)を去年は投稿したのですが、同じものを[PySimpleGUIを使って実装した記事](https://qiita.com/dario_okazaki/items/59e48444c85e8516123e)を投稿頂きました。コードを比較してみると、PySimpleGUIの方が単純なコードの行数で3割ほど短く書けています。



## PyAutoGUIとは

Pythonにマウスやキーボード操作を行わせるためのライブラリです。

人間がPC上で行う操作の大半に対応しており、チュートリアルとしては[こちらの記事](https://qiita.com/hirohiro77/items/78e26a59c2e45a0fe4e3)がオススメです。



## RPAツールの機能検討

PCの単純作業を分解してみると、下記の4つの操作の組み合わせで大半の作業は実施できそうです。

これらの4つの機能を繋げてシナリオが作れるRPAツールを作ってみようと思います。

1. マウス：指定した座標のクリック（クリック位置が固定である場合）
2. マウス：指定した画像のクリック（アイコン等のクリックを指定する場合）
3. キーボード：指定キーの押下
4. キーボード：文字列の入力

![shiyou.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320446/e042503a-a035-7977-cd06-c0172a9ef383.png)


UIはシンプルに、上画面で次に行う操作の設定と追加、下画面に現在のシナリオを表示する作りにします。

![ui.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320446/df1e6f23-fef0-8a21-eaed-488087c0b717.png)


## 準備体操：座標指定クリックだけが出来るRPAツールを作ってみる

座標指定してクリックできる機能さえ実装できれば、残りの機能はその拡張で実現できそうです。まずは座標指定のクリック機能だけを持つRPAツールを実装してみます。

ソースコードは下記の通りです。約90行で実装できました。

PySimpleGUIを使うのは初めてだったのですが、かなり直感的に作れるライブラリでした。

全体の構成は先ほど紹介した記事のコードをかなり参考にしており、RPAツールを実現するために変更した主な点は下記です。

- UI部分はレイアウト用に用意したリスト（layout）に上から順に表示したい機能を追加していく

- シナリオの記憶と画面表示用にリスト（def_data）にユーザが設定した操作を追加していき、PySimpleGUIのTableを用いて画面表示する

```python
import time

import PySimpleGUI as sg
import pyautogui as ag

def add_data_to_table(a_data, def_data, window):
    if def_data == [['' for _ in range(len(table_cols))]]:
        def_data = [a_data]
    else:
        def_data.append(a_data)
    window['-TABLE-'].update(values = def_data)
    return def_data

# テーマカラーの決定
sg.theme("DarkBlue3")

# シナリオを記憶しておくテーブルの定義
table_cols = ['       動作名       ', 'クリック位置    ', '     クリック画像     ', '   入力キー   ', '      入力文字      ', '  待機秒数  ']
cols_width = [100 for _ in range(len(table_cols))]
def_data = [['' for _ in range(len(table_cols))]]
table_style = {
    'values': def_data,
    'headings': table_cols,
    'max_col_width': 1,
    'def_col_width': cols_width,
    'num_rows': 15,
    'auto_size_columns': True,
    'key': '-TABLE-'
}

# 座標クリック、画像クリック、キー入力、文字入力の4つを追加
layout = []

# 座標クリック
layout.append([sg.Text("座標クリックの追加", background_color="#404080")])
layout.append([sg.Text("x座標"), sg.InputText("", size=(4,1), key="click_x"), sg.Text("y座標"), sg.InputText("", size=(4,1), key="click_y")])
layout.append([sg.Checkbox("右クリック", key="right_click_1"), sg.Checkbox("ダブルクリック", key="double_click_1")])
layout.append([sg.Text("待機秒数"), sg.InputText("2", size=(4,1), key="wait_1"), sg.Text("sec")])
layout.append([sg.Button('シナリオへ追加', key="add_1")])
layout.append([sg.HorizontalSeparator()])

# シナリオの一覧表を追加
layout.append([sg.Table(**table_style)])

# シナリオ実行ボタンの追加
layout.append([sg.Submit(button_text = '実行')])

# ウィンドウを作成
window = sg.Window('なんちゃってRPAツール', layout)

# イベントループ
while True:
    event, values = window.read()

    # 閉じるボタンが押されたとき
    if event is None:
        print('exit')
        break

    # 座標クリックを追加するとき
    if event == "add_1":
        # 右クリックかどうか、ダブルクリックかどうかの判定
        click_type = ""
        if values["double_click_1"] == True:
            click_type += "double_"
        if values["right_click_1"] == True:
            click_type += "right_"
        click_type += "click_pos"

        # テーブルへのシナリオ追加
        a_data = [click_type, (int(values['click_x']), int(values['click_y'])), "-", "-", "-", int(values['wait_1'])]
        def_data = add_data_to_table(a_data, def_data, window)

    # シナリオの実行ボタンが押されたとき
    if event == '実行':
        for a_data in def_data:
            click_type, (click_x, click_y), click_img, input_key, type_key, wait_time = a_data

            # 座標クリックの実行
            if "pos" in click_type:
                click_button = "right" if "right" in click_type else "left"
                n_click = 2 if "double" in click_type else 1
                ag.click(x=click_x, y=click_y, button=click_button, clicks=n_click)
            time.sleep(wait_time)

        show_message = "Done!\n"
        print(show_message)
        sg.popup(show_message)

# ウィンドウの破棄と終了
window.close()
```

画面はこちらのようになりました。1つのクリック操作において指定する値は下記としています。

- 画面上のクリックする座標（画面左上を原点としてx座標、y座標）

- 右クリックを行うか否か、ダブルクリックを行うか否か

- クリック後、次の操作を行うまでに待機する秒数

シナリオへ追加を押すと、現在設定している操作が画面下部のテーブルに追加されます。

下記の例で実行を押すと、(300, 400)を左クリック→(600, 200)をダブル右クリック、(1000, 100)をダブル左クリック、の操作が行われます。

![result1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320446/910e6025-8075-5b41-74bb-42cc80e28002.png)

## 本番：フル機能を持ったRPAツールを作ってみる

上記のツールに下記の3機能を追加しました。

- 画像を指定し、画面上にその画像があればクリックする機能（PyAutoGUIの画像検索機能を利用）
- 指定したキーやホットキーを入力する機能
- 指定した文字列を入力する機能

キー入力と文字入力は同じでは、という考え方もあるのですが、操作をまとめるとどういうUIにするかが難しいこと、PyAutoGUIの機能としてキー入力と文字入力が分かれていることを踏まえて別機能にしています。

ソースコードは下記のようになりました。約180行で実装できました。

```python
import time

import PySimpleGUI as sg
import pyautogui as ag
import cv2

def add_data_to_table(a_data, def_data, window):
    if def_data == [['' for _ in range(len(table_cols))]]:
        def_data = [a_data]
    else:
        def_data.append(a_data)
    window['-TABLE-'].update(values = def_data)
    return def_data

# テーマカラーの決定
sg.theme("DarkBlue3")

# シナリオを記憶しておくテーブルの定義
table_cols = ['       動作名       ', 'クリック位置    ', '     クリック画像     ', '   入力キー   ', '      入力文字      ', '  待機秒数  ']
cols_width = [100 for _ in range(len(table_cols))]
def_data = [['' for _ in range(len(table_cols))]]
table_style = {
    'values': def_data,
    'headings': table_cols,
    'max_col_width': 1,
    'def_col_width': cols_width,
    'num_rows': 10,
    'auto_size_columns': True,
    'key': '-TABLE-'
}

# 座標クリック、画像クリック、キー入力、文字入力の4つを追加
layout = []

# 座標クリック
layout.append([sg.Text("座標クリックの追加", background_color="#404080")])
layout.append([sg.Text("x座標"), sg.InputText("", size=(4,1), key="click_x"), sg.Text("y座標"), sg.InputText("", size=(4,1), key="click_y")])
layout.append([sg.Checkbox("右クリック", key="right_click_1"), sg.Checkbox("ダブルクリック", key="double_click_1")])
layout.append([sg.Text("待機秒数"), sg.InputText("2", size=(4,1), key="wait_1"), sg.Text("sec")])
layout.append([sg.Button('シナリオへ追加', key="add_1")])
layout.append([sg.HorizontalSeparator()])

# 画像クリック
layout.append([sg.Text(f'画像クリックの追加', background_color='#404080')])
layout.append([sg.FileBrowse(f'画像', size=(10,1), key="fn_image"), sg.InputText('画像ファイルを選択してください')])
layout.append([sg.Checkbox("右クリック", key="right_click_2"), sg.Checkbox("ダブルクリック", key="double_click_2")])
layout.append([sg.Text("待機秒数"), sg.InputText("2", size=(4,1), key="wait_2"), sg.Text("sec")])
layout.append([sg.Button('シナリオへ追加', key="add_2")])
layout.append([sg.HorizontalSeparator()])

# キー入力
layout.append([sg.Text(f'キー入力の追加', background_color='#404080')])
layout.append([sg.OptionMenu(['', 'shift', 'ctrl', 'esc'], key="hotkey_input"), sg.Text("+"), sg.InputText("", size=(4,1), key="key_input")])
layout.append([sg.Text("待機秒数"), sg.InputText("2", size=(4,1), key="wait_3"), sg.Text("sec")])
layout.append([sg.Button('シナリオへ追加', key="add_3")])
layout.append([sg.HorizontalSeparator()])

# 文字入力
layout.append([sg.Text(f'文字入力の追加', background_color='#404080')])
layout.append([sg.Multiline("入力文字列をタイプ", size=(60,4), key="text_input")])
layout.append([sg.Text("待機秒数"), sg.InputText("2", size=(4,1), key="wait_4"), sg.Text("sec")])
layout.append([sg.Button('シナリオへ追加', key="add_4")])
layout.append([sg.HorizontalSeparator()])

# シナリオの一覧表を追加
layout.append([sg.Table(**table_style)])

# シナリオ実行ボタンの追加
layout.append([sg.Submit(button_text = '実行')])

# ウィンドウを作成
window = sg.Window('なんちゃってRPAツール', layout)

# イベントループ
while True:
    event, values = window.read()

    # 閉じるボタンが押されたとき
    if event is None:
        print('exit')
        break

    # 座標クリックの追加
    if event == "add_1":
        # 右クリックかどうか、ダブルクリックかどうかの判定
        click_type = ""
        if values["double_click_1"] == True:
            click_type += "double_"
        if values["right_click_1"] == True:
            click_type += "right_"
        click_type += "click_pos"

        # テーブルへのシナリオ追加
        a_data = [click_type, (int(values['click_x']), int(values['click_y'])), "-", "-", "-", int(values['wait_1'])]
        def_data = add_data_to_table(a_data, def_data, window)

    # 画像クリックの追加
    if event == "add_2":
        # 右クリックかどうか、ダブルクリックかどうかの判定
        click_type = ""
        if values["double_click_2"] == True:
            click_type += "double_"
        if values["right_click_2"] == True:
            click_type += "right_"
        click_type += "click_image"

        # テーブルへのシナリオ追加
        a_data = [click_type, "-", values['fn_image'], "-", "-", int(values['wait_2'])]
        def_data = add_data_to_table(a_data, def_data, window)

    # キー入力の追加
    if event == "add_3":
        # テーブルへのシナリオ追加
        if values['hotkey_input'] != "":
            key_input = f"{values['hotkey_input']} + {values['key_input']}"
        else:
            key_input = values['key_input']
        a_data = ["key_input", "-", "-", key_input, "-", int(values['wait_3'])]
        def_data = add_data_to_table(a_data, def_data, window)

    # テキスト入力の追加
    if event == "add_4":
        # テーブルへのシナリオ追加
        a_data = ["text_input", "-", "-", "-", values["text_input"], int(values['wait_4'])]
        def_data = add_data_to_table(a_data, def_data, window)

    # シナリオの実行ボタンが押されたとき
    if event == '実行':
        for a_data in def_data:
            operation, click, click_img, input_key, input_text, wait_time = a_data
            if len(click)==2:
                click_x, click_y = click
            if '+' in input_key:
                hotkey, key = input_key.split('+')
                hotkey = hotkey.strip()
                key = key.strip()
            else:
                hotkey = None
                key = input_key

            # 座標クリックの実行
            if "pos" in operation:
                click_button = "right" if "right" in operation else "left"
                n_click = 2 if "double" in operation else 1
                ag.click(click_x, click_y, button=click_button, clicks=n_click)

            # 画像クリックの実行
            if "image" in operation:
                click_button = "right" if "right" in operation else "left"
                n_click = 2 if "double" in operation else 1
                img = cv2.imread(click_img)
                if img is not None:
                    pos = ag.locateOnScreen(click_img, confidence=0.8)
                    if pos != None:
                        x, y = ag.center(pos)
                        ag.click(x, y, button=click_button, clicks=n_click)

            # キー入力の実行
            if operation == "key_input":
                if hotkey != None:
                    ag.hotkey(hotkey, key)
                else:
                    ag.press(key)

            # テキスト入力の実行
            if operation == "text_input":
                ag.typewrite(input_text)

            time.sleep(wait_time)

        show_message = "Done!\n"
        print(show_message)
        sg.popup(show_message)

# ウィンドウの破棄と終了
window.close()
```

画面はこちらのようになりました。追加した機能は下記のようになっています。

- 画像クリックでは、指定する画像ファイルを選択するボタン
- キー入力では、ホットキー（shift, ctrl等）とキーのペアを設定する機能
- 文字入力では、タイプする文字列を入力する機能

![ui2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320446/4566c33c-c3f3-5913-0cf6-e3f60a635587.png)


例えば、メモ帳を開いて「hello world!」と入力するシナリオだと下記のようになります。

1. Windowsキーを押す
2. 「notepad」と入力
3. エンターキーを押す
4. 「hello world!」と入力

![sample_full.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320446/74e169f5-0638-1317-828f-a1b35dd0c7ee.png)

### 本気でRPAツールとして使いたい場合の改善点

今回のコードをベースにちゃんとしたツールとしたい場合、下記のような修正点があります。

- 作ったシナリオを保存する機能の追加
- シナリオ内の操作の並び替えや削除機能の追加
- 例外処理（数字入力箇所に数字でない入力があった場合など）
- クリックの種類などを文字列で記録＆判定している部分の改善
- 文字入力の日本語対応
  - これはPyAutoGUIの文字入力機能を使わず、文字列をクリップボードに保存＆貼り付けで入力するように実装すれば出来そうです


## おわりに
PySimpleGUIとPyAutoGUIを組み合わせた記事が見つからなかったので、RPAツールの開発という題材で取り組んでみました。ドキュメントやサンプルコードが豊富なこともあり、ほとんど詰まらずに実装できました。

PySimpleGUIに関しては詳細を理解していない部分も多いので、より良い実装があると思います。気になる点ありましたらコメント頂けるとありがたいです。
（中身の理解が不十分でも動くものが作れるというのは、それはそれでPySimpleGUIの素晴らしい特徴だとも思います）

この2ライブラリの組み合わせにはまだまだ可能性がある気がしています。どちらもとっつきやすいライブラリなので、何かアイデアある方はぜひ実装して見せてください！