株式銘柄紹介ブログ

主に日本の株式を紹介しています

PythonでWindowsアプリを自動操作する 「pywinautoを使ってみる」

はじめに

仕事で作業効率UPのためwindowsアプリの自動化を試みたところ、色々知見がたまったので備忘録的に記事にすることにしました。このブログの内容からは外れておりますがご容赦ください。


windowsアプリの自動化をするためにはUIオートメーションと呼ばれる仕組みを使えばいいのですが*1、これを直接使うのは私には少々敷居が高かったので諦めました*2。色々調べたところpythonでpywinautoというライブラリがあり、これを使えば結構簡単にやれそうだとわかったので今回はpywinautoというライブラリを用いました。


UIオートメーションについては以下が詳しいので興味があれば読んでみてください。

 
エクセルなどのオフィスソフト、Chromeなどのウェブブラウザの自動化はこの記事では扱っていません。これらに関してはVBAやseleniumを使ったほうが良いです。


今回はメモ帳の自動操作を例にpywinautoの使い方について説明していきます。本記事は以下のウェブページを参考に書いています。是非これらのページも読んでみてください。

 

インストール

pythonをインストール済みであれば、コマンドプロンプトにて以下のコマンドを実行すればpywinautoがインストールされます。
 

pip install pywinauto

 

実行環境

ソフトウェア version
OS Windows10
python 3.7.4
pywinauto 0.6.8

 

Accessibility Insightsをインストールする

pywinautoを使う前に導入しておいた方が良いツールがあるので紹介します。Accessibility Insightsを使うとUIオートメーションでアクセス可能な画面部品のツリー構造やプロパティ値を確認することが出来ます。


以下のページからwindows向けのインストーラーを入手可能です。インストーラー起動後、指示に従えば問題なくインストールが完了します。


Accessibility Insightsを起動すると以下の画面が表示されるので「Get Started」ボタンを押します。

 
以下はAccessibility Insightsのメイン画面です。左側に画面部品のツリー構造、右側に画面部品のプロパティ値が表示されています。


 

プロパティ表示設定を変更して値を持つすべてのプロパティを表示するようにしておくとよいでしょう。「Include all properties that have values」にチェックが入っていれば大丈夫です。


 

適当なアプリ上にカーソルを移動すると以下のように部品範囲が線で囲まれて、当該画面部品の情報がAccessibility Insightsのメイン画面に表示されます。


 

このままだとカーソルを移動した際に別の画面部品情報が表示されてしまうので、「Shift + F7」で情報取得を一時停止してください。再開したいときは再度「Shift + F7」を押せばよいです。
 

pywinautoの使い方

メモ帳を起動して画面構造を取得する

ソースコード

from pywinauto import Desktop
import subprocess
import time

subprocess.Popen(r'C:\WINDOWS\system32\notepad.exe', shell=True)

time.sleep(5)

app = Desktop(backend="uia")
for win in app.windows():
	print(win.texts())

root_win = app['無題 - メモ帳']
root_win.print_control_identifiers()

 
まず、「subprocess.Popen」でメモ帳を起動し、「app = Desktop(backend="uia")」でバックエンドがUIオートメーションである画面一覧を取得しています。
次に、「for win in app.windows(): win.texts()」で各画面のテキスト(タイトル)を取得しています。以下はその実行結果です。

['タスク バー']
['無題 - メモ帳']
['コマンド プロンプト - python  test.py']
['test.py - サクラエディタ32bit 2.4.2.6048  ']

 
メモ帳のタイトルが「無題 - メモ帳」だとわかったので、「root_win = app['無題 - メモ帳']」でメモ帳の最上位画面を取得します。最後に「root_win.print_control_identifiers()」でメモ帳の画面構造を取得しています。

以下は画面構造の取得結果です。

Dialog - '無題 - メモ帳'    (L1637, T80, R2619, B815)
['無題 - メモ帳Dialog', '無題 - メモ帳', 'Dialog']
child_window(title="無題 - メモ帳", control_type="Window")
   |
   | Edit - 'テキスト エディター'    (L1645, T131, R2611, B783)
   | ['Edit']
   | child_window(title="テキスト エディター", auto_id="15", control_type="Edit")
   |    |

