やもりの技術ブログ

日々の生活で行った技術的な活動や日頃感じたことを書きます。

【開発】予約の空きやキャンセルがでたらLINEに通知するシステムを作った話

こんにちは、やもり(yamori-tech)です。

開発として「予約の空きやキャンセルがでたらLINEに通知するシステムを作った話」について書こうと思います。他サイトでも、大体同じような流れでできると思います。

f:id:yamori-tech:20210808113317p:plain

開発システムからLINEへ通知される内容


結論としては、サーバ(CentOS)で対象サイトの予約ページを定期的にスクレイピングし、予約可能な日時を見つけたらラインへ通知するようにしました。

またスクレイピングでありがちな「ブラウザのバージョン変更ごとに手動でChromeDriverを更新しなくてはならない煩雑さ」を回避するため、

今回は ChromeDriverManager() を導入しました。

なぜ作ったか

このシステムを作った理由は、『誰よりも早く空きやキャンセル状況を把握して、予約したい、それだけだったんだ。。』 。以上です。

というと、少し乱暴なので、近況を交えながら思い至った背景を説明します。

最近、習い事をはじめました。

習い事を初めて気づいたのは、習い事のレッスン予約は、想像以上に争奪戦だということです。

どうしてそう思うのかというと、習い事先の運営上の制限だと思うので仕方ないですが、例えば、

  • レッスン予定日は1週間先までwebで公開されており、各自が個別に予約する必要
  • 今月予定のレッスンは、今月頭時点で、全日程が予約済み(web公開範囲[1週間]を超えて予約したいと確認すると、1ヶ月埋まっていますと受付に回答されました)
  • 不定期だが予定になかったレッスンが追加されることもある
  • 受付から「キャンセル待ちや不定期の追加レッスンをこまめに確認し、空いたらレッスン予約するように」と指示される
  • 各自でwebをこまめにチェックすること、そうしないと予約することさえできない(らしい)
  • レッスンの空き時間を示す仕組みがあるが、キャンセルなどで急遽空いた場合、即時に通知してくれるシステムはない
  • システムからだと、1レッスン予約すると、次回レッスンの予約ができない*1
  • etc...

 ということがありました。

はて、…私は一体、いつになったらレッスン受けられるのかしら、上達したいよ〜><。

などと、一抹の不安がよぎると共に『レッスン予約可能な日時をすぐ知れたら予約できるのに。。』という気持ちが湧き上がり、

 システムつくろ。

に至ります。

こうして誰よりも早くレッスンを受けたい私は、レッスンで習った内容の復習などそっちのけで、システムを作ることに専念しました。←クズ

どのように作ったか

『レッスン予約可能な日時をすぐ知れたら予約できるのに。。』という要望を満たすためにシステムが満たすべき要件(機能)を、以下としました。

  • 要件1:対象ページにアクセスして解析(スクレイピング)し、レッスン予約状況を検知すること
  • 要件2:検知結果をユーザへ通知すること
  • 要件3:通知結果を受けたあと、ユーザが対象のページへ飛べること
  • 要件4:定期的に要件1&2を行うこと

要件3は必須でない気がしますが、一応入れておきます。

整理した要件を踏まえて、システムの方針と概要について整理します。

方針とシステム概要

各種要件を満たすため、Seleniumbeautifulsoup(bs)でのスクレイピング と LineNotifyの通知機能を利用し実現します。

Selenium を使う理由は、要件1を満たすため、対象ページにJavascript が使われてるために利用します*2

LineNotifyを選んだ理由は、要件2、3のためであり、実装が簡単なことと、利用者のわたし自身がライン通知を逃さず確認する習慣があるためです。またLineNotifyの実装方法を学んでおきたいという気持ちもあります。

要件4については、システム用のサーバ(CentOS)でcronを利用し実現することとします。

システムの全体概要は以下になります。

f:id:yamori-tech:20210522214249p:plain

全体概要(overview)

 

システム設計上の考慮点

スクレイピングについて
  1. アクセス回数
    今回の対象ページではクローラでのクロールが許可されていたため、アクセス自体は問題ありませんが、あまりに頻繁なアクセスは対象ページへの負荷になります。そこで本システムでは、対象ページの負荷にならないように1時間に数回程度までアクセス回数を制限します。
  2.  クロール挙動の自然さ
    スクローリングやボタン操作を、ある程度ですが自然に人間が行っているように念の為、工夫します。
  3. 画面表示せず動作するためにサーバ環境を整備
    サーバ(CentOS)でスクレイピングするには headlessモード(画面を表示させずに動作する方式)を導入する必要があるため、環境を整備します。
  4. ChromeDriverManager()の導入
    冒頭でも述べた「ブラウザのバージョン変更ごとに手動でChromeDriverを更新しなくてはならない煩雑さ」解消のために導入します。
LineNotifiyについて

こちらは特にありませんが、強いて言えばメッセージが見やすいように整形すること、トークンをソースコードに直接書き込まず別ファイルで取り込むようにする、を意識しました。別ファイルから値を取り込む仕組みについては今回記載しません。

