【Python】Selenium ChromeDriverでディズニーレストランのキャン待ちツールを作ってみた

#内容
[TDRレストランの予約](https://reserve.tokyodisneyresort.jp/restaurant/search/)ツールです．

レストランの予約はぴったり1ヶ月前の午前１１時から可能になります．

ただ，予約しようと思った時には既に空きがなくキャンセルによる空き待ちをする方がほとんどではないでしょうか？

当然，いつ空きが出るかなんてわからないですよね．

だからといって２４時間常にスマホと向き合ってブラウザの更新ボタンを押すわけにもいかないですよね（笑）

そ・こ・で

その**単純で長期的なめんどい作業をツール任せにしよう**というのが今回の記事の内容になります．

#システム

![system.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/377231/1169710e-87fe-9d97-1839-7b3b83a07741.png)


- Python SeleniumでChromeDriverを操作
- 定期的にお目当てのレストランの空き状況を確認
- 空きが出たら即LINEでお知らせ

フローチャート↓

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/377231/59239a0c-ea37-2b9a-30b0-c1a82677543f.png" width=300>




#開発環境

- MacOS Big Sur
- Python3.8
- Chrome 96.0.4664.110








#ソースコード

ツリー構造

```
.
├── reserveTDR.py
├── chromedriver
├── config.yaml
└── restaurant.txt
```

```python:reserveTDR.py
# coding:utf-8
from selenium import webdriver
from selenium.webdriver.support.ui import Select
from time import sleep
import requests, os, datetime, yaml, warnings, calendar
warnings.simplefilter('ignore')
from selenium.webdriver.chrome.options import Options
from pywebio.input import select, checkbox, radio, textarea, file_upload, input_group
from pywebio.output import put_markdown, put_table, put_buttons, put_image, put_text, popup, put_html, close_popup
from datetime import datetime
from datetime import timedelta
from pynotificator import DesktopNotification

# カレンダーの作成
def get_date_list():
    dt_now = datetime.now()
    year = dt_now.year
    month = dt_now.month
    day = dt_now.day
    date_list = [datetime(year, month, day) + timedelta(days=i) for i in range(calendar.monthrange(2019, 1)[1])]
    date_str_list = [d.strftime("%Y/%m/%d") for d in date_list]
    return date_str_list

# レストランのリスト作成
def get_restaurant_name():
    a = open("restaurant.txt", "r")
    restaurant_name = []
    dict_restaurant = {}
    for line in a:
        restaurant_name.append(line.rstrip().rsplit(" ")[1])
        dict_restaurant[line.rstrip().rsplit(" ")[1]] = line.rstrip().rsplit(" ")[0]
    a.close()
    return restaurant_name, dict_restaurant

# 入力フォーム
def input_form(restaurant_list):
    adult_list = list(range(1, 11))
    result = input_group("TDRモニタリング", [
        select('レストラン', restaurant_list, name="restaurant"),
        select('人数', adult_list, name="adult"),
        select('インパ予定日', get_date_list(), name="date"),
        radio("インターバル", options=["1分", "5分", "10分"], inline=True, name="interval"),
    ])
    return result

# 入力エラーの場合のポップアップ
def show_popup():
    popup('入力に不備があります', [
        put_markdown('**インターバル**を選択してください'),
        put_buttons(['Close'], onclick=lambda _: close_popup())
    ])

# モニタリング開始確認
def output(result, dict_restaurant):
    put_html('<h1>以下の内容でモニタリングを開始しました<br>予約空きが見つかればLINEでお知らせします</h1>')
    put_table([
        ["レストラン", result["restaurant"]],
        ["人数", str(result["adult"]) + "人"],
        ["インパ予定日", result["date"]],
        ["インターバル", result["interval"]],
    ])
    # YAMLファイルへ書き込む
    with open("config.yaml", "w") as yf:
        yaml.dump(result, yf, encoding='utf8', allow_unicode=True, default_flow_style=False)

# フォームの入力から実行開始まで
def form():
    while True:
        restaurant_list, dict_restaurant = get_restaurant_name()
        result = input_form(restaurant_list)
        print(result)
        if result["interval"] is None:
            show_popup()
        else:
            break
    output(result, dict_restaurant)
    dn = DesktopNotification('のモニタリングを開始しました', title='TDRモニタリング', subtitle=result["restaurant"])
    dn.notify()

# 設定ファイルの読み込み
def read_config():
    with open('config.yaml', 'r') as yml:
        config = yaml.safe_load(yml)
        print (config)
        return config

# レストランの辞書型を作成
def read_restaurant():
    dict_restaurant = {}
    a = open("restaurant.txt","r")
    for i in a:
        i = i.rstrip()
        num = i.split(" ")[0]
        name = i.split(" ")[1]
        dict_restaurant[name] = num
    a.close()
    return dict_restaurant

# LINE通知
def send_line_notify(notification_message):
    line_notify_token = '<HERE PLEASE PUT TOKEN>'
    line_notify_api = 'https://notify-api.line.me/api/notify'
    headers = {'Authorization': f'Bearer {line_notify_token}'}
    data = {'message': f'{notification_message}'}
    requests.post(line_notify_api, headers=headers, data=data)


# ブラウザ操作部分
def chrome(config, dict_restaurant):

    # ChromeDriverの起動
    options = Options()
    driver = webdriver.Chrome('./chromedriver')
    driver.implicitly_wait(10)

    try:
        # 予約トップページへ遷移
        driver.get("https://reserve.tokyodisneyresort.jp/top/")
        sleep(3)

        # "レストラン"のイメージリンクをクリック
        driver.find_element_by_xpath("//img[@src='/cgp/images/jp/pc/btn/btn_gn_04.png']").click();
        sleep(3)

        # 同意書の同意ボタンをクリック
        driver.find_element_by_xpath("//img[@src='/cgp/images/jp/pc/btn/btn_close_08.png']").click();
        driver.implicitly_wait(3)

        # 日付の指定
        driver.find_element_by_id('searchUseDateDisp').send_keys(config["date"])

        # 人数の指定
        color_element = driver.find_element_by_id('searchAdultNum')
        color_select_element = Select(color_element)
        color_select_element.select_by_value(str(config["adult"]))

        # レストランの指定
        color_element = driver.find_element_by_id('nameCd')
        color_select_element = Select(color_element)
        color_select_element.select_by_value(dict_restaurant[config["restaurant"]])

        # "検索する"をクリック
        driver.find_element_by_xpath("//input[@src='/cgp/images/jp/pc/btn/btn_search_01.png']").click();
        sleep(1)

        # ページのスクロール
        height = driver.execute_script("return document.body.scrollHeight")
        for x in range(1, height):
            driver.execute_script("window.scrollTo(0, " + str(x) + ");")
        sleep(3)

        # 検索結果から空き状況を判定
        if "お探しの条件で、空きはございません。" in driver.find_element_by_id('hasNotResultDiv').text:
            print(driver.find_element_by_id('hasNotResultDiv').text)
        else:
            print("空きが見つかりました")
            send_line_notify('空きが出ました\n')

        # ChromeDriverを閉じる
        driver.close()

    # メンテナンス中の場合
    except:
        driver.close()
        print("只今メンテナンス中です")


########### main ############
form() # 入力フォーム
while True:
    config = read_config() # 設定ファイルの読み込み
    dict_restaurant = read_restaurant() # レストランの辞書作成
    chrome(config, dict_restaurant) # ブラウザの操作
    sleep(int(config["interval"].replace("分",""))*60) #一定時間スリープ
########### main ############
```

TDRレストランのIDと名前をスペースで区切ったリストです．
このIDを`Value`名前を`Key`とした`辞書型`を作成します．
ユーザー入力時は名前で選択を行い，ChromeDriver操作時にはそれに紐づくIDでタグの指定を行います．

```:restaurant.txt
RESC0 イーストサイド･カフェ
RGAW0 グレートアメリカン・ワッフルカンパニー
RCSC0 センターストリート ･ コーヒーハウス
RJRH0 れすとらん北齋
RCPR0 クリスタルパレス・レストラン
RBBY0 ブルーバイユー・レストラン
RPLT2 ポリネシアンテラス・レストラン
RDHS2 ザ・ダイヤモンドホースシュー
RLTG0 ラ・タベルヌ・ド・ガストン
RBPP0 ビッグポップ
RMGL0 マゼランズ
RRDC0 リストランテ・ディ・カナレット
RSSD0 Ｓ.Ｓ.コロンビア･ダイニングルーム
RTRL1 テディ・ルーズヴェルト・ラウンジ
RJRS0 レストラン櫻
RHZB1 ホライズンベイ・レストラン
RCHM0 シェフ・ミッキー
REPG0 エンパイア・グリル
RHPL3 ハイピリオン・ラウンジ「期間限定ケーキセット」
RHPL5 ハイピリオン・ラウンジ 「ディズニー ツイステッドワンダーランド」スペシャルケーキセット
RHPL4 ハイピリオン・ラウンジ「プレミアムスイーツセット」
RTIC3 チックタック・ダイナー　ブレッドセレクション
RTIC2 チックタック・ダイナー　スペシャルブレッド
ROCE0 オチェーアノ／ブッフェ
ROCE1 オチェーアノ／コース
RSRG0 シルクロードガーデン
RBVL0 ベッラヴィスタ・ラウンジ
RBVL1 ベッラヴィスタ・ラウンジ／後方席（窓側から3～4列目）
RSWG0 シャーウッドガーデン・レストラン
RCAN0 カンナ
RDML2 ドリーマーズ・ラウンジ（アフタヌーンティーセット限定)
RDML4 ドリーマーズ・ラウンジ（パスタセット限定)
```

#使い方

事前準備として[**LINE Notify**](https://notify-bot.line.me/ja/)のトークンおよびご自身がお使いのChromeのバージョンに合った[**ChromeDriver**](https://chromedriver.chromium.org/downloads)を用意してください．

スクリプトの実行

```
$ python3 reserveTDR.py
```

ブラウザが立ち上がるので予約したい`日付`，`人数`，`レストラン`と`インターバル`(どれくらいの頻度で確認してほしいか)を選択して`Submit`をクリックします．これだけです！　あとは全てスクリプトが処理してくれます．定期的に確認作業を行い，空きが見つかればリアルタイムでお知らせしてくれます．

![スクリーンショット 2021-12-30 21.58.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/377231/66603078-fc07-4dd3-24b4-67344216d3e2.png)
このような通知が来ます．
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/377231/4190d889-a804-7271-227f-485e6a743bab.jpeg" width=200>

#余談

今回は`pywebio`モジュールを用いてブラウザからのユーザ入力を行い，`Selenium`モジュールを用いてブラウザの操作を行い，`LINE Notify API`によるLINE通知を実装しました．なぜ，`requests`モジュールを用いたライトなWebスクレイピングではなく，わざわざ重い`Selenium`を嚙ますのか？それはウェブサイト側のbot対策が理由です．リファラーのないリクエストをTDR側はbot認定している可能性が高いからです．リアルユーザーが行う自然な操作の流れをそのままChromeDriver上で再現することで，botとして弾かれるのを避けました．

あと，本ツールはあくまでも空きが見つかったら即連絡が来るってだけなので最後は自分で予約するってことをお忘れなく（笑）

ではまた！今年もお疲れさまでした🎉