(中略)

   |
   | TitleBar - ''    (L1661, T83, R2611, B111)
   | ['TitleBar']
   |    |
   |    | Menu - 'システム'    (L1645, T88, R1667, B110)
   |    | ['Menu', 'システムMenu', 'システム', 'システム0', 'システム1', 'Menu0', 'Menu1']
   |    | child_window(title="システム", auto_id="MenuBar", control_type="MenuBar")
   |    |    |
   |    |    | MenuItem - 'システム'    (L1645, T88, R1667, B110)
   |    |    | ['MenuItem', 'システム2', 'システムMenuItem', 'MenuItem0', 'MenuItem1']
   |    |    | child_window(title="システム", control_type="MenuItem")
   |    |
   |    | Button - '最小化'    (L2472, T81, R2519, B111)
   |    | ['最小化', 'Button5', '最小化Button']
   |    | child_window(title="最小化", control_type="Button")
   |    |
   |    | Button - '最大化'    (L2519, T81, R2565, B111)
   |    | ['Button6', '最大化Button', '最大化']
   |    | child_window(title="最大化", control_type="Button")
   |    |
   |    | Button - '閉じる'    (L2565, T81, R2612, B111)
   |    | ['閉じる', '閉じるButton', 'Button7']
   |    | child_window(title="閉じる", control_type="Button")
   |

(略)

 
各画面部品の親子関係が木構造で表現されています。「メモ帳を閉じるボタン」についての記載は以下のとおりです。

Button - '閉じる'    (L2565, T81, R2612, B111)
['閉じる', '閉じるButton', 'Button7']
child_window(title="閉じる", control_type="Button")

 
「Button - '閉じる' (L2565, T81, R2612, B111)」が要素のコントロールタイプ、テキスト(タイトル)、表示座標を示しています。また、「['閉じる', '閉じるButton', 'Button7']」と「child_window(title="閉じる", control_type="Button")」が要素の取得方法を示しています。

以下のいずれの行のコードでも「メモ帳を閉じるボタン」要素を取得可能です。

#root_win['']による要素取得
close_button_elm = root_win['閉じる']
close_button_elm = root_win['閉じるButton']
close_button_elm = root_win['Button7']


#child_window()による要素取得
close_button_elm = root_win.child_window(title="閉じる", control_type="Button")

 

上記の要素取得方法の違いについて簡単に説明します。root_win['']による要素取得ではpywinauto独自のアルゴリズムによって柔軟に要素を検索しているようでroot_win.child_window()と比較して時間がかかります。一方、root_win.child_window()は恐らくですが要素のプロパティが一致するものを検索しているだけであり、比較的高速です。

したがって、root_win['']で遅いと感じる場合はroot_win.child_window()を使ってみたら良いでしょう。

しかし、いずれの方法であっても要素数が多い画面で検索を行うと時間がかかります*3。時間がかかり使用に耐えない場合は別のやり方を検討します。このことについては後述します。
 

画面要素を操作する

方法

基本的に取得した画面要素から特定のメソッドを呼び出すことで画面要素を操作することが出来ます。例えばボタンをクリックしたいならclickメソッドを呼び出します。

どの種類の要素に対してどのメソッドを使えばよいのかについては以下のページが参考になります。

 

クリックする

「メモ帳を閉じるボタン」要素をクリックするにはclickメソッドを使います。

from pywinauto import Desktop
import subprocess
import time

subprocess.Popen(r'C:\WINDOWS\system32\notepad.exe', shell=True)

time.sleep(1)

app = Desktop(backend="uia")

root_win = app['無題 - メモ帳']

close_button_elm = root_win.child_window(title="閉じる", control_type="Button")

#ここで閉じるボタンをクリックする
close_button_elm.click()

 

「click_input()」でもボタンを押すことが出来ます。「click_input()」の場合は要素座標の中心にマウスを移動し、マウスクリックする動作となります。この場合、要素が画面に表示されておらずマウスクリック出来ない場合は失敗します。

「click()」が上手く機能しない場合の代替方法として「click_input()」を用いるとよいと思います*4
 

Edit要素への文字入力、文字取得

Edit要素に文字を入力するにはset_edit_textメソッドを使います。ただし、メモ帳の文字入力画面要素(Edit要素)のバックエンドはwin32なので、Desktopオブジェクトはバックエンドとしてwin32を指定して生成しています*5

Accessibility Insightsで確認した画面部品のプロパティ「Frameworkid」がWin32であれば、該当する画面部品のバックエンドがWin32だと思ったらよいです。


文字入力後に改行を入れたい場合はtype_keysでエンターキー入力を行います。pywinautoでサポートしているキーの一覧はpywinauto.keyboardを参照してください。


Edit要素から文字を取得するにはtext_blockメソッドを使います。

