はじめに
実際に人間が行っているマウス操作を記録し、その操作を後からRPAを使い自動的に再現できるようにしたいと思いました。
そこで、Pythonのmouseライブラリを使用して、マウス操作を記録するプログラムを作成しました。
最初は、記録した操作からそのままmouseやPyAutoGUIのコードを生成する方法を考えていました。
しかし、よく考えると、
「操作を記録する部分」と「記録した操作をRPAとして実行する部分」は分離した方がよい
と考えるようになりました。
例えば、操作記録にmouseライブラリを使ったとしても、記録データまでmouseライブラリに依存してしまうと、将来的にPyAutoGUIやpywinautoなど別のライブラリを使いたくなったときに扱いにくくなります。
そこで、以下のような構成を目指します。
人間のマウス操作
↓
mouseライブラリでイベントを解析
↓
RPAライブラリ非依存のactions.jsonを生成
↓
RPAライブラリごとにプログラムを生成
↓
PyAutoGUI / pywinauto ...で操作の再現
つまり、mouseライブラリはマウス操作を検知するためだけに使用し、記録した操作そのものはmouseライブラリに依存しないデータとして保存するという考え方です。
今回は、
「人間のマウス操作を正確に検知して、ライブラリ非依存のactionsを生成する」
ところまでを作成しました。
1. 使用するライブラリ
マウス操作の検知にはPythonのmouseライブラリを使用します。
pip install mouse
操作を実行するRPAライブラリについては、現時点ではmouseに限定しません。
最終的には、
- PyAutoGUI
- pywinauto
- その他のRPAライブラリ
などから選択できる構成を目指します。
2. 最終的なプログラム
現時点で使用しているマウス操作レコーダーのプログラムです。
import mouse
import time
import threading
import json
# =====================================
# 設定
# =====================================
# ダブルクリック判定時間
DOUBLE_CLICK_INTERVAL = 0.5
# ドラッグと判定する最小移動距離
DRAG_THRESHOLD = 10
# =====================================
# 操作データ
# =====================================
actions = []
# =====================================
# 状態管理
# =====================================
lock = threading.Lock()
# 直前の操作が終了した時刻
last_action_time = None
# 保留中のクリック
pending_click = None
# クリック確定用タイマー
pending_timer = None
# =====================================
# ドラッグ状態
# =====================================
drag_start_x = None
drag_start_y = None
drag_button = None
drag_start_time = None
dragging = False
# =====================================
# 操作ID
# =====================================
action_id = 0
# =====================================
# 操作データを追加
# =====================================
def add_action(action):
global action_id
global last_action_time
now = time.time()
# ---------------------------------
# 操作間隔
# ---------------------------------
if last_action_time is None:
delay = 0.0
else:
delay = now - last_action_time
# ---------------------------------
# ID
# ---------------------------------
action_id += 1
action["id"] = action_id
action["delay"] = round(delay, 3)
# ---------------------------------
# 保存
# ---------------------------------
actions.append(action)
last_action_time = now
# =====================================
# リアルタイム表示
# =====================================
def print_action(action):
action_type = action["type"]
# =================================
# Click
# =================================
if action_type == "click":
print(
f"[DETECT] "
f"{action['button'].upper()}_CLICK "
f"X={action['position']['x']}, "
f"Y={action['position']['y']}"
)
# =================================
# Double Click
# =================================
elif action_type == "double_click":
print(
f"[DETECT] "
f"DOUBLE_CLICK "
f"{action['button'].upper()} "
f"X={action['position']['x']}, "
f"Y={action['position']['y']}"
)
# =================================
# Scroll
# =================================
elif action_type == "scroll":
print(
f"[DETECT] "
f"SCROLL "
f"X={action['position']['x']}, "
f"Y={action['position']['y']} "
f"delta={action['delta']}"
)
# =================================
# Drag
# =================================
elif action_type == "drag":
print(
f"[DETECT] "
f"DRAG_{action['button'].upper()} "
f"START "
f"X={action['start']['x']}, "
f"Y={action['start']['y']} "
f"-> END "
f"X={action['end']['x']}, "
f"Y={action['end']['y']} "
f"duration={action['duration']:.3f}s"
)
# =====================================
# 通常クリックを確定
# =====================================
def confirm_click():
global pending_click
global pending_timer
with lock:
if pending_click is None:
return
click = pending_click
pending_click = None
pending_timer = None
# ---------------------------------
# actions作成
# ---------------------------------
action = {
"type": "click",
"button": click["button"],
"position": {
"x": click["x"],
"y": click["y"]
}
}
add_action(action)
print_action(action)
# =====================================
# マウスイベント処理
# =====================================
def on_event(event):
global pending_click
global pending_timer
global drag_start_x
global drag_start_y
global drag_button
global drag_start_time
global dragging
# =================================
# ボタンイベント
# =================================
if isinstance(event, mouse.ButtonEvent):
x, y = mouse.get_position()
# =================================
# DOWN
# =================================
if event.event_type == "down":
with lock:
# ---------------------------------
# ドラッグ開始位置
# ---------------------------------
drag_start_x = x
drag_start_y = y
drag_button = event.button
drag_start_time = time.time()
dragging = False
# =================================
# UP
# =================================
elif event.event_type == "up":
with lock:
if (
drag_start_x is not None
and drag_start_y is not None
and drag_button == event.button
):
# ---------------------------------
# 移動距離
# ---------------------------------
distance_x = abs(
x - drag_start_x
)
distance_y = abs(
y - drag_start_y
)
# =================================
# ドラッグ
# =================================
if (
distance_x >= DRAG_THRESHOLD
or distance_y >= DRAG_THRESHOLD
):
dragging = True
# 保留中クリックを破棄
pending_click = None
if pending_timer is not None:
pending_timer.cancel()
pending_timer = None
duration = (
time.time()
- drag_start_time
)
action = {
"type": "drag",
"button": event.button,
"start": {
"x": drag_start_x,
"y": drag_start_y
},
"end": {
"x": x,
"y": y
},
"duration": round(
duration,
3
)
}
add_action(action)
print_action(action)
# =================================
# クリック
# =================================
else:
# ---------------------------------
# 既存のクリック候補を破棄
# ---------------------------------
if pending_timer is not None:
pending_timer.cancel()
pending_timer = None
# ---------------------------------
# 新しいクリック候補
# ---------------------------------
pending_click = {
"button": event.button,
"x": x,
"y": y
}
# ---------------------------------
# 少し待って確定
# ---------------------------------
pending_timer = threading.Timer(
DOUBLE_CLICK_INTERVAL,
confirm_click
)
pending_timer.start()
# ---------------------------------
# ドラッグ状態リセット
# ---------------------------------
drag_start_x = None
drag_start_y = None
drag_button = None
drag_start_time = None
dragging = False
# =================================
# DOUBLE
# =================================
elif event.event_type == "double":
with lock:
# ---------------------------------
# 保留中クリックを破棄
# ---------------------------------
pending_click = None
if pending_timer is not None:
pending_timer.cancel()
pending_timer = None
# ---------------------------------
# ダブルクリック
# ---------------------------------
action = {
"type": "double_click",
"button": event.button,
"position": {
"x": x,
"y": y
}
}
add_action(action)
print_action(action)
# =================================
# マウス移動
# =================================
elif isinstance(event, mouse.MoveEvent):
with lock:
if drag_start_x is None:
return
x, y = mouse.get_position()
distance_x = abs(
x - drag_start_x
)
distance_y = abs(
y - drag_start_y
)
# ---------------------------------
# ドラッグ開始
# ---------------------------------
if (
distance_x >= DRAG_THRESHOLD
or distance_y >= DRAG_THRESHOLD
):
dragging = True
# クリック候補を破棄
pending_click = None
if pending_timer is not None:
pending_timer.cancel()
pending_timer = None
# =================================
# スクロール
# =================================
elif isinstance(event, mouse.WheelEvent):
x, y = mouse.get_position()
action = {
"type": "scroll",
"position": {
"x": x,
"y": y
},
"delta": event.delta
}
with lock:
add_action(action)
print_action(action)
# =====================================
# JSON保存
# =====================================
def save_actions():
data = {
"version": "1.0",
"actions": actions
}
with open(
"actions.json",
"w",
encoding="utf-8"
) as f:
json.dump(
data,
f,
indent=4,
ensure_ascii=False
)
# =====================================
# Summary
# =====================================
def print_summary():
print()
print("========================================")
print(" Summary")
print("========================================")
if not actions:
print("操作は記録されませんでした。")
return
for action in actions:
action_type = action["type"]
# ---------------------------------
# Click
# ---------------------------------
if action_type == "click":
print(
f"{action['id']:02d}. "
f"{action['button'].upper()}_CLICK "
f"X={action['position']['x']}, "
f"Y={action['position']['y']} "
f"delay={action['delay']:.3f}s"
)
# ---------------------------------
# Double Click
# ---------------------------------
elif action_type == "double_click":
print(
f"{action['id']:02d}. "
f"DOUBLE_CLICK "
f"{action['button'].upper()} "
f"X={action['position']['x']}, "
f"Y={action['position']['y']} "
f"delay={action['delay']:.3f}s"
)
# ---------------------------------
# Drag
# ---------------------------------
elif action_type == "drag":
print(
f"{action['id']:02d}. "
f"DRAG_{action['button'].upper()} "
f"START "
f"X={action['start']['x']}, "
f"Y={action['start']['y']} "
f"-> END "
f"X={action['end']['x']}, "
f"Y={action['end']['y']} "
f"duration={action['duration']:.3f}s "
f"delay={action['delay']:.3f}s"
)
# ---------------------------------
# Scroll
# ---------------------------------
elif action_type == "scroll":
print(
f"{action['id']:02d}. "
f"SCROLL "
f"X={action['position']['x']}, "
f"Y={action['position']['y']} "
f"delta={action['delta']} "
f"delay={action['delay']:.3f}s"
)
# =====================================
# メイン
# =====================================
print("========================================")
print(" マウス操作レコーダー")
print("========================================")
print("操作を開始してください")
print("終了する場合は Ctrl+C を押してください")
print()
mouse.hook(on_event)
try:
while True:
time.sleep(0.1)
except KeyboardInterrupt:
pass
finally:
mouse.unhook(on_event)
# ---------------------------------
# タイマー停止
# ---------------------------------
if pending_timer is not None:
pending_timer.cancel()
# ---------------------------------
# 保留中クリック確定
# ---------------------------------
confirm_click()
# =====================================
# Summary
# =====================================
print_summary()
# =====================================
# JSON保存
# =====================================
save_actions()
print()
print("actions.json に保存しました。")
print("========================================")
print(" 記録を終了しました")
print("========================================")
3. 最初に確認したこと
mouseライブラリでは、マウスイベントを取得できます。
例えば左クリックすると、
ButtonEvent ButtonEvent(event_type='down', button='left', time=...)
ButtonEvent ButtonEvent(event_type='up', button='left', time=...)
のようなイベントが取得できます。
右クリックなら、
ButtonEvent ButtonEvent(event_type='down', button='right', time=...)
ButtonEvent ButtonEvent(event_type='up', button='right', time=...)
となります。
スクロールでは、
WheelEvent(delta=-1.0, time=...)
のようなイベントが取得できます。
4. MoveEvent と ButtonEvent の違い
最初にイベント処理を実装したとき、
if event.event_type == 'down':
としてすべてのイベントを処理しようとしたところ、
AttributeError: 'MoveEvent' object has no attribute 'event_type'
というエラーが発生しました。
これはMoveEventにはevent_typeが存在しないためです。
そこで、イベントの種類を判定して処理するようにしました。
if isinstance(event, mouse.ButtonEvent):
...
elif isinstance(event, mouse.MoveEvent):
...
elif isinstance(event, mouse.WheelEvent):
...
このようにすることで、
- ボタン操作
- マウス移動
- スクロール
を分離して処理できます。
5. ダブルクリックの判定
ダブルクリックについては、最初は単純に、
- 同じボタン
- 同じような座標
- 一定時間以内
などの条件からダブルクリックを判定する方法を考えました。
しかし、実際に操作を記録してみると、ダブルクリックの検出タイミングや、通常クリックとの区別が問題になりました。
そこで、通常のクリックを一旦「保留」する方式にしました。
1回目のクリック
↓
クリック候補として保留
↓
一定時間待つ
↓
2回目のクリック?
↓ ↓
YES NO
↓ ↓
DOUBLE CLICK
一定時間内に2回目のクリックが来なければ、通常のクリックとして確定します。
これにより、
LEFT_CLICK
と、
DOUBLE_CLICK LEFT
をリアルタイムに区別できるようになりました。
現在の設定では、
DOUBLE_CLICK_INTERVAL = 0.5
としています。
この値は環境やマウス操作の速さによって調整できます。
6. ドラッグ&ドロップ
次にドラッグ操作を追加しました。
ドラッグでは、
Button DOWN
↓
マウス移動
↓
Button UP
というイベントが発生します。
これをそのまま保存するのではなく、意味のある1つの操作にまとめます。
例えば、
DRAG_LEFT
START X=155, Y=719
END X=163, Y=628
という操作に変換します。
さらに、
duration
として、ボタンを押してから離すまでの時間も記録します。
ドラッグと通常クリックを区別するため、一定以上の移動があった場合をドラッグとして扱います。
現在の設定では、
DRAG_THRESHOLD = 10
としています。
7. 現在検出できる操作
現在は以下の操作を検出できます。
| 操作 | type |
|---|---|
| 左クリック | click |
| 右クリック | click |
| 中クリック | click |
| ダブルクリック | double_click |
| スクロール | scroll |
| 左ドラッグ | drag |
| 右ドラッグ | drag |
| 中ドラッグ | drag |
例えば実際のテストでは、
[DETECT] LEFT_CLICK X=1528, Y=271
[DETECT] RIGHT_CLICK X=1528, Y=271
[DETECT] MIDDLE_CLICK X=1501, Y=269
[DETECT] DOUBLE_CLICK LEFT X=1500, Y=269
[DETECT] SCROLL X=1497, Y=269 delta=1.0
[DETECT] DRAG_LEFT START X=1497, Y=269 -> END X=1295, Y=388
[DETECT] DRAG_RIGHT START X=1295, Y=388 -> END X=1353, Y=552
[DETECT] DRAG_MIDDLE START X=1312, Y=498 -> END X=1435, Y=349
のように検出できました。
8. なぜ直接RPAコードを生成しないのか
当初は、記録した操作からそのまま、
mouse.click(100, 200)
のようなコードを生成することも考えていました。
しかし、この方式には問題があります。
例えば記録側がmouseライブラリに依存していると、
操作記録
↓
mouseコード
となります。
将来的にPyAutoGUIを使いたくなった場合、
操作記録
↓
mouseコード
↓
PyAutoGUIへ変換
という余計な変換が必要になります。
そこで、途中にライブラリ非依存の操作データを置くことにしました。
操作記録
↓
actions
↓
PyAutoGUI
または、
操作記録
↓
actions
↓
pywinauto
という構成です。
この設計にしておけば、操作記録部分を変更せずに、RPAの実行部分だけを変更できます。
9. ライブラリ非依存の actions
今回の設計では、人間の操作を「Action」という単位に変換します。
例えば、
左クリック
を、
{
"type": "click",
"button": "left",
"position": {
"x": 1528,
"y": 271
}
}
と表現します。
ここには、
mouse.ButtonEvent
mouse.MoveEvent
などのmouseライブラリ固有の情報は残しません。
つまり、
「どのライブラリで取得したか」ではなく「人間が何を操作したか」
を保存します。
これが今回の設計で最も重要な部分です。
10. actions.json の正式仕様
今後のRPAシステムの基礎となるため、actions.jsonの仕様をv1.0として定義しました。
基本構造は以下です。
{
"version": "1.0",
"actions": [
{
...
}
]
}
11. 共通項目
すべてのActionには以下の項目を持たせます。
| 項目 | 型 | 内容 |
|---|---|---|
id |
integer | 操作番号 |
type |
string | 操作種別 |
delay |
number | 前のActionから現在のActionまでの待ち時間 |
例えば、
{
"id": 1,
"type": "click",
"delay": 0.0
}
となります。
12. delay の意味
delayは、
直前のActionが完了してから、現在のActionが開始されるまでの時間
と定義します。
例えば、
LEFT_CLICK
↓ 2秒
DRAG
↓ 1秒
RIGHT_CLICK
の場合、
{
"id": 1,
"delay": 0.0
}
{
"id": 2,
"delay": 2.0
}
{
"id": 3,
"delay": 1.0
}
となります。
これにより、RPA実行側では、
time.sleep(2.0)
などに変換できます。
13. Click
クリックのActionは以下の形式です。
{
"id": 1,
"type": "click",
"button": "left",
"position": {
"x": 1528,
"y": 271
},
"delay": 0.0
}
buttonは、
left
right
middle
を使用します。
14. Double Click
ダブルクリックは、2つのクリックとして保存せず、1つのActionとして保存します。
{
"id": 4,
"type": "double_click",
"button": "left",
"position": {
"x": 1500,
"y": 269
},
"delay": 2.038
}
このようにしておけば、RPA生成側で、
double_click(...)
のような専用操作に変換できます。
15. Scroll
スクロールは以下の形式です。
{
"id": 5,
"type": "scroll",
"position": {
"x": 1497,
"y": 269
},
"delta": 1.0,
"delay": 3.418
}
連続したスクロールも、現時点では個別のActionとして保存します。
例えば、
SCROLL +1
SCROLL +1
SCROLL +1
SCROLL -1
を、
SCROLL +3
SCROLL -1
のように自動的にまとめることはしません。
これは、人間が実際に行った操作をできるだけそのまま残すためです。
16. Drag
ドラッグは以下の形式です。
{
"id": 14,
"type": "drag",
"button": "left",
"start": {
"x": 1497,
"y": 269
},
"end": {
"x": 1295,
"y": 388
},
"duration": 2.226,
"delay": 5.236
}
startはドラッグ開始位置、
endはドラッグ終了位置です。
durationは、
マウスボタンを押してから離すまでの時間
です。
17. delay と duration の違い
この2つは似ていますが、意味が違います。
Action 1
│
│ delay
↓
Action 2 : DRAG
│
│ duration
↓
Action 2 完了
つまり、
delay
= ActionとActionの間の待ち時間
duration
= ドラッグ操作そのものにかかった時間
です。
この2つを分けておくことで、RPA実行時に人間の操作に近い時間感覚を再現できます。
18. マウス移動はActionとして保存しない
通常のマウス移動については、
{
"type": "move"
}
というActionを作りません。
MoveEventは、
DOWN
↓
MoveEvent
↓
MoveEvent
↓
UP
からドラッグ操作を判定するために利用します。
通常のマウス移動をすべて保存するとデータ量が非常に多くなるためです。
19. DOWN / UPも保存しない
同様に、
Button DOWN
Button UP
という低レベルイベントもactions.jsonには保存しません。
例えば、
ButtonEvent(down)
ButtonEvent(up)
を、
{
"type": "click",
"button": "left",
"position": {
"x": 100,
"y": 200
}
}
という意味のあるActionに変換します。
つまり、actions.jsonはマウスドライバから取得した低レベルイベントではなく、**RPAで再現したい「意味のある操作」**を保存するデータです。
20. 座標
v1.0では、座標は画面全体に対する絶対座標とします。
"position": {
"x": 1528,
"y": 271
}
現時点では、
- ウィンドウ基準
- コントロール基準
- 相対座標
- 画像認識による位置補正
などは扱いません。
これらは将来的な拡張機能として考えます。
21. 画像認識について
RPAを安定させるためには、将来的には画像認識による待機処理も必要になると考えています。
例えば、
画面が表示される
↓
画像を検出
↓
クリック
という処理です。
また、マウス操作を検知したタイミングでスクリーンショットを取得しておけば、後から必要な部分だけをトリミングして待機画像として利用する方法も考えられます。
ただし、v1.0のactions.jsonには画像情報を入れません。
まずはマウス操作そのものを正確に記録することを優先します。
22. 現在の構成
現在は以下のような構成です。
人間の操作
↓
mouseライブラリ
↓
イベント取得
↓
イベント解析
↓
Action生成
↓
actions.json
重要なのは、
mouse
と、
actions.json
を分離していることです。
mouseは「入力を取得するためのライブラリ」であり、actions.jsonは「人間の操作を表現するデータ」です。
23. actions.json の役割
ここまで作ってきたことで、RPAシステムを以下のように分割できます。
┌──────────────────────┐
│ マウス操作レコーダー │
│ mouse │
└──────────┬───────────┘
↓
actions.json
↓
┌──────────┴───────────┐
│ RPA Generator │
├──────────────────────┤
│ PyAutoGUI Generator │
│ pywinauto Generator │
│ その他のGenerator │
└──────────────────────┘
これなら、操作を一度記録してしまえば、実行側のライブラリを変更できます。
24. 今後の構想
最終的には、
┌→ PyAutoGUI
│
人間の操作 → actions.json ─→ pywinauto
│
└→ その他のRPA
という構成を目指します。
例えば、
actions.json
│
├── PyAutoGUI Generator
│
├── pywinauto Generator
│
└── 将来追加するRPA Generator
という形です。
25. Action Validator
いきなりPyAutoGUIのコード生成に進むのではなく、次のステップとして、
actions.json
↓
Action Validator
↓
JSONの構文・仕様チェック
を作る予定です。
例えば、
- JSONが正しいか
-
versionが存在するか -
typeが正しいか -
clickにbuttonが存在するか - 座標が正しいか
-
dragにstart/end/durationが存在するか
などを検証します。
これにより、RPA Generator側は、
「入力された
actions.jsonは正しい」
という前提で処理できるようになります。
26. PyAutoGUI Generator
Action Validatorの次は、
actions.json
↓
PyAutoGUI Generator
↓
Pythonコード
という処理を作る予定です。
例えば、
{
"id": 1,
"type": "click",
"button": "left",
"position": {
"x": 100,
"y": 200
},
"delay": 0.0
}
から、
pyautogui.click(100, 200)
のようなコードを生成します。
ここで初めて、actions.jsonをPyAutoGUIという特定のRPAライブラリへ変換します。
27. 現在の段階
現時点では、
- 左クリック
- 右クリック
- 中クリック
- ダブルクリック
- スクロール
- 左ドラッグ
- 右ドラッグ
- 中ドラッグ
の検出ができています。
また、
- 操作間隔 →
delay - ドラッグ時間 →
duration - 操作番号 →
id
として記録しています。
そして、これらをRPAライブラリに依存しないactions.jsonとして保存できるようになりました。
まとめ
今回のポイントは、マウス操作を直接RPAコードに変換しないことです。
人間の操作
↓
イベント
↓
意味のあるAction
↓
actions.json
↓
RPAライブラリ
という中間層を設けることで、
「操作を記録する仕組み」と「操作を実行する仕組み」を分離する
ことができます。
これにより、
1回の操作記録
↓
actions.json
↓
┌────┴────┐
↓ ↓
PyAutoGUI pywinauto
のように、同じ操作データから複数のRPAライブラリ向けのコードを生成できる構成にできます。
現時点ではactions.jsonの仕様をv1.0として固定し、まずはこのデータ形式を安定させることを優先します。
今後は、
Step 1
マウス操作を記録
↓
Step 2
actions.jsonを検証
↓
Step 3
PyAutoGUIコードを生成
↓
Step 4
画像認識による待機処理
↓
Step 5
RPAとして実行
という形で発展させていく予定です。