Selenium活用術：娘のために作った予約システム

![IMG_2847.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/0ba9bd91-aee9-452b-e669-73dadbbf4bb4.jpeg)
# はじめに

今回は、**selenium**を活用してWebブラウザを操作する方法について解説します。  
先日、大学に通う娘から「サークルでWebブラウザを使って予約をしなければならないが、これを自動化できないか？」と相談を受けました。  

これまでに**selenium**に関する記事をいくつか投稿してきましたが、まだ十分に自信がありませんでした。とはいえ、娘からの依頼ということもあり、改めて**selenium**を深く学び、Webブラウザ操作を本格的に扱えるようになろうと決意しました。  

この記事では、Webブラウザの操作を自動化して予約処理を実現する方法を紹介し、作成したソースコードも公開しています。  

以下は、これまでに投稿した**selenium**関連の記事です。

https://qiita.com/ogi_kimura/items/db22e2697eb1a4def0c9

# 今回試すサイトについて
今回試すサイトは、京都の公共施設の予約サイトです。
記事の中では詳しい説明は控えますが、**GitHub**のソースコードには記載していますので、一度試していただけると幸いです。

https://github.com/kimkimkim5/kyoto_reserve

このサイトは、各ユーザーが京都にある公共施設の空き状況を確認し、予約を行うためのものです。そのため、空き状況はリアルタイムで変動します。今回作成するプログラムでは、この変動に柔軟に対応できる仕組みが求められます。  
さらに、このサイトではユーザーがログインする必要があるため、ログイン処理への対応も実装する必要があります。

# プログラムソースコードと説明
以下にプログラムのソースコードを掲載します。  
併せて、注目すべきポイントについて解説を加えます。


## app.py

