LineMessagingAPIを利用してトーク上で乗り換え案内の結果を送信する

# はじめに
yahoo天気予報をLineMessagingAPIを使用して、トーク上でやりとりする記事を投稿してから
長い時間が経ってしまいましたが、久しぶりに進捗が出たので投稿しようと思います。

今回は、本来の目標でもあったyahoo乗り換え案内をLINE上でやりとりを行う方法について
説明していこうと思います。

# 設計・構造について
どのような方法で実装しようかと考えたのですが、大体二案でした。

* スクレイピング
* yahoo Web API

まず、yahoo天気予報の際にも使用したスクレイピングです。
こちらは一番慣れていることと、手軽に実装することができるというところから第一候補でした。
ただし、方法によってはアクセス数過多になったりすることがありそうなので、
もしも色々な方に使ってもらえるように開発するとなると、この方法はいまいちかもしれません。

代わりに、二つ目の案であるyahooのAPIを使用した場合であれば、アクセスのリミットなども
定められているため広く普及させる機会ができれば、こちらを使用して開発したいなと考えています。
今回もこっちの方法で開発すればよかったのでは？と思った方がいそうですが、
とりあえず自分で実装してみたいということなのでスクレイピングの方法を採用しました。
~~（ちょっとモチベが見出せなかったことと、公式ドキュメントを読んでもあまり理解できなかったとは言えない、、）~~