from pywinauto import Desktop
import subprocess
import time

subprocess.Popen(r'C:\WINDOWS\system32\notepad.exe', shell=True)

time.sleep(1)

app = Desktop(backend="win32")

root_win = app['無題 - メモ帳']

edit_elm = root_win.child_window(class_name="Edit")


#ここで文字を入力する
edit_elm.set_edit_text('aaaaaaaaaaaaaaaaaaaaaaa')
edit_elm.type_keys("{ENTER}")
time.sleep(1)

#ただし、この方法では上書きしてしまう
edit_elm.set_edit_text('bbbbbbbbbbbbbbbbbbbbbbb')
edit_elm.type_keys("{ENTER}")
time.sleep(1)


#文字を追記したい場合は既存文字を取得したうえで
#それに追記する形で文字を入力する
b_text = edit_elm.text_block()
edit_elm.set_edit_text('ccccccccccccccccccccccc',pos_end=b_text)

 

メニュー選択

メニュー選択を行うためにはアプリの最上位要素からmenu_selectメソッドを呼び出します。例えばメモ帳の「ファイル」から「名前を付けて保存」のようにメニュー選択を行う場合はroot_win.menu_select('ファイル->名前を付けて保存')のようにメソッドを呼び出します。


 

from pywinauto import Desktop
import subprocess
import time

subprocess.Popen(r'C:\WINDOWS\system32\notepad.exe', shell=True)

time.sleep(5)

app = Desktop(backend="uia")
for win in app.windows():
	print(win.texts())

root_win = app['無題 - メモ帳']

#ここでメニュー選択を行う
root_win.menu_select('ファイル -> 名前を付けて保存')

 
メニュー検索のアルゴリズムにはファジーな(選択に幅がある)やり方を採用しているようなのでメニュー名称は完全なものでなくてもよいみたいです。例えば空白がないとか部分一致しかしないような入力であっても指定されたメニューを特定しようと努力するようです。

ここら辺の内容は

を参照してください。
 

menu_selectの処理はselectメソッドを使っても同様のことを実現可能です。

from pywinauto import Desktop
import subprocess
import time

subprocess.Popen(r'C:\WINDOWS\system32\notepad.exe', shell=True)

time.sleep(5)

app = Desktop(backend="uia")
for win in app.windows():
	print(win.texts())

root_win = app['無題 - メモ帳']


#「ファイル」メニューアイテムを取得し、選択する
file_menu_item_elm = root_win.child_window(title="ファイル(F)", control_type="MenuItem")
file_menu_item_elm.select()


#「名前を付けて保存」メニューアイテムを取得し、選択する
#titleでなぜがマッチしないので他のプロパティのみを使って要素検索した
save_as_menu_item_elm = root_win.child_window(auto_id="4", control_type="MenuItem")
save_as_menu_item_elm.select()

 

コンボボックスの選択要素のテキスト取得、要素選択

コンボボックスで選択されている要素のテキストを取得するにはselected_textメソッド、コンボボックス中に含まれる全てのテキストを取得するにはtextsメソッド、要素を選択するにはselectメソッドを用いればよいです。

メモ帳の保存文字コードを選択するためのコンボボックス要素のバックエンドはwin32なので、Desktopオブジェクトはバックエンドとしてwin32を指定して生成しています*6
 

from pywinauto import Desktop
import subprocess
import time

subprocess.Popen(r'C:\WINDOWS\system32\notepad.exe', shell=True)

time.sleep(5)

app = Desktop(backend="uia")
for win in app.windows():
	print(win.texts())

root_win = app['無題 - メモ帳']

file_menu_item_elm = root_win.child_window(title="ファイル(F)", control_type="MenuItem")
file_menu_item_elm.select()


save_as_menu_item_elm = root_win.child_window(auto_id="4", control_type="MenuItem")
save_as_menu_item_elm.select()



#文字コードを指定するcomboboxのバックエンドがwin32であるため
#win32を指定する
app32 = Desktop(backend="win32")
root_win32 = app32['名前を付けて保存']

#文字コードを指定するcomboboxを取得する
char_code_select_combobox_elm = root_win32['ComboBox3']

#ここで選択中の文字列を表示する
#実行結果
#UTF-8
print(f'{char_code_select_combobox_elm.selected_text()}')

#ここでコンボボックスの選択肢すべてのテキストを表示する
#実行結果
#UTF-8
#ANSI
#UTF-16 LE
#UTF-16 BE
#UTF-8
#UTF-8 (BOM 付き)
for combobox_item_text in char_code_select_combobox_elm.texts() :
	print(combobox_item_text)

