【2023.6月時点】Selenium + Flet + py2exe を使ってRPAっぽいシステムを作って得られた知見まとめ

# 1.今回の記事内容
* 社内業務効率UPのため、あるシステムへの入力業務の自動化を行いました。
* 実際に作ってみると様々な問題にぶつかりましたが、やりたいことは実現可能であることが分かりました。
* 後続で似たようなことをやってみようという人のため、また将来の自分のためにメモを残しておきます。
* RPA も良いけどやって欲しいことをAIに言葉で説明すればいい感じにやってくれるようなシステムはできないのか…（いわゆるエージェント）については今回は考えません。[^5]

[^5]: 今後AIが発達すればそのようなシステムにも現実味が出てくると思います。そちら方面の調査も進めていきたいと思います。


というものになります。また、一応[前回の記事（下記）](https://qiita.com/smats-rd/items/aa1334c476c752cf979d)の続編という位置付けにもなっています。

https://qiita.com/smats-rd/items/aa1334c476c752cf979d

# 2. プロジェクト要件の整理
## (1) システム概要図
　こんな感じです。右側の既存データを人間が手動で入力し、入力先システムへ送信していましたがこの入力作業が煩雑で担当者に負担がかかっていました。このデータ入力作業のうち、一番面倒な部分を今回作成したプログラムで自動設定するようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2261111/ba215067-d25c-4f47-e082-c51ee56e3862.png)


いわゆるRPAでやるような処理なのですが、専用のRPAシステムを購入する費用や時間がないのと、当方の技術的な知見も蓄積したかったので、Pythonを使うとどんなものなのかやってみました。

## (2) システム要件
* 自動化対応が必要な画面は、最も操作が煩雑な１画面をターゲットとする。ただし途中で入力ダイアログ的な別画面への遷移も発生する。
* 入力項目は1画面で平均50か所程度で、業務パターン（ざっくり５～６パターンくらい）に応じてフォームレイアウトが動的に変化する。
* 入力時にどうしても人間が見て判断しなくてはいけない項目があるため、完全自動にはできない。また自動入力した内容を最終的に担当者が見て、内容を確認してから送信したい。

## (3) 使用したもの

| 使用したツール/パッケージ| バージョン|
|---| --- |
| Python |3.10.11|
|Selenium | 4.4.3|
|webdriber-manager |3.8.6|
|Microsoft Edge|114.0.1823.67 |
|Flet |0.7.4|
|OpenPyxl |3.1.2|
|pywin32 |306|
|pyodbc |4.0.39|
|SQL-Server |15.0.2101.7|
|Microsoft Excel 2019 |バージョン2306 ビルド 16.0.16529.20100 32ビット|
|py2exe |0.13.0.0|


# 3. SeleniumをRPAツールとして流用するにあたっての諸問題
　ここからは今回のシステムで発生した技術的な問題点について書いていきます。まずはSeleniumについてです。

## (1) コンソールアプリのGUI化
　これまでSeleniumを使うシーンでは、自分が使えればよかったのでPythonでコンソールアプリを作成し、そこからSeleniumモジュールを呼び出してブラウザ制御を行っていました。しかし一般ユーザが使うことを想定した場合、コンソールからコマンドを叩くというのはハードルが高く、やはりGUIが欲しくなります。そこでPythonでGUIを簡単に作れるフレームワークで手ごろなものはないか調べ、下記の記事でFletが良いといわれているのを見つけました。

https://qiita.com/ForestMountain1234/items/bc709c3599ee86e2a0dd

Pythonで作れて比較的見栄えがよく学習コストが低そうなものという基準で選びました。欠点としては公式ドキュメントが英語で、説明が少々簡単すぎることでしたが、今回はリッチなUIは必要ないので学習時間もそれほどかからず、推測交じえて適当にやってみたら簡単にGUIアプリを作成できました。次なる問題はこれをexe化することですが、それについてはpy2exeの項で後述します。

https://flet.dev/docs/


## (2) Webドライバ更新問題
　もともとSeleniumというのはRPAのために作られたツールではなく、Web開発時のテストを自動化するためのツールです。

https://qiita.com/Chanmoro/items/9a3c86bb465c1cce738a

　そのため、使い方も基本的に玄人前提になっていて、そのままでは開発者ではない事務作業担当者が扱いづらいものです。使用にあたってはブラウザのバージョンごとに用意されているWebドライバをダウンロードして適切な場所に配置する必要があり、またブラウザが更新されるたびに対応するバージョンのものをダウンロードして配置しなおす必要があります。Edgeの場合はこのブラウザ自身がMicrosoft Updateによって結構な頻度で自動的に更新されてしまうため、Webドライバのバージョンもそれに追随して合わせる必要があります。
　この問題を解決するのが WebDriverManager です。このツールの導入についてはPy2exeと組み合わせたときに問題が起きて一苦労あったのですが、その顛末については前回記事に書いていますので、詳細はそちらをご覧ください。