細かな追加機能は利用しながら、欲しくなったら追加する形とし、実装を進めます。 

cron利用を踏まえた環境構築方針

cron利用を想定するに当たり、sudo による環境構築を行います。ユーザごとに設定できるようですが、今回は管理者権限で問題ないと考えるためです。

構築環境

スクレイピング環境の構築

スクレイピング環境の構築は、先人の方々が様々行っておられます。

下記非常に参考になりました。ありがとうございます。

詳細については上記の各リンクにおまかせし、私の方では概略のみ記載します。

 yumGoogle Chrome をインストールするために、リポジトリを追加
% sudo vim /etc/yum.repos.d/google-chrome.repo
以下をコピペ
[google-chrome]
name=google-chrome
baseurl=http://dl.google.com/linux/chrome/rpm/stable/x86_64
enabled=1
gpgcheck=1
gpgkey=https://dl-ssl.google.com/linux/linux_signing_key.pub
 yum で Google Chrome をインストールする
 % sudo yum install google-chrome-stable 
pip で Google Chrome をインストールする

pip で selenium, webdriver-manager, beautifulsoup4をインストールします。

cronを利用するため、sudo (su)でのインストールとします。

% sudo pip install selenium 
% sudo pip install webdriver-manager
% sudo pip install beautifulsoup4

スクレイピング実装

対象ページの構成などにより、Seleniumやbsの実装内容が変わるため、ここでは以下をメインに取り扱います。2については、本システムでの必要操作に限定し記載します。

  1. 対象ページへのアクセス方法
  2. 対象ページの操作
  3. 対象ページの解析(予約可能日の抽出)

最初にアクセス・操作・解析を行うclassを定義し、実装します。

コンストラクタとデストラクタを作成しただけのクラス雛形を作成しました。

ここに各処理を追記していきます。

# -*- coding: utf-8 -*-
import time
from selenium import webdriver
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support.select import Select

class WebAccess:
   site_url = None user_id = None user_password = None driver = None

# constract(initialization)
   def __init__(self, val, user_id, user_password):
   print('-- init WebAccess --')
   self.site_url = val
   self.user_id = user_id
   self.user_password = user_password
   print('site_url:', self.site_url)
   print('user_id:', self.user_id)
   print('user_password:', self.user_password)

# Destructor
def __del__(self):
   
print("-- del WebAccess -- ")
    self.driver.quit()
   
del self.site_url, self.user_id, self.user_password, self.driver

def
main():
   print('Hello!')
   
# -------------------------------------------------
   # 初期値の読み込み
   # -------------------------------------------------
   site_url = 'hoge.com' ## 対象ページurl
   user_id, user_password = 'id xxxxxxx', 'pass xxxxxxxxx' ## id と パスワード
   # -------------------------------------------------
   # クラス生成
   # -------------------------------------------------
   web_ac = WebAccess(site_url, user_id, user_password) 
対象ページへのアクセス方法

得られたurlを確認し、ログイン処理に向けwebdriverを作成します。ブラウザに対応したドライバを自動的に落とし、手作業更新の煩雑さをなくすためChromeDriverManagerを利用します。

ポイントは、オプション設定に--headlessモード以外に「--no-sandbox」を追加することです。CentOS実行では必要なオプションになります。

def get_url(self):
        print('-- get_url --')
        print('site_url:', self.site_url)
        print('date:', datetime.today())
# --headlessの設定(画面表示せず動作するための設定) options = webdriver.ChromeOptions() options.add_argument('--headless') options.add_argument('--no-sandbox') # CentOS実行では必要、ハマった # ChromeDriverManagerの設定
# chrome ブラウザに対応したドライバを落としてくれるので利用

self.driver = webdriver.Chrome(ChromeDriverManager().install(), options=options) self.driver.get(self.site_url) time.sleep(5)

ここから先の実装は、好みで関数化し先のClassへ追記すると良いと思いますが、以下では関数化を考えず、基本的な実装を記載します。

driver 設定ができたので、id と パスワードを利用しログインします。chromeのinspectを使って、idとパスワードを入力するフォームの名前を調べ、find_element_by_nameで指定して自分のidとパスワードを入力し、ログインボタンを押して、ログインします。

find_element_by_name の使い方は、下記サイトが参考になります。

www.seleniumqref.com

#inspect で調べた結果を name 指定で入れる
#name 内容は適宜変更 id_box = self.driver.find_element_by_name("USERID") id_box.send_keys(self.user_id) pass_box = self.driver.find_element_by_name("USERPASSWD") pass_box.send_keys(self.user_password)
#送信ボタンをクリックする submit_button = self.driver.find_element_by_xpath("//input[@type='submit']") submit_button.click()
対象ページの操作

実装した操作内容に限定して記載します。

  • ボタンのクリック
    送信ボタンのクリック、として先程記載したので省略します。
  • ドロップダウン操作
    先と同様に chrome の inspectでドロップダウンの名前を調べ指定します。
    以下ではドロップダウンで選びたい内容を、select_by_visible_text を利用して指定しています。text以外でも番号でしていすることが可能です*3