```python:app.py
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
import time
import logger
import util
import subprocess

# --- 先ず、以下を実行し、デバッグモードのブラウザを立ちあげる。 ---
command = r'"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir=C:\selenium\chrome-profile'
subprocess.Popen(command, shell=True)
time.sleep(10)

chrome_service = Service(executable_path='C:*****/chromedriver-win64/chromedriver.exe')

# Chromeオプションを設定
chrome_options = Options()
chrome_options.debugger_address = "localhost:9222"  # デバッグモードのポート番号に合わせる

# 既に開いているChromeブラウザに接続
driver = webdriver.Chrome(service=chrome_service, options=chrome_options)

# データ登録の操作を実行
# ここにRPAを実施したいURLを登録します。
driver.get('https://*********************************')

# 現在開かれているタブのハンドルを取得
original_window = driver.current_window_handle

# ===================================================================
# ========================= メイン関数 ===============================
# ===================================================================
def main(): 

    login_flag = 0

    # ==================== 施設予約システム ======================
    logger.debug('施設予約システム START')
    # フレームに切り替え
    driver.switch_to.frame("MainFrame")  # フレーム名 'MainFrame' を使用
    
    # "バスケットボール" のリンクをクリック
    util.link_click(driver, "バスケットボール", 0)
    
    
    # ==================== 所在地選択 ======================
    # "京都市南区" のリンクをクリック
    util.link_click(driver, "京都市南区", 0)
    
    # "検索" をクリック
    xpath = "//input[@class='clsImage']"
    util.xpath_click(driver, xpath, 7)
    

    # ==================== 会館選択 ======================
    # "抽選" をクリック
    xpath = "//input[@alt='予約画面へ']"
    util.xpath_click(driver, xpath, 1)
    
    
    # ==================== 日程選択 ======================
    # "該当月" をクリック
    util.link_click(driver, f"{util.SELECT_MONTH}月", 0)
    
    logger.info(f'★★★★★  {util.SELECT_MONTH}月の登録を実施★★★★★')
    
    for index in util.SELECT_DAY:

        # 念のため、クリアしておく
        xpath = "//input[@name='btn_clear']"
        util.xpath_click(driver, xpath, 0)
        
        # ポップアップ画面
        util.popup_click(driver, util.POPUP_ACCEPT)
        
        # "該当日" のリンクを取得
        logger.info(f'★★★★★       {str(index)}日の登録を実施★★★★★')
        
        # "該当日" のリンクをクリック
        util.link_click(driver, str(index), 0)
        
        # ************ 条件設定 ************
        # "予約可能" のリンクを取得
        #    image000000000：9:30開始   image000001000：13:00開始    image000002000：16:00開始   image000003000：19:00開始
        xpath = "//img[@name='image000001000' and @alt='予約可能']"
        select_element = util.get_elements_xpath(driver, xpath)
        
        if select_element:

            # クリック
            select_element[0].click()
            time.sleep(util.SLEEP_TIME)
        
            logger.info(f'＝＝＝＝＝  {util.SELECT_MONTH}月{str(index)}日の予約をします ＝＝＝＝＝')    
            # ==================== 面施設選択処理画面 ======================
            # 現在のウィンドウハンドルを取得
            original_window = driver.current_window_handle
            # すべてのウィンドウハンドルを取得
            all_windows = driver.window_handles

            # 新しいウィンドウに切り替える
            util.switch_window(driver, all_windows, original_window)

            # ************ 条件設定 ************
            # "予約可能" のリンクを取得
            # 　　　image0：全面  image1：A面  image2：B面
            # xpath = "//img[( @name='image1' or @name='image2' ) and @alt='予約可能 ']"
            xpath = "//img[@name='image0' and @alt='予約可能 ']"
            select_element2 = util.get_elements_xpath(driver, xpath)
            if select_element2:
                
                # クリック
                select_element2[0].click()
                time.sleep(util.SLEEP_TIME)
                
                # "閉じる" のリンクを取得
                xpath = "//input[@name='btn_close']"
                util.xpath_click(driver, xpath, 0)
                
                # ==================== 日程選択 ======================
                # フレームに切り替え
                driver.switch_to.window(original_window)
                driver.switch_to.frame("MainFrame")  # フレーム名 'MainFrame' を使用
                
                # "次へ" のリンクを取得
                xpath = "//input[@alt='次へ']"
                util.xpath_click(driver, xpath, 0)
                
                # ==================== ログイン画面 ======================
                if login_flag == 0:
                    xpath = "//input[@name='txt_usr_cd']"
                    util.set_textbox(driver, xpath, util.KYOTO_FACILITY_USERNAME, 0)
                    
                    # "パスワード" のテキストボックス
                    xpath = "//input[@name='txt_pass']"
                    util.set_textbox(driver, xpath, util.KYOTO_FACILITY_PASSWORD, 0)
                    
                    # "OK" のリンクを取得
                    xpath = "//input[@alt='ＯＫ']"
                    util.xpath_click(driver, xpath, 0)
                    
                    login_flag = 1
                
                # ==================== 紹介画面 ======================
                # "次へ" のリンクを取得
                xpath = "//input[@alt='次へ']"
                util.xpath_click(driver, xpath, 0)

                # ==================== 利用人数画面 ======================
                # "利用人数" のテキストボックス
                xpath = "//input[@name='NUM_1']"
                util.set_textbox(driver, xpath, '10', 0)
                
                # "次へ" のリンクを取得
                xpath = "//input[@alt='次へ']"
                util.xpath_click(driver, xpath, 0)

                # ==================== 予約確認画面 ======================
                # "予約" のリンクを取得
                xpath = "//input[@alt='予約']"
                util.xpath_click(driver, xpath, 0)
            
                # ==================== 内容確認ダイアログ ====================== 
                util.popup_click(driver, util.POPUP_DISMISS)
                logger.info(f'＝＝＝＝＝  {util.SELECT_MONTH}月{str(index)}日の予約は完了しました ＝＝＝＝＝')
                
                # ==================== 画面戻し作業(1) ======================
                # "戻る" のリンクを取得
                xpath = "//input[@name='btn_back']"
                util.xpath_click(driver, xpath, 0)
                
                # ==================== 画面戻し作業(2) ======================
                # "戻る" のリンクを取得
                xpath = "//input[@name='btn_back']"
                util.xpath_click(driver, xpath, 0)

                # ==================== 画面戻し作業(3) ======================
                # "戻る" のリンクを取得
                xpath = "//input[@name='btn_back']"
                util.xpath_click(driver, xpath, 0)
            
            else:
                # "閉じる" のリンクを取得
                xpath = "//input[@name='btn_close']"
                util.xpath_click(driver, xpath, 0)
                logger.info(f'＝＝＝＝＝  A面・B面が空いていない為、{util.SELECT_MONTH}月{str(index)}日の予約はできませんでした ＝＝＝＝＝') 
            
        else:
            logger.info(f'＝＝＝＝＝  19:00が空いていない為、{util.SELECT_MONTH}月{str(index)}日の予約はできませんでした ＝＝＝＝＝')    
    
    
    # ==================== 終了報告 ====================== 
    print("\n★終了★")



if __name__ == '__main__': 
    logger.debug('============== START ==============')
    main()
    logger.debug('============== END ==============')
```