#ここでANSIを選択する
char_code_select_combobox_elm.select('ANSI')

 

画面要素のプロパティ値を取得する

ここでは画面要素のプロパティ値を取得するメソッド(わたしがよく使うもの)を以下の表にまとめます。実行結果例はメモ帳の閉じるボタンに対してのものです。

メソッド
説明
実行結果例
get_properties() プロパティ値を辞書として返す {'class_name': '', 'friendly_class_name': 'Button', 'texts': ['閉じる'], 'control_id': None, 'rectangle': , 'is_visible': True, 'is_enabled': True, 'control_count': 0, 'is_keyboard_focusable': False, 'has_keyboard_focus': False, 'automation_id': ''}
legacy_properties() レガシーなプロパティ値を辞書として返す {'ChildId': 5, 'DefaultAction': '押す', 'Description': 'ウィンドウを閉じます。', 'Help': None, 'KeyboardShortcut': None, 'Name': '閉じる', 'Role': 43, 'State': 0, 'Value': None}
friendly_class_name() 親切なクラス名を返す Button
automation_id() オートメーションIDを返す (未設定のため空文字)
texts() テキストのリストを返す ['閉じる']

 

画面のツリー構造をたどって要素を取得する

前述したroot_win['']やchild_window()での要素取得が遅すぎて使用に耐えない場合は、画面のツリー構造をたどって目的となる要素を取得する必要があります。メモ帳の場合は要素数が少なく例としては適当ではありませんがご了承ください。


画面のツリー構造をたどるにはchildrenメソッドを使います。今回はメモ帳を閉じるボタンを取得します。


print_control_identifiersメソッドで画面の構造を確認すると「ルート要素」→「TitleBar」→「閉じるボタン(目的の要素)」のような手順で目的の要素を取得できそうだとわかります。

print_control_identifiersメソッドの実行結果

Dialog - '無題 - メモ帳'    (L1637, T80, R2619, B815)
['無題 - メモ帳Dialog', '無題 - メモ帳', 'Dialog']
child_window(title="無題 - メモ帳", control_type="Window")
   |
   | Edit - 'テキスト エディター'    (L1645, T131, R2611, B783)
   | ['Edit']
   | child_window(title="テキスト エディター", auto_id="15", control_type="Edit")
   |    |

(中略)

   |
   | TitleBar - ''    (L1661, T83, R2611, B111)
   | ['TitleBar']
   |    |
   |    | Menu - 'システム'    (L1645, T88, R1667, B110)
   |    | ['Menu', 'システムMenu', 'システム', 'システム0', 'システム1', 'Menu0', 'Menu1']
   |    | child_window(title="システム", auto_id="MenuBar", control_type="MenuBar")
   |    |    |
   |    |    | MenuItem - 'システム'    (L1645, T88, R1667, B110)
   |    |    | ['MenuItem', 'システム2', 'システムMenuItem', 'MenuItem0', 'MenuItem1']
   |    |    | child_window(title="システム", control_type="MenuItem")
   |    |
   |    | Button - '最小化'    (L2472, T81, R2519, B111)
   |    | ['最小化', 'Button5', '最小化Button']
   |    | child_window(title="最小化", control_type="Button")
   |    |
   |    | Button - '最大化'    (L2519, T81, R2565, B111)
   |    | ['Button6', '最大化Button', '最大化']
   |    | child_window(title="最大化", control_type="Button")
   |    |
   |    | Button - '閉じる'    (L2565, T81, R2612, B111)
   |    | ['閉じる', '閉じるButton', 'Button7']
   |    | child_window(title="閉じる", control_type="Button")
   |

(略)

 

TitleBar要素を取得する方法を検討するため、とりあえず、root要素直下の要素のプロパティ値を表示してみましょう。

root要素直下の要素のプロパティ値を表示するソースコード

from pywinauto import Desktop
import subprocess
import time
from pywinauto.uia_defines import NoPatternInterfaceError

subprocess.Popen(r'C:\WINDOWS\system32\notepad.exe', shell=True)

time.sleep(1)

app = Desktop(backend="uia")

root_win = app['無題 - メモ帳']

for index, child_elm in enumerate(root_win.children()) :

	#バックエンドがwin32の要素に対してget_properties
	#を実行すると例外が発生するので処理する
	try:
	
		print(f'{index}:{child_elm.get_properties()}')
		
	except NoPatternInterfaceError :
	
		print(f'{index}:{child_elm}')

 