#ドロップダウン箇所の選択
dropdown = self.driver.find_element_by_name("LESSONTYPE") select = Select(dropdown)
#ドロップダウンで選びたい内容を指定する select.select_by_visible_text("CHOICE1"
  • 戻る操作
    データが落ちてこなくて、一度前画面に戻りたいときに利用します 
self.driver.back()
  • スクロール操作 
self.driver.execute_script("window.scrollTo(0, 600);")  # スクロールが
  • 画面を閉じる操作
self.driver.quit()
  • ブラウザの待ち操作
    データ表示が遅い場合に利用します。
wait = WebDriverWait(self.driver, 5)  # Timeout 5秒(最大待ち時間)
対象ページの解析(予約可能日の抽出)

予約可能時間がhtmlのtable(class指定)で記載されていたため、bsでテーブルデータを抽出します。基本的には下記で取得できますが、tr か td で取得するかやidで取得するかは、対象ページによるため、適宜修正します。

html = self.driver.page_source.encode('utf-8')
soup = BeautifulSoup(html, "html.parser")
table = soup.findAll("table", {"class":"tableName"})
table = table[0]
table = table.findAll("tr")

for i, tr in enumerate(table):
    tmp = tr.findAll("td")
    for k, td in enumerate(tmp):
        print(td)
# テーブル内のテキスト取得
# 今回はこの部分が予約可能日 print(td.get_text().strip())
# id 指定がある場合は、td["id"]で取得可能

今回は基本的に上記処理で取得できました。場合によっては、javascriptなどで日付計算をidに合わせて行い、表示している場合も思いますので、そのときはゴリゴリ計算します。td.get_text().strip() で取得した結果を配列や辞書などに格納したら、予約日データの取得は完了です。

LineNotify実装

LineNotify についても、多くの先人が実践結果を残しています。下記参考になりました。ありがとうございました!

詳細は上記に譲り、実装内容のみ記載します。

class LineNotify:
    token_val = ''
    line_notify_api = ''
    message = ''

    def __init__(self, val, api):
        print("-- init LineNotify --")
        self.token_val = val
        self.line_notify_api = api

    def make_message(self, info, select_val, status):
        print("-- make_message --")
        self.message = ''
        self.message += '\n'
        self.message += "{}{}スケジュールを確認するよ!\n\n".format(select_val, status)
        if len(info) == 0:
            self.message += "{}日はないよ、残念。。また教えるね。".format(status)
            return None
        else:
            self.message += "{}日にち(曜日):時間:講義番号\n".format(status)
            for i, val in enumerate(info):
                self.message += val[0] + "(" +val[1] + "):" + val[2] + ":" + val[3] + "\n\n"
            return self.message

    def send_line_notify(self, notification_message):
        print('-- send_line_notify --')
        if notification_message is None:
            return
        line_notify_token = self.token_val
        line_notify_api = self.line_notify_api
        headers = {'Authorization': 'Bearer {}'.format(line_notify_token)}
        data = {'message': 'message: {}'.format(notification_message)}
        requests.post(line_notify_api, headers = headers, data = data)def main():

def main():
   print
('Hello!')
   # -------------------------------------------------
   # 初期値の読み込み
   # -------------------------------------------------
   token_val, api = 'yyyyyzzzzz', 'api_value' ## id と パスワード
   # -------------------------------------------------
   # クラス生成
   # -------------------------------------------------
   web_ac = WebAccess(site_url, user_id, user_password) line_notify = LineNotify(token_val, api) message = line_notify.make_message(reservation_info, '授業', '') line_notify.send_line_notify(message)

cronの設定方法

cron設定例を以下に示します。今回は1時間に数回程度なので、10分おきに実行するようにします。また実行時のログをlogファイルに保存するようにします。

cron の状況を確認します。

% service crond status
crond.service - Command Scheduler
Loaded: loaded (/usr/lib/systemd/system/crond.service; enabled; vendor preset: enabled)
Active: active (running) since Wed 2021-05-19 23:29:45 JST; 4 days ago
...

active であれば、cronは動いてます。動いていない場合は、service crond start を実行してください。

/etc/cron.d/ に適当な名前のファイルを作成し、下記を記載し保存します。

10分毎に指定ファイルをroot実行し、実行結果を .log として保存します。

% vim /etc/cron.d/yamori_reservation

*/10 * * * * root sh -c '/usr/bin/python3 /var/yamori_lesson_reservation/main.py' >> /var/log/reservation_lesson.log

設定が終わったら、cronを再起動します。

% systemctl restart crond.service 

以上で完了です。

動作状況については、動画を見ていただければと思います。 

まとめ

Sereniumやbs、LineNotify、cronを利用して、予約の空きやキャンセルがでたらLINEに通知するシステムを作成しました。 実際にキャンセル待ちだったレッスンを本システムで検知し、予約・受講することができました。

これで安心してレッスンの予約や復習をしながら勉強できます。

*1:予約できる回数に制限が設けられている。※色んな人を幅広く回すためなんでしょうね

*2:スクレイピングを行う際は対象ページが、クローラによるクロールを許可しているかどうかを事前に確認し実施してください。

*3:

yuki.world