### デバッグモードでブラウザを起動する  
**Selenium**を使用して操作を実行する際には、デバッグモードのブラウザを起動する必要があります。以前の記事では、コマンドプロンプトでデバッグモードを起動した後にPythonプログラムを実行していましたが、今回は利便性を考慮し、Pythonプログラム内でブラウザを起動する方法を採用しました。  
非同期処理を実現するために、`run()`ではなく`Popen()`を使用しています。

```python:
# --- 先ず、以下を実行し、デバッグモードのブラウザを立ちあげる。 ---
command = r'"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir=C:\selenium\chrome-profile'
subprocess.Popen(command, shell=True)
```


### XPathの利用  
**Selenium**では、**XPath**という概念があり、正規表現を活用して要素を取得できることがわかりました。  
そこで、今回の各処理では正規表現を用いて要素を指定する方法を採用しています。  
正規表現の詳細な説明については、他の参考サイトをご覧いただくことをお勧めします。

```python:
    ...
    xpath = "//input[@class='clsImage']"
    ...
    xpath = "//input[@alt='予約画面へ']"
    ...
```
### ユーザーIDとパスワードの管理  
**GitHub**にソースコードを公開していますが、セキュリティ上の観点から、ユーザーIDやパスワードを直接ソースコードに記載するのは避けるべきです。  
そこで、環境変数を利用して対応する方法を採用しました。  

お使いのPCの環境変数設定にて、以下の設定を行ってください：  
- `KYOTO_FACILITY_USERNAME`: ユーザーID  
- `KYOTO_FACILITY_PASSWORD`: パスワード  

これにより、安全に認証情報を管理できます。


```python:
xpath = "//input[@name='txt_usr_cd']"
util.set_textbox(driver, xpath, util.KYOTO_FACILITY_USERNAME, 0)

# "パスワード" のテキストボックス
xpath = "//input[@name='txt_pass']"
util.set_textbox(driver, xpath, util.KYOTO_FACILITY_PASSWORD, 0)
```



### 条件分岐の実装  
予約状況がリアルタイムで変化する可能性を考慮し、柔軟に対応できるよう条件分岐を組み込みました。

```python
# ************ 条件設定 ************
# "予約可能" のリンクを取得
#    image000000000：9:30開始   image000001000：13:00開始    image000002000：16:00開始   image000003000：19:00開始
xpath = "//img[@name='image000001000' and @alt='予約可能']"
select_element = util.get_elements_xpath(driver, xpath)
```
上記は、`img`タグの属性`name`と`alt`が合致したものを取得するようにしています。
該当するエレメントが取得できなければ、次に進むようにしています。

```python
# ************ 条件設定 ************
# "予約可能" のリンクを取得
# 　　　image0：全面  image1：A面  image2：B面
# xpath = "//img[( @name='image1' or @name='image2' ) and @alt='予約可能 ']"
xpath = "//img[@name='image0' and @alt='予約可能 ']"
select_element2 = util.get_elements_xpath(driver, xpath)
```

上記のコードでは、`img`タグの属性`name`が`image1`または`image2`であり、さらに`alt`属性が「予約可能」となっている場合に、該当する要素を取得する仕組みを実現しています。  
このように、正規表現を用いることで、`AND`や`OR`条件を簡潔に表現することが可能です。