root要素直下の要素のプロパティ値

0:uia_controls.EditWrapper - 'テキスト エディター', Edit
1:{'class_name': 'msctls_statusbar32', 'friendly_class_name': 'StatusBar', 'texts': ['ステータス バー'], 'control_id': 1025, 'rectangle': <RECT L476, T589, R1065, B613>, 'is_visible': True, 'is_enabled': True, 'control_count': 5, 'is_keyboard_focusable': True, 'has_keyboard_focus': False, 'automation_id': '1025'}
2:{'class_name': '', 'friendly_class_name': 'TitleBar', 'texts': [''], 'control_id': None, 'rectangle': <RECT L492, T86, R1065, B114>, 'is_visible': True, 'is_enabled': True, 'control_count': 4, 'is_keyboard_focusable': True, 'has_keyboard_focus': False, 'automation_id': ''}
3:{'class_name': '', 'friendly_class_name': 'Menu', 'texts': ['アプリケーション'], 'control_id': None, 'rectangle': <RECT L476, T114, R1065, B133>, 'is_visible': True, 'is_enabled': True, 'control_count': 5, 'is_keyboard_focusable': True, 'has_keyboard_focus': False, 'automation_id': 'MenuBar'}

 

結果を見ると2番目の要素がTitleBarであり、friendly_class_nameプロパティを用いて要素を特定できそうだとわかります。あとはこれを繰り返せば「閉じるボタン(目的の要素)」を取得できます。以下が「閉じるボタン(目的の要素)」を取得するソースコードです。

from pywinauto import Desktop
import subprocess
import time

subprocess.Popen(r'C:\WINDOWS\system32\notepad.exe', shell=True)

time.sleep(1)

app = Desktop(backend="uia")

root_win = app['無題 - メモ帳']


#root直下のtitle bar要素を取得する
title_bar_elm = None
for child_elm in root_win.children() :

	if child_elm.friendly_class_name() == 'TitleBar' :
	
		title_bar_elm = child_elm
		break


#続いてtitle bar要素直下の閉じるボタン要素を取得する
close_button_elm = None
for child_elm in title_bar_elm.children() :

	if child_elm.friendly_class_name() == 'Button' and '閉じる' in child_elm.texts():
	
		close_button_elm = child_elm
		
		
#閉じるボタンをクリックする
close_button_elm.click()

 

おわりに

今回はpywinautoでwindowsアプリを自動操作する方法を書いてきましたが、pywinautoであっても操作できない画面部品は結構あったりします。例えばAdobe acrobat readerのプリントボタンなんかはUIオートメーションの仕組みでは操作できない画面部品です。

こういうやつです。

 

こうした画面部品はpyautogui(画像を検索し、マウスクリック出来る)を使うなどすれば対応可能です。pyautoguiが非常に簡単に扱えて便利ですが、あらかじめ画像を用意する必要があり、部品サイズが変わったりするとうまく認識できなかったりするので*7、個人的にはpywinautoが使える範囲はpywinautoを使ったほうが良いように思っています。


pywinautoについて更に知見がたまったら内容を追記していこうと思っています。


今回はここまでです。

*1:古いwindowsアプリでなければ大体はUIオートメーションで問題ないはず、ただ、一部win32が用いられていることがあるので場合によってはwin32も使う

*2:仕事でつかうということもあり時間制限もあった

*3:私が経験したケースでは大量の要素が存在する画面を検索すると、root_win['']では30分程度、root_win.child_window()では10分程度時間がかかりました。1回あたりの時間であり、使用に耐えませんでした。

*4:なぜclickが失敗するかはわからないことが多かったです。単純にわたしの知識不足。クリックに連動してモーダルダイアログが表示される場合は大抵ダメでした。ハイパーリンクをクリックする場合はinvokeを使うのですがそれも同様

*5:UIオートメーションをバックエンドとして指定しても文字入力はできますが、type_keysで改行を入力すると行頭に改行コードが入力されたり、text_blockで文字取得を行えなかったりと想定外の動きとなるため、要素から文字取得を行いたい、追記処理を実装したいのであればwin32をバックエンドに指定することを推奨します。

*6:UIオートメーションをバックエンドに指定して操作を行うとテキスト取得が上手くいかなかったり、要素選択の際にIndexErrorが頻発します(毎回ではない)。こちらでも報告があります。

*7:全画面の時は上手くいくのにそうじゃないと画像が見つからず例外発生することがある