https://qiita.com/smats-rd/items/aa1334c476c752cf979d



## (3) Edgeの起動モード
　今回のシステムでは入力内容を完全に自動化できず、入力途中で人間による判断を入れる必要がありました。そのため、通常のようにブラウザを制御して全自動で最初から最後まで動かすのとは異なり、ユーザが通常通りの操作でブラウザを使って操作している中で、ある画面で特定のタイミングになったときにだけ起動済みのブラウザに対して自動入力を行いたいという要件がありました。そのためにはあらかじめEdgeブラウザをリモートデバッグモードで起動しておき、後からSeleniumで接続する必要があります。具体的には、Edgeの起動時に下記のようなコマンド引数を設定します。

``` : リモートデバッグモードでの起動コマンドの例
C:\>msedge.exe --remote-debug-port=9222 --user-data-dir=c:\temp
```

　待ち受けポートやユーザデータ用ディレクトリの指定先は使えるものであればなんでもよいです（なぜかサンプルではポートが9222になっていることが多い）。注意点としては、__このモードで立ち上げるときは --remote-debug-port だけでなく --user-data-dir も必ず指定する必要がある__ ということです 。--remote-debug-port の指定だけではエラーにはならず問答無料で通常モードでEdgeが起動してしまいます。しかしこの特殊な起動方法を一般ユーザーにお願いするのは難しいので、今回のプログラムではEdgeのリモートデバッグモードでの起動用ボタンを設けて、それをユーザに押してもらうようにしました。実装は下記のような感じになります。

``` python: Edge起動処理サンプル
import subprocess
# ・・・中略

# Edgeを起動する
def start_edge(e):
    port: int = 9222
    # 起動確認
    if check_tcp_connectivity("localhost", port, 0, 1):
        Logger.log(INFO, "Edgeは既に起動しています。")
        disp_dlg(e, "Edgeは既に起動しています。") #エラーメッセージ表示
    else:
        Logger.log(INFO, "Edgeを起動します。")
        path: str = r'"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"'
        args: list[str] = [
            f"--remote-debugging-port={port}",
            r"--user-data-dir=C:\temp",
        ]

        # 子プロセスとして起動する
        subprocess.Popen(
            path + " " + args[0] + " " + args[1], shell=True
        )
        Logger.log(INFO, "Edgeの起動を待ちます。")
        if check_tcp_connectivity("localhost", port, 0, 1):
            Logger.log(INFO, "Edgeが起動しました。")
```

　子プロセス起動時の注意点ですが、VSCode からプログラムを起動している場合は、subprocess.Popen()の引数設定に関わらず本プログラムを終了させると子プロセスのプログラム（今回の場合はEdge）も強制終了させられます。これは開発時に余計なプロセスが残らないようにという配慮でそうなっているのだろうと思いますが、知らないと子プロセスが終了してしまう理由がわからず悩むことになります。VSCodeと独立で本プログラムを動かせば子プロセスは問題なく独立で起動します。ハマりそうなポイントなので注意が必要です。

　ここで、 check_tcp_connectivity() という関数でEdgeが既に起動しているかどうかをチェックし、ブラウザの二重起動を防止しています。
下記は所定のポート(今回は9222)に接続してみて接続可能だったらすでに開いていると判定する処理です。

``` python: Edge起動確認処理
# TCP接続確認
def check_tcp_connectivity(host, port, interval=0, retries=1) -> bool:
    s: socket.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    for x in range(retries):
        try:
            s.connect((host, port))
            s.close()
            return True
        except socket.error as e:
            time.sleep(interval)

    s.close()
    return False
```
リトライできるように作りましたが、結局1回試行で判定しています。


## (4) Edgeと接続する際に一瞬コンソールウィンドウが表示されてしまう問題
今回のやり方では、ユーザに通常通りのログインから手動で画面遷移してもらい、自動入力の対象である所定の画面までは手動で操作してもらいます。対象画面が表示されたところでおもむろに自動入力開始ボタンを押してもらうと、自動入力処理が始まります。
実際の処理順序は下記のようになります。

* まずWebDriverManager を使って、最新のWebドライバを必要に応じてダウンロードします。
* Webドライバの初期化ができたらSelenium がセッションを開始します。