## util.py

**selenium**を使用したプログラムでは、`elements = driver.find_elements(By.XPATH, ...)`のようなコードを頻繁に記述する必要があります。このような冗長なコードを一元管理するために、`util.py`というモジュールを新たに作成しました。  

このモジュールには、「リンクテキストから要素を取得してクリックする」「XPathから要素を取得してクリックする」など、よく使われる処理を汎用的にまとめて記述しています。  
これにより、コードの記述量を減らすだけでなく、別のプログラムでも簡単に再利用できる構成となっています。

```python:util.py
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import Select
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import time
import os
import logger

SLEEP_TIME = 3

KYOTO_FACILITY_USERNAME = os.environ.get('KYOTO_FACILITY_USERNAME')
KYOTO_FACILITY_PASSWORD = os.environ.get('KYOTO_FACILITY_PASSWORD')

SELECT_MONTH = 2
SELECT_DAY = [4, 6, 13, 18, 20, 25, 27]

POPUP_ACCEPT = 1
POPUP_DISMISS = 2



def link_click(driver, link_text, index):
    logger.debug(f'link_text START {link_text}')
    # エレメント取得
    basketball_element = driver.find_elements(By.LINK_TEXT, link_text)
    # クリック
    basketball_element[index].click()
    logger.debug(f'link_text END {link_text}')
    time.sleep(SLEEP_TIME)


def xpath_click(driver, xpath_text, index):
    logger.debug(f'xpath_click START {xpath_text}')
    # エレメント取得
    element = driver.find_elements(By.XPATH, xpath_text)
    # クリック
    element[index].click()
    logger.debug(f'xpath_click END {xpath_text}')
    time.sleep(SLEEP_TIME)


def get_elements_xpath(driver, xpath_text):
    logger.debug(f'get_element_xpath START {xpath_text}')
    # エレメント取得
    elements = driver.find_elements(By.XPATH, xpath_text)
    logger.debug(f'get_element_xpath END {xpath_text}')
    return elements


def popup_click(driver, f):
    logger.debug(f'popup_click START {f}')
    alert = driver.switch_to.alert
    if f == POPUP_ACCEPT:
        alert.accept()
    else:
        alert.dismiss()
    logger.debug(f'popup_click END {f}')
    time.sleep(SLEEP_TIME)


def switch_window(driver, all_windows, original_window):
    logger.debug(f'switch_window START')
    for window in all_windows:
        if window != original_window:
            driver.switch_to.window(window)
            break
    time.sleep(SLEEP_TIME)
    logger.debug(f'switch_window END')


def set_textbox(driver, xpath_text, value, index):
    logger.debug(f'set_textbox START {value}')
    # エレメント取得
    textbox = driver.find_elements(By.XPATH, xpath_text)
    textbox[index].clear()
    textbox[index].send_keys(value)
    logger.debug(f'set_textbox END {value}')
    time.sleep(SLEEP_TIME)
```
# 実行

プログラムを実行すると、処理が正常に完了することが確認できます。Webブラウザが完全に処理を終えてから次のステップに進むため、3秒程度の間隔を置いて処理を実行しています。

```python
    time.sleep(SLEEP_TIME)
```

さらに、今回は予約が本当に完了してしまうと費用が発生するため、プログラムの最後でキャンセル処理を追加しています。

```python
# ==================== 内容確認ダイアログ ====================== 
util.popup_click(driver, util.POPUP_DISMISS)
```


# おわりに

今回は、娘のためにプログラムを作成できて、嬉しいというよりもホッとしました。私が所属する会社では、**RPA**を推進しており、「UI-PATH」や「Power Automate」などのツールを使用することを推奨していますが、重要な部分で操作がうまくいかないなど、多くの課題に直面していました。その一方で、**selenium**は非常に柔軟で、こうした問題を解決してくれました。

今回作成した`util.py`は、他のプログラムや処理にも応用可能で、今後のコーディングがよりスムーズになると考えています。ぜひ皆様にも役立てていただけると嬉しいです。

最後までお読みいただき、ありがとうございました。