#駅データのDBテーブル作成
構造は決まりましたので、次は駅名のDBが必要です。
今回使用したデータは[こちらのサイト](https://opendata-web.site/station/)からいただきました。
こちらのデータをbot側の選択肢として表示、照合を行っていきます。

## 作成したテーブル
* 駅名を保存するテーブル(table名：stations)
    * column１：station_name(駅名),String
* 入力したデータを保存するテーブル(table名：transit_input)
    * column１：user_id(ユーザーのID),String
    * column２：departure_station(出発する駅),String
    * column３：arrival_station(到着する駅),String
    * column４(オプション１)：date_and_time(日時),String
    * column５(オプション２)：condition(出発or到着など),Integer
    * column６(オプション３)：show_rule：(条件のうち表示順序について),Integer

#処理の流れ
![Untitled Diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/701238/d1ff3387-8599-0a24-919f-a36f2b0bd45a.png)

# コード
さて、ながれを説明したところで実際に使用したコードを記載します。
## メッセージ受信時のコード

``` python
# リッチメニューで乗り換え案内を選択した場合(乗り換え案内１)
# ユーザー登録、出発or到着の選択
if received_message == "乗り換え案内":
    user_id = str(event.source.user_id)
    used_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    commonDB.create_data(user_id)
    commonDB.update_data(user_id, used_time, received_message)
    transitDB.create_data(user_id)
    transitMethod.condition_turn(line_bot_api, event)

# 検索条件選択後の処理(乗り換え案内２)
# 出発or到着の登録、時間の指定
if received_message == "出発" or received_message == "到着":
    user_id = str(event.source.user_id)
    if transitDB.exist_record(user_id, "start_up"):
        transitDB.update_data(user_id, received_message, "condition")
        transitMethod.date_and_time_turn(line_bot_api, event)
        transitDB.update_data(user_id, None, "departure_station")
        transitDB.update_data(user_id, None, "arrival_station")

# 出発駅選択後の処理(乗り換え案内４、５)
# 出発駅の入力が初めての場合、出発駅の登録
# 到着駅の入力が初めての場合、到着駅の登録
# どちらも登録されている場合は出発->到着の順に入力のループ
if received_message in list(station_list["station_name"]):
    user_id = str(event.source.user_id)
    if transitDB.exist_record(user_id, "date_and_time"):
        if transitDB.exist_record(user_id, "departure_station") and \
                transitDB.exist_record(user_id, "arrival_station"):
            transitDB.update_data(user_id, None, "departure_station")
            transitDB.update_data(user_id, None, "arrival_station")

        if transitDB.exist_record(user_id, "departure_station"):
            transitDB.update_data(user_id, received_message, "arrival_station")
            transitMethod.options_turn(line_bot_api, event)

        else:
            transitDB.update_data(user_id, received_message, "departure_station")
            transitMethod.arrival_turn(line_bot_api, event)

# 表示条件選択後の処理(乗り換え案内６)
# 表示条件選択した場合、表示条件の登録
if received_message in ["到着が早い順", "乗り換え回数順", "料金が安い順"]:
    user_id = str(event.source.user_id)
    if received_message == "乗り換え回数順":
        rule_number = 1
    elif received_message == "料金が安い順":
        rule_number = 2
    else:
        rule_number = 0

    if transitDB.exist_record(user_id, "arrival_station"):
        transitDB.update_data(user_id, rule_number, "show_rule")
        transitMethod.result(line_bot_api, user_id, event)

# postback受信時のメソッド
@handler.add(PostbackEvent)
def handle_postback(event):
    if isinstance(event, PostbackEvent):
        # 時間選択後の処理(乗り換え３)
        # 時間の登録、出発駅の選択
        user_id = str(event.source.user_id)
        if transitDB.exist_record(user_id, "condition"):
            select_date = transitMethod.time_received(event).strftime("%m-%d %H:%M")
            transitDB.update_data(user_id, select_date, "date_and_time")
            transitMethod.departure_turn(line_bot_api, select_date, event)
```

こちらは、Line側に送られたメッセージを処理するコードの塊です。
大まかな流れは、コメントアウトの部分に番号が振ってあります。
以前記事にした、天気予報を取得するコードに似たようなところが多いですが、時間を選択形式で
入力するため一部だけPostbackEventを受け取った際に実行されるコードが必要となりました。
そのためhandle_addで受信するイベントを追加しています。

## 次の入力操作を促すコード

``` python
from datetime import datetime, timezone, timedelta
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from bs4 import BeautifulSoup
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage, QuickReplyButton, MessageAction, QuickReply,
    TemplateSendMessage, ButtonsTemplate, DatetimePickerTemplateAction
)
from DB import transitDB


# 検索条件の選択（出発or到着）
def condition_turn(line_bot_api, event):
    items = [QuickReplyButton(action=MessageAction(label=f"{con}", text=f"{con}")) for con in ["出発", "到着"]]
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text="検索条件を入力してください",
                        quick_reply=QuickReply(items=items))
    )


# 日付の選択
def date_and_time_turn(line_bot_api, event):
    # タイムゾーンの設定
    jst = timezone(timedelta(hours=9))
    # 現在時刻を取得する(日本標準時JST)
    now = datetime.now(tz=jst)

    # 乗り換え案内選択時の日時取得
    initial_date = datetime.now(tz=jst)
    string_initial_date = initial_date.strftime("%Y-%m-%d" + "T" + "%H:%M")

    # 現在時刻よりも7日前の日付
    before_date = initial_date - timedelta(days=7)
    string_before_date = before_date.strftime("%Y-%m-%d" + "T" + "%H:%M")

    # 現在時刻よりも7日後の日付
    after_date = initial_date + timedelta(days=7)
    string_after_date = after_date.strftime("%Y-%m-%d" + "T" + "%H:%M")

    date_picker = TemplateSendMessage(
        alt_text="時刻の設定",
        template=ButtonsTemplate(
            text="時刻の設定",
            actions=[
                DatetimePickerTemplateAction(
                    label="select date",
                    data="backId=12345",
                    mode="datetime",
                    initial=string_initial_date,
                    min=string_before_date,
                    max=string_after_date
                )
            ]
        )
    )

    line_bot_api.reply_message(
        event.reply_token,
        date_picker
    )


# 入力された日付の受け取り
def time_received(event):
    # 「yyyy-mm-ddThh:MM」の日付形式
    # dictのキーはmodeのもの
    # 取得しやすいように一旦datetime型に変更
    # year = select_date.yearなどで一部だけ取ることも可能
    return datetime.fromisoformat(event.postback.params["datetime"])


# 入力された日付の確認送信
def time_confirm(li_bot_api, event):
    li_bot_api.reply_message(
        event.reply_token,
        TextSendMessage()
    )


# 出発駅の処理案内
def departure_turn(line_bot_api, select_date, event):
    line_bot_api.reply_message(
        event.reply_token,
        [
            TextSendMessage(text=select_date),
            TextSendMessage(text="出発駅を入力してください")
        ]
    )


# 到着駅の処理案内
def arrival_turn(line_bot_api, event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text="到着駅を入力してください")
    )


# 表示条件の処理案内
def options_turn(line_bot_api, event):
    items = [
        QuickReplyButton(
            action=MessageAction(label=f"{con}", text=f"{con}")
        ) for con in ["到着が早い順", "乗り換え回数順", "料金が安い順"]
    ]
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(
            text="オプションを選択してください",
            quick_reply=QuickReply(items=items)
        )
    )


def result(line_bot_api, user_id, event):
    result_data = transitDB.select_userdata(user_id)
    departure_station = result_data.iat[0, 1]
    arrival_station = result_data.iat[0, 2]
    show_rule = result_data.iat[0, 5]

    line_bot_api.reply_message(
        event.reply_token,
        [
            TextSendMessage(
                text=departure_station + "駅から" + arrival_station + "駅への直近の電車を調べます..."
            ),
            TextSendMessage(
                text=transit_scraping(departure_station, arrival_station, show_rule)
            )
        ]
    )


def transit_scraping(departure_station, arrival_station, show_rule):
    options = Options()
    options.add_argument('--headless')
    driver = webdriver.Chrome(options=options)
    # 対象ページにアクセス
    URL = "https://transit.yahoo.co.jp/"
    try:
        driver.get(URL)

        # 出発駅と到着駅の入力
        driver.find_element_by_id("sfrom").send_keys(departure_station)
        driver.find_element_by_id("sto").send_keys(arrival_station)

        # 検索ボタンをクリックする
        driver.find_element_by_id("searchModuleSubmit").click()

        # 検索結果のページのHTMLをBeautifulSoupに流し込む
        soup = BeautifulSoup(driver.page_source, "html.parser")

        # 時間が書かれた部分をCSSセレクタで指定し、テキストを抜き出す
        time = soup.select(".routeSummary li.time")[0].select("span")[0].text
        return time

    finally:
        driver.close()
```

はい、かなり長いです。
ただやっていることは非常に単純明快です。
lineのメソッドとして使っているのは、reply_messageであったりQuickReplyButton、
そして時間を選択することができるDatetimePickerTemplateActionです。
特に、DatetimePickerTemplateActionはラベルやモードなどの変更が
まだまだ全て把握できていないところなので、是非とも調べてみてください。
おおまかな流れとしましては、前のコードで乗り換え案内の入力規則・順序に則った文字列が入力されるとその文字列の次のデータ入力を促すといったことを行なっているコードになっています。

## DBコード
そして最後にDBに関するコードです。

```python
import sqlite3
import pandas as pd


# DBから駅名一覧を取得するメソッド
def station_list():
    # コネクタ作成。dbnameの名前を持つDBへ接続する。
    conn = sqlite3.connect("weatherDatabase.db")
    cur = conn.cursor()

    # ここから好きなだけクエリを打つ
    table = cur.execute("select * FROM stations;")
    data = table.fetchall()

    # 処理をコミット
    conn.commit()

    # 接続を切断
    conn.close()

    # select結果をリストからデータフレームにして返却
    data_list = []
    for i in data:
        data_list.append(list(i))
    return pd.DataFrame(data_list,
                        columns=["station_name", "ruby"])


# user_idは処理を要求してきたユーザのID
# CREATE TABLE transit_input(user_id STRING PRIMARY KEY, departure_station STRING, arrival_station STRING,
# date_and_time STRING, condition INTEGER, show_rule INTEGER)
# CREATE TABLE stations(station_name STRING, ruby STRING)
def create_data(user_id):
    conn = sqlite3.connect("weatherDatabase.db")

    cur = conn.cursor()
    # IDが登録されていなければtry文、登録されていればレコードを削除して再度登録
    try:
        cur.execute("INSERT INTO transit_input(user_id) values(?);", (user_id,))
    except sqlite3.IntegrityError:
        cur.execute("DELETE FROM transit_input WHERE user_id = ?;", (user_id,))
        cur.execute("INSERT INTO transit_input(user_id) values(?);", (user_id,))
    finally:
        # データベースへコミット。これで変更が反映される。
        conn.commit()
        conn.close()


# user_idは処理を要求してきたユーザのID
def exist_record(user_id, column):
    conn = sqlite3.connect("weatherDatabase.db")
    cur = conn.cursor()

    try:
        # ユーザーレコードの確認->出発か到着の確認->時間の確認->出発駅の確認->到着駅の確認->優先的に表示する指定があるかの確認
        if column == "start_up":
            cur.execute("SELECT * FROM transit_input WHERE user_id = ?;", (user_id,))
        elif column == "condition":
            cur.execute("SELECT * FROM transit_input WHERE user_id = ? and condition IS NOT NULL;", (user_id,))
        elif column == "date_and_time":
            cur.execute("SELECT * FROM transit_input WHERE user_id = ? and date_and_time IS NOT NULL;", (user_id,))
        elif column == "departure_station":
            cur.execute("SELECT * FROM transit_input WHERE user_id = ? and departure_station IS NOT NULL;", (user_id,))
        elif column == "arrival_station":
            cur.execute("SELECT * FROM transit_input WHERE user_id = ? and arrival_station IS NOT NULL;", (user_id,))
        elif column == "show_rule":
            cur.execute("SELECT * FROM transit_input WHERE user_id = ? and show_rule IS NOT NULL;", (user_id,))

        # データがあるかどうか判定
        if cur.fetchone()[0] != 0:
            return True
        else:
            return False
    except TypeError:
        return False
    finally:
        conn.close()


# user_idは処理を要求してきたユーザのID
# dataはユーザIDと一致したレコードを更新する
# column更新するカラム
def update_data(user_id, data, column):
    conn = sqlite3.connect("weatherDatabase.db")
    cur = conn.cursor()

    try:
        if column == "condition":
            cur.execute("UPDATE transit_input SET condition = ? WHERE user_id = ?;", (data, user_id))
        elif column == "date_and_time":
            cur.execute("UPDATE transit_input SET date_and_time = ? WHERE user_id = ?;", (data, user_id))
        elif column == "departure_station":
            cur.execute("UPDATE transit_input SET departure_station = ? WHERE user_id = ?;", (data, user_id))
        elif column == "arrival_station":
            cur.execute("UPDATE transit_input SET arrival_station = ? WHERE user_id = ?;", (data, user_id))
        elif column == "show_rule":
            cur.execute("UPDATE transit_input SET show_rule = ? WHERE user_id = ?;", (data, user_id))
    finally:
        # データベースへコミット。これで変更が反映される。
        conn.commit()
        conn.close()


# 乗り換え検索する際に情報を取得する
def select_userdata(user_id):
    conn = sqlite3.connect("weatherDatabase.db")
    cur = conn.cursor()

    try:
        data = []
        cur.execute("SELECT * FROM transit_input WHERE user_id = ?;", (user_id,))
        data.append(list(cur.fetchone()))
        return pd.DataFrame(
            data,
            columns=
            [
                "user_id", "departure_station", "arrival_station", "date_and_time",
                "condition", "show_rule"
            ]
        )
    finally:
        # データベースへコミット。これで変更が反映される。
        conn.commit()
        conn.close()


def delete_record(user_id):
    conn = sqlite3.connect("weatherDatabase.db")
    cur = conn.cursor()

    try:
        cur.execute("DELETE FROM transit_input WHERE user_id = ?;", (user_id, ))
    finally:
        # データベースへコミット。これで変更が反映される。
        conn.commit()
        conn.close()

```

こちらも非常に長いですが、特にやっていることはデータの作成、更新、削除などです。
あまり気にしなくても良いと思います。

# 最後に
この記事を書こうとしてからかなりの日にちが経ってしまいました、、、
実装やアイディア出しにはあまり時間がかからなかったのですが、herokuのdeployやherokuでchromedriverを使用する際の挫折によって非常に時間を取られてしまいました。
（記事の最後に備忘録として残します。）

今回記載したコードを使えば、時間・出発駅・到着駅を入力すれば一応yahoo乗り換え案内のページから情報を取ってきて、乗り換えにかかる時間を表示することができると思います。
ただし、最低限の情報しか取ることができていないので、スクレイピングの改良が必要そうです。
次回の記事はスクレイピングに関する記事になりそうです！！

長々と、書きましたがありがとうございました！！

# 備忘録
## herokuでchromedriverを使用する方法
https://qiita.com/nsuhara/items/76ae132734b7e2b352dd

主にこの記事を参考にさせていただきました。ありがとうございます！
どこで詰まったかというと、興味がある方は実際にやってみてもらえると良いのですが、デプロイ後のherokuのログを覗くと次のように表示されます。

```
/app/.heroku/python/lib/python3.8/site-packages/selenium/webdriver/firefox/firefox_profile.py:208: SyntaxWarning: "is" with a literal. Did you mean "=="?
if setting is None or setting is '':
```
、、、いってることはわかるけどどこいじるの、、、？？？
まあとりあえず、動作確認してみるか、、、と思い確認すると、特に問題なくスクレイピングの情報がlineに届く。
通るんかい！！！っていう感じでした。
まあ、chromedriverなら特に気にすることはないといったことでしょうかね。

## herokuのアプリケーション名
この見出しだけ見ると、どこで詰まる部分あるん？と思う方が大半だと思います。
ただ、一つ上の備忘録と相まってとんでもなくややこしいことになってしまったのです。
まず、この見出しの点で何をしたかというとアプリケーション名を変更しました。
当初、lineアプリの開発を始めようとしていた頃はherokuがどういうものかというのも分からないまま、
調べては実行しを繰り返していました。
そのため、アプリケーション名が```botsample```という非常にダサい名前で開発を行なっていたわけです。

そこで、久しぶりにherokuの管理画面開いたからアプリケーション名変えよう！と思い、いざ変えてみるとlineにメッセージ送っても返信返ってこず。
そこで発生していたエラーのメッセージが上の備忘録のものだったので、
勘違いしてずっとfirefoxは使っていないのにもうこの記事書けないなと思っていました。

もう一旦全部アプリケーション削除して一からやり直そうと思い、
lineの管理画面まで行き色々情報を確認していると、登録していたURLの項目がありました。そこには```https://botsample.herokuapp.com/callback```という風に設定されたままでした。
、、、おいおいおい、ここ変えたらもしかしていけるんか？と思い、
変更後のアプリケーション名に変えてみると無事にlinebotから返信が返ってくるという奇跡的な復活を遂げました。
非常に嬉しかったです。。。

ということで、変更は一度で複数のことを行わないことを改めて実感したという報告でした。