``` python: セッション開始処理サンプル
from selenium.webdriver import Edge
from selenium.webdriver.edge.service import Service
from selenium.webdriver.edge.options import Options
from webdriver_manager.microsoft import EdgeChromiumDriverManager
from subprocess import CREATE_NO_WINDOW

# 中略

# 自動入力のセッションを管理するクラス
class AutoInput: 
    def __init__(self):
        self.browser: Edge = self.connect_driver() # ブラウザへ接続
        self.__handle: str = ""  # 入力先ウィンドウのハンドル

    # ブラウザへ接続
    def connect_driver(self) -> Edge:
        opt: Options = Options()  # selenium 4
        opt.debugger_address:str = "127.0.0.1:9222"
        # Webドライバを必要に応じてDLしインストール
        driver_path: str = EdgeChromiumDriverManager().install()
        svc: Service = Service(driver_path)
        svc.creationflags = CREATE_NO_WINDOW  # コンソールを一瞬表示しないようにする設定
        browser: Edge = Edge(service=svc, options=opt) # ここで接続する
        return browser
```

実はここで一つ問題が発生しました。WebドライバがEdgeに接続するときに、GUIプログラムであるにも関わらず、一瞬黒いコンソールが表示されてしまうという問題がありました。これを防ぐためには、サンプルでやっているように、 Serviceのcreatitonflags に CREATE_NO_WINDOW を設定しておくと表示されなくなります。この値は具体的には 0x8000000 と定義されていて

``` python: _winapi.pyi
CREATE_NO_WINDOW: Literal[0x8000000]
```

おそらくWindowsAPIの CreateProcess() の引数である Process Creation Flags (dwCreationFlags) の値と思われます。

https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags


## (5) ウィンドウ（ブラウザタブ）の切り替え
　Seleniumセッションがつながった後は、ターゲットとなった画面（ブラウザのタブ）にフォーカスを合わせる必要があります。今回はブラウザ画面のタイトル文字列の先頭部分によって判定しました。

``` python: ウィンドウ（ブラウザタブ）へのフォーカス
    # セッション開始
    def start(self) -> None:
        Logger.log(INFO, "セッション開始")
        self.browser.start_client()
        # 見つけたウィンドウのハンドルを保持しておく
        self.__handle: str = self.__forcus_window("xxx 入力処理") #目的のウィンドウを見つける

    # タイトル文字列を指定して、ウィンドウをアクティブにする
    def __forcus_window(self, title: str) -> str:
        for handle in self.browser.window_handles:
            # ウィンドウを切り替えてみる
            self.browser.switch_to.window(handle)
            # タイトルの先頭が指定の名前と合致したらループを抜ける
            if self.browser.title.startswith(title):
                return handle
        else:
            # 見つからなかった場合は、エラー
            msg: str = f"ブラウザで[{title}]画面が見つかりませんでした。\n適切な画面を開いているか確認してください。"
            raise Exception(msg)

#中略...
        # ウィンドウを元に戻す
        self.browser.switch_to.window(self.__handle)

```

見つけたウィンドウのハンドルを覚えて置いて、後で戻るときにswitch_to.window()で使っています。
このメソッドは画面途中でボタン押下によって表示された別ウィンドウへの切り替えにも使えます。

　Selenium周りでの注意点は以上です。一度ブラウザへ接続した後のページやターゲット要素の見つけ方などは通常のSelenium操作と同様ですので、本記事では割愛とさせていただきます。


# 4. OpenPyxl で Excel ファイルを扱うときの諸問題
 OpenPyxlはPythonでExcelファイルを編集できるパッケージとして有名です[^3]が、ここにもいくつか落とし穴があります。

[^3]: たまに「OpenPyxlでExcelを操作できる」と書いている記事を見かけますが、OpenPyxlではExcel自体を操作できるわけではなく、__Excelファイルを編集できる__ だけです。OpenPyxlとは別のパッケージである pywin32 ではCOMを経由することで「Excelを操作」していると言ってよい処理をしています。

## (1) OpenPyxl では .xlsx ファイルしか扱えない
　Excelファイルには .xlsx ファイルの前バージョンにあたる .xls ファイル形式が存在しますが、OpenPyxl で編集できるのは一番新しいファイル形式である .xlsx だけです。.xlsファイルは .xlsxファイルとは仕様が全く異なるためOpenPyxlでは開けません。最近では古いファイル形式は使われなくなってきているのでこのことは大きな問題ではないかも知れません。この際現場で .xls ファイルを使うのはやめてもらい .xlsx ファイルで統一するように業務を整理することをお勧めします。

## (2) OpenPyxl ではセルに書かれた式を評価できない
　これはOpenPyxlの非常に残念なポイントです。このことを知らずにPythonでもVBAと同等のことができると思い込んで OpenPyxl を採用して後から困るケースは結構ありそうです。この問題について検索すると見つかる解決方法として、ファイルをオープンする処理で data_only 引数に True を設定すると言う方法があります。こうすれば通常は式を評価した値が取得できます。

``` python: data_onlyパラメータを設定する例
    wb: Workbook = openpyxl.load_workbook(file_name, data_only=True)
```

　しかし、これはこれで別の副作用があります。この引数を設定して開いたExcelファイルをOpenPyxlで編集して保存すると、セルに設定した式は消えてしまい、式を評価した値に差し替えられて保存されてしまいます。それでは困るというケースが多いと思います。この問題への対処方法として、data_only=Trueで開いたファイルは読むだけにして、編集する場合は別途ファイルをdata_only=Falseで開きなおすなどの面倒な対応が必要になります。
さらに、それとは別にもう一つ問題があります。


## (3) OpenPyxl で作成、保存した直後のExcel ファイルを data_only=True で開いても式の評価値は取得できない
　これも大きな落とし穴です。例えば、セルA1とB1の値を足した値をC1に表示するため、C1に「=A1+B1」という式が書かれていたとします。そこでOpenPyxlを使ってA1に1、B1に2という値を設定し、ファイルをセーブした上で、すぐにそのファイルをもう一度OpenPyxlで data_only=True で開きなおしたとします。このとき、 __C1セルから値を取得しても3という値は取得できず、取得値はNone__ となるのです。なぜなら、OpenPyxl が data_only=True でExcelファイルを開いたときに、OpenPyxlが式を評価して値を取得しているわけではないからです。セルに書かれた式を評価できるのはあくまで Excel だけです。上記の例でOpenPyxlによって保存された直後のExcelファイルは、__まだ一度もExcelによって開かれていないため、式が評価されずその評価値は更新されていない__ のです。この問題に対処するには、例えば以下のような方法があります。

　1. セルから式そのものを文字列としてPythonプログラムで取得して解読し、計算を自力でエミュレートする。
　2. 手動でファイルをExcelを使って開いて保存しなおす。
　3. pywin32 を使ってExcelを遠隔操作してファイルを開き改めて保存しなおす。

今回は3の方法を使って解決しました。実はこの方法にも実際にやってみると多少の問題があるのですが、長くなりますので詳細は割愛します。[^4] 

[^4]: それだったらpywin32をはじめから使えば？という話もありますが、pywin32 はOpenPyxlよりもクセが強くて使いにくいという問題があります。OpenPyxlとExcelセルの式評価の問題については改めて詳細記事を書きたいと思っています（時間が欲しい）。

# 5. PythonプログラムをExe化するときの諸問題

　Pythonで作ったプログラムを一般のユーザに使ってもらおうとしたときに悩むのが配布方法です。ユーザの各端末にPythonをインストールしてスクリプトを配置し、python コマンドを叩いて起動してもらうというのは一般ユーザには難しいのです。そこでexe 化ツールを使って直接実行できる形にした上で配布したくなります。ところがこの exe 化ツール(今回はpy2exeを採用)が鬼門で、本格的に使おうとすると様々な問題を引き起こします。Web上で見つかる Pythonの様々なパッケージの説明は通常のスクリプトとしての実行しか想定していないため、exe化で問題が起こっても解決方法が見つけにくく自力解決が必要になります。上述の[前回記事](https://qiita.com/smats-rd/items/aa1334c476c752cf979d)ではその問題の一つであるImport エラー問題を扱いました。ここではそのほかの問題について解決方法を記載しておきます。

## (1) GUI化したのでコンソールは出したくない
　Fletを使ってGUIプログラムとしたので、コンソールからの起動はしたくありません。コンソールアプリのままでは、exeを直接起動してもコンソール画面も一緒に開いてしまいます。そのため、py2exeでexe化する場合にコンソールアプリとしてではなく、GUIアプリとしてexe化してやる必要があります。そのためにはfreeze.py（exe化を行うための関数freeze()を呼びだす設定スクリプト)の設定をWindowsアプリ用に変更します。

``` python: freeze.py の例
from py2exe import freeze

freeze(
    # console=["hogehoge.py"], #コンソールアプリの場合
    windows=["hogehoge.py"],  #windowsアプリの場合
    options={
        "packages": ["charset_normalizer"],
        "includes": [],
        "excludes": [],
    },
)
```

メインスクリプトのファイル名(上記例ではhogehoge.py)を freeze() 関数のconsole引数に渡す代わりにwindows引数に渡してやればOKです。これとは別に、Webドライバ起動時に一瞬コンソールが表示されてしまう問題がありましたが、それについては上記3(4)項で解決法を説明しました。


## (2) プログラム終了時にログ出力メッセージが出てしまう
　これも最初ちょっと原因がわからず悩んだのですが、プログラム終了時に下図のような「Errors in 'xxxx.exe'」「See the logfile 'xxxx.log' for details」という内容のダイアログメッセージが表示されてしまうという現象が出ました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2261111/c20c2a27-f404-d2cf-bb29-e159567055ac.png)

　これは、py2exeで作成したGUIプログラムで標準エラー出力(いわゆる stderr) へログなどの出力を行っていると、py2exeが検知してユーザディレクトリの AppData\Roaming フォルダの下へ自動的にlogファイルを作り、プログラム終了時にメッセージを出してしまうためのようです。
具体的には、ロガーのハンドラに StreamHandler() を使っていたりするケースがこれに該当します。
そのため、ログ初期化処理で調整するなどして標準エラー出力にログを出さないように設定します。
標準エラー出力を使っているつもりがないのにこのダイアログが出てしまう場合、調査が面倒ならば以下のように強制的に標準エラーを無効にする手もあります。

``` python: 標準エラー出力を無効化する
import sys
import os

sys.stderr = sys.stdout = os.devnull
```

ただしこういう対応はあくまで暫定措置であって、お勧めはできません。
ロガーの設定の考え方がよくわからないという方には、手前味噌で恐縮ですが以前書いた下記の記事をお勧めします。

https://qiita.com/smats-rd/items/c5f4345aca3a452041c7

# 6. まとめ
　ということで、今回記事の内容を総括します。

* Python で Selenium を使ってRPAっぽいことを実現しようとすると実現できないわけではないが様々な問題が発生します。

* __実際にやってみると巷でよく言われる「Pythonで簡単に自動化できる」的な言葉でイメージするほど簡単ではない[^2]ですし、トラブル解決にはそれなりの技術力が必要となるので計画時には注意が必要です。__ (※個人の見解です）

* それでもやってみたいという方への一助となるべく、今回記事で以下の問題の解決方法について書いたので参考にしていただければ幸いです。
    * pythonのGUI化
    * Selenium のWebドライバ更新
    * Edgeのリモートデバッグ起動
    * コンソールウィンドウ表示の抑制
    * ブラウザタブ切替
    * OpenPyxl で式の値を取得
    * GUI化したプログラムの exe 化
    * exe化したGUIプログラムの終了時に出るエラーダイアログの抑制


* 余談ですが、RPAによる自動化というのは見た目は派手でDXやった感があるのですがシステムの在り方としては邪道というべき解決方法だと思います。Web画面からの入力はもともと人間向けのインターフェイスであり機械での処理には向いていないためです。処理速度の面でも、信頼性の面でも品質は良くありません。本来あるべき姿としては入力先のシステムでインタフェース用のAPIなどを用意してもらい、途中に人間が介在しないでコンピュータ間で静かに確実に情報連携するのが理想です。RPAはあくまでそういった本来的ソリューションが可能になるまでの暫定的つなぎ措置として位置付けるのが妥当だと思います。

* 冒頭に書いたように、AIにぶん投げればよしなにやってくれるような素晴らしい世界が来ればRPA？そういえばそんなものもあったよね…と遠い目をしながら語られるロストテクノロジーになるかもしれません。昔はこんな苦労してたんだよという記録にでもなればと思います。


# 7. 【追記】導入による効果
　このシステムを導入した結果についてですが、約200件／月程度処理しなくてはならない入力処理で、１件につき５分以上かかっていたものが１分程度に削減されたとのことです。スピードアップもさることながら、入力内容の間違いも大幅に減り、作業者の心理的負担が軽減されました。またこの作業は業務上の都合で入力に必要な情報が分かってから1件ごとに社内の該当する関係者に連絡を取って内容を確認の上、入力処理を完了するまでの締め切りが1日～２日以内というタイトなもので、それらが数日の内に集中して発生するため、担当者が時間外稼働で夜間まで対応しなくてはならないストレスフルな作業になっていました。今回入力作業の所要時間が劇的に削減された結果、担当者の心理的肉体的な負担も大きく軽減されたと言えます。

----

以上、どなたかの参考になれば幸いです。
ここまで読んでいただきありがとうございました。

[^2]: 確かにずっと昔 Selenium や OpenPyxl が無かった頃に同様のことをやろうとした場合に比べたらずっと簡単になっているので「簡単にできる」というのは嘘ではないのです。