LINE botでディズニーの待ち時間を表示する

#目次
- 概要
- 作成したLINE bot
- ディレクトリ構成
    - 構成図
    - 各ファイルの紹介
- 動作の流れ
- **リッチメニューの作成**
- パークの選択（ボタン）
- 開園チェック
- 開園中なら取得したい待ち時間のカテゴリを選択
- **スクレイピング→Flex Messageのreciptで出力**
- まとめと今後の課題

**太字**は特に苦労したところです

#概要
以下のサイトを参考にnode.jsではなくPythonでbotを作ってみました。また、スクレイピングで取得した情報は上記に載っていたサイトと同じところからいただきました。

[ディズニーの待ち時間を教えてくれるLINE botを作ってみた](https://qiita.com/daitasu/items/8f568ee72fe99899cdd6)


このサイトでは手軽に使えることを前提としているので、文字列が多いのですが逆にリッチメニューやFlex Messageを使い、欲しいデータをユーザーに選んでもらえるようにしました。
リッチメニュー、Flex Messageなどはまとまっているところがなくリファレンスを見たり、様々なサイトを往復したのでそれもまとめられればと思っています。

※この記事では初心者の私がつまずいた、初めて知ったことに重きを置いているため「LINE botでディズニーの待ち時間を表示する」こと自体はそこまで重要視していません。そのため初心者向けに細かく書いてあるので、中・上級者にとっては長すぎるかもしれませんがご了承ください。また、営利目的ではないことを先に記しておきます。

#作成したLINE bot
先に完成版を貼っておきます。よければ登録して使ってください！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551992/be555d02-eb25-797b-a100-f186b32a4fc6.png" width=25%>
直接読み込む場合は、[こちらからどうぞ](https://lin.ee/qcK9t1Y)


ソースコード類はGitHubで公開しています。スクレイピングのコードなど詳しいものが見たい場合は[こちらからどうぞ](https://github.com/ryodisney/disney_wait)


#ディレクトリ構成
###構成図
disneyというフォルダに全部入れる形にしました。.gitは隠しファイルとなっていたためここには出てきていませんがdeploy.batと同階層においてあります。

```
disney
├  deploy.bat
├  main.py
├  scrape_requests.py
├  makejsonfile.py
├  Procfile
├  runtime.txt
├  requirements.txt
│  
└─templates
        land_theme.json
        recipt.json
        sea_theme.json
        theme_select.json
```
###各ファイルの紹介
LINE botを動かす部分は後で説明するので、設定ファイルの中身を下に載せます。

**deploy.bat**：これは変更版をHerokuにデプライするときにいちいちコマンドを打たなくていいようにするものです。

```bat:deploy.bat
git add . && git commit -m 'Improve' && git push
```
**Procfile**：Herokuにプログラムの起動方法を教えるための設定ファイル、Procfileを作成します。コマンドプロンプトでカレントディレクトリまで移動した後に、下のコマンドを入力してください。この時main.pyと書いてあるところに最初に起動するものを入れてください。名前はmain.pyでなくて大丈夫です。

```Procfile:Procfile
echo web: python main.py > Procfile
```
**runtime.txt**：使用するPythonのバージョンをここに記します。

```txt:runtime.txt
python-3.7.0
```
**requirements.txt**：Pythonで使うモジュールの中でpip installしたものはここに書いておきます。これでHeroku側でもこれらのモジュールが使えるようになります。

```txt:requirements.txt
Flask==1.1.1
line-bot-sdk==1.15.0
requests==2.21.0
bs4==0.0.1
lxml==4.4.2
```

※余談になりますが、スクレイピングにselenium,chromedriverを使うときはHerokuのSettingにあるBuildpacksにこれらを追加する必要があります。add buildpacksを押した後に以下のURLを貼り付けてください。

```
https://github.com/heroku/heroku-buildpack-chromedriver.git
https://github.com/heroku/heroku-buildpack-google-chrome.git
```

#動作の流れ
1. ホームボタンを押してもらう（後述のリッチメニュー）
2. パーク選択
3. 開園チェック
4. 開園中なら取得したい待ち時間のカテゴリを選択（後述のリッチメニュー）
5. スクレイピング→Flex Messageのreciptで出力

#リッチメニューの作成
下の画像に出ている6分割の部分がリッチメニューです。今回は自分で組むのではなく、LINE Official Managerの機能を使って作ります。本当はポストバックを使いたかったので完全自作にしたかったのですが、リファレンスを読んでも実装方法がいまいちよくわからなかったので妥協しました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551992/7e194072-e4be-c545-e857-74008b0601fa.jpeg" width=25%>

###LINE Official Manager
ログインした後は下の赤枠をクリックしてください。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551992/cb19b343-eb4d-ae2a-ac8b-5e23176dee6a.png" width=60%>
そこで作成ボタンを押すと次のようなページが表示されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551992/e5af4c32-6ac9-8d42-ded9-6038debbc795.png" width=85%>
タイトルは何でもいいです。リッチメニューを複数作ったときに見分けるようです。（自作でないと同一アカウントでのリッチメニューの切り替えはできなさそうです）
表示期間は長めに取っておけばよいとおもいます。開始日は実装する日より前にしないと出てきません。（当然ですが）

下に行くと**コンテンツの設定**があります。**テンプレートを選択**を押すと分割数を選べます。ここでは6分割を選びました。**背景画像をアップロード**を選択すると一面の画像になってしまうので、6分割個々に画像をつけるなら下の**画像を作成**を押します。
また、アクションで押したときの反応を選べます。今回はそのあとのイベントにつながるのでテキストにします。（他を使う機会はあまりない気がします）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551992/807b4584-9698-a3dc-fdb6-a79711921e21.png" width=60%>
画像の作成時のポイントを述べます。まず赤丸のアイコンを押すと画像をアップロードできます。そして青丸のアイコンで外枠が縁どられます。これがないと境界がわからないのであったほうがいいと思います。デフォルトの太さだと隙間が空いたので、縁はmaxの5でちょうどよかったです。
また、右上の**適用**は全部終わってから押しましょう。途中で押すとそれで背景の1枚の画像として保存されてしまうので、個々の編集ができなくなります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551992/3c7c5506-deaa-b9ac-96d7-01a633d9b2a5.png" width=60%>
こうして出来上がったのがこのリッチメニューです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551992/11b47ee2-4180-3650-42e5-2b4201d50280.jpeg" width=60%>

動作の流れで書いたホームボタンが真ん中下のミッキーアイコンです。押したときに「ホーム」とテキストを返す役割を担っています。
その他の5つはすべてカテゴリを選択するためにあるので、ホームボタンとは機能が違います。

#パークの選択（ボタン）
ホームボタンを押した後はパークを選択してもらうために下のようなボタンが出るようにしました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551992/dd5cb188-0d3e-23b1-4493-e28db0072467.jpeg" width=25%>
ホームボタンを押したときの処理をざっくり載せます。この後ポストバックのイベントもあり、どのパークを選択しているかなど保存しておきたい情報があるので、変数のスコープを考えてグローバル変数を使っています。ホームボタンを押した後の処理に関しては**les = "les"**というところから始まっています。大事な点は以下の2点です。

- jsonファイルの表示の仕方
- pushとreplyの違い

```Python:home_button.py
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    global park,genre,area,info_url,target_url,counter,situation

    text = event.message.text
    userid = event.source.user_id

    #最初とリセット時
    if text == "ホーム":
        #初期化
        park = "park"
        genre = "genre"
        area = "area"
        info_url = ""
        target_url = ""
        counter = 0
        situation = ""


        les = "les"
        template = template_env.get_template('theme_select.json')
        data = template.render(dict(items=les))


        select__theme_massage = FlexSendMessage(
                alt_text="テーマ選択",
                contents=BubbleContainer.new_from_json_dict(json.loads(data))
                )
            
        line_bot_api.push_message(userid, messages=select__theme_massage) 
```

まず最初に書いておきたいのは、TextMessageやFlexSendMessageについてです。これらはオウム返しでコピペしたソースコードの上のほうをいじる必要があります。下のようにEvent,Action,Message系はimportする必要があるようです。エラーが出た場合は、ここに記述しているか確認してみてください。

```Python
from linebot.models import (
    MessageEvent, TextMessage, PostbackTemplateAction, PostbackEvent, PostbackAction, QuickReplyButton, QuickReply,
    FlexSendMessage, BubbleContainer, CarouselContainer, TextSendMessage
)
```
####jsonファイルの表示
正直jinja2を使ってテンプレートに当て込むということしかわかっていないので、あまり理解をしていないです。なので、初心者の方は**les = "les"**以下をコピペしてもらうのが時短になると思います。

1. jsonファイルを作成する

    [Flex Message Simulator](https://developers.line.biz/flex-simulator/)を使って、完成しているものをいじってとりあえず形にしました。

    ```json:theme_select.json
    {
    "type": "bubble",
    "hero": {
      "type": "image",
      "url": "https://secured.disney.co.jp/content/disney/jp/secured/dcc/tokuten/bf-tdr-prk-tckt/_jcr_content/par/dcc_hero_panel_image/image1.img.jpg/1474355301452.jpg",
      "size": "full",
      "aspectRatio": "20:13",
      "aspectMode": "cover"
    },
    "body": {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "text",
          "text": "パークを選択してください",
          "weight": "bold",
          "size": "lg"
        }
      ]
    },
    "footer": {
      "type": "box",
      "layout": "vertical",
      "spacing": "sm",
      "contents": [
        {
          "type": "button",
          "style": "link",
          "height": "sm",
          "action": {
            "type": "postback",
            "label": "ランド",
            "data": "land"
          }
        },
        {
          "type": "button",
          "style": "link",
          "height": "sm",
          "action": {
            "type": "postback",
            "label": "シー",
            "data": "sea"
          }
        },
        {
          "type": "spacer",
          "size": "sm"
        }
      ],
      "flex": 0
    }
  }
    ```
    "action"の中身は
    - type
    - label
    - data

    に分かれており、"type"がデータのやり取りの形、"label"はボタンに書いてあるもの（今回なら「ランド」、「シー」）、"data"は受け取るデータを指します。"data"には画像や音声も入るらしいです。詳しくは[リファレンス](https://developers.line.biz/ja/reference/messaging-api/#wh-text)を読んでください。
    （補足）"action" : {}の"type"は用途のよって選んでください。相手が押したときにそれがメッセージとして出たほうが良ければ"type" : messageとするのが良いです。
    ####大事！
    jsonファイルの保存は同階層にtemplatesというフォルダを作ってその中に保存してください！そこから読み込んでるらしいです。

2. 下のコードに代入する
文字列の部分に代入してください。

    ```Python
    template = template_env.get_template('theme_select.json')
    ```
3. カルーセルを実装する場合（横スライドのやつ）、下のコードに変更する
BubbleContainerからCarouselContainerに変更します。
    
```Python
    select__theme_massage = FlexSendMessage(
            alt_text="テーマ選択",
            contents=CarouselContainer.new_from_json_dict(json.loads(data))
            )
```

####pushとreplyの違い
pushは一回のイベントに対して複数回可能ですが、replyは一回しかできなさそうです。なのでreplyをしてしまうとそのあとにはもうメッセージが送れなくなってしまいます。
例えばスクレイピングに多少の時間がかかると想定して、ユーザーからメッセージを受け取ったときに「処理中」と表示し、その後結果をjsonファイルで表示する場合は、「処理中」をpushしjsonファイルをreplyすることで解決します。
また、pushはuseridとmessageを用意しなくてはいけないです。上のbutton.pyを見て真似てください。

```Python:push.py
line_bot_api.push_message(userid, messages=select__theme_massage) 
```

```Python:reply.py
line_bot_api.reply_message(
    event.reply_token,
    FlexSendMessage(
        alt_text="結果表示",
        contents=BubbleContainer.new_from_json_dict(json.loads(data))
    )
) 
```

#開園チェック
datetimeを用いて開園時間以外は「閉園中」と表示するようにしました。以下に2つのコードを載せます。
1つ目はパーク選択のデータをポストバックで受け取り、営業時間を確認し、戻り値でユーザーに返信をするものです。
2つ目は営業時間を確認するコードです。（おまけ）

**注意点：**Herokuのタイムゾーン設定を日本にしていないとdatetimeがアメリカの時間帯になってしまいます。タイムゾーン設定については→[こちらの記事](https://qiita.com/haman29/items/773b522b92bbf806ab32)

```Python:postback_park.py
@handler.add(PostbackEvent)
def handle_postback(event):
    global park,genre,area,info_url,target_url,counter,situation
    area = ""

    post_data = event.postback.data
    userid = event.source.user_id

    if post_data == "land" or post_data == "sea":
        park = post_data
        if park == "land":
            #開園時間や天気などのリンク
            info_url = "https://tokyodisneyresort.info/index.php?park=land"
            park_ja = "ランド"
        
        elif park == "sea":
            #開園時間や天気などのリンク
            info_url = "https://tokyodisneyresort.info/index.php?park=sea"
            park_ja ="シー"

        #開園時間をチェック
        business_hour = Scrape_day(info_url)
        situation = Check_park(business_hour)

        if situation == "close":
            print("close")
            line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(text="閉園中です")
                )
                
        elif situation == "open":
            park_message = TextSendMessage(text= str(park_ja) + "を選択しています\nカテゴリを下のメニューから\n選択してください")
            line_bot_api.push_message(userid, messages=park_message)
```

areaを空にしてるのはボタンを間違って複数回押してしまったときを想定したエラー回避です。

```Python:check_park.py
#今が開園時間か確認
def Check_park(business_hour):
    #今の時間、日時を確認
    dt_now = dt.now()

    #今日の日付
    year = int(dt_now.year)
    month = int(dt_now.month)
    day = int(dt_now.day)

    #開園時間の分割
    open_time = business_hour.split("～")[0]
    if open_time.split(":")[0] == "":
        return "close"

    else:
        open_hour = int(open_time.split(":")[0])
        open_minute = int(open_time.split(":")[1])

        #閉園時間の分割
        close_time = business_hour.split("～")[1]
        close_hour = int(close_time.split(":")[0])
        close_minute = int(close_time.split(":")[1])

        #datetime化
        open_datetime = dt(year,month,day,open_hour,open_minute)
        close_datetime = dt(year,month,day,close_hour,close_minute)


        if open_datetime < dt_now < close_datetime:
            return "open"

        else:
            return "close"
```
引数のbusiness_hourはサイトからスクレイピングしてきた営業時間を文字列に変換したものです。

#開園中なら取得したい待ち時間のカテゴリを選択
パークを選択した結果、開園中であったら次のステップに進みます。先ほど作成したリッチメニューを押し、どのカテゴリの待ち時間を表示したいかを選択します。このとき、選択したカテゴリはテキストで画面上に表示されます。これを回避するにはリッチメニューを自作するしかありません泣

#スクレイピング→Flex Messageのreciptで出力
コード全体は上記の通りgithubで公開しているので、待ち時間のスクレイピングに関しては特に記述しません。ここでは取得してきた値を出力するときにFlex Messageのreciptを用いる方法について説明します。
出力する文字列が少ない場合や形にこだわらない場合は、上に記述したpushまたはreplyで十分なのでここは飛ばしていいと思います。

今回もjsonファイルをいじっていきますが、大きく分けて3つのことをします。

- 一部に変数を埋め込む
- 出力する項目数がその時々で変わるものを埋め込む
- ファイルの初期化

[Flex Message Simulator](https://developers.line.biz/flex-simulator/)のreciptを編集して以下のjsonファイルを作成しました。

```Json:recipt.json
{
    "type": "bubble",
    "styles": {
    "footer": {
        "separator": true
    }
    },
    "body": {
    "type": "box",
    "layout": "vertical",
    "contents": [
        {
        "type": "text",
        "text": "待ち時間",
        "weight": "bold",
        "color": "#1DB446",
        "size": "sm"
        },
        {
        "type": "text",
        "text": "テーマ",
        "weight": "bold",
        "size": "xl",
        "margin": "md"
        },
        {
        "type": "separator",
        "margin": "xxl"
        },
        {
        "type": "box",
        "layout": "vertical",
        "margin": "xxl",
        "spacing": "sm",
        "contents": [
            
        ]
        }
    ]
    }
}
```

####一部に変数を埋め込む
"text": "テーマ"と書いてある部分を"text": "取得した文字"に変更したいので、次のような処理を行います。

```Python:set_json.py
def Send_area(area):
    json_file = open('templates/recipt.json', 'r',encoding="utf-8-sig")
    json_object = json.load(json_file)
    json_object["body"]["contents"][1]["text"] = str(area)
    #書き込み
    new_json_file = open('templates/recipt.json', 'w',encoding="utf-8")
    json.dump(json_object, new_json_file, indent=2,ensure_ascii=False)
```
手順としては
1. まず上のjsonファイルを書き込める形で読み込みます
2. 複雑なリストの要素を指定することによって"text": "テーマ"があるところまで行きます（エディターによっては簡単に見つけることができると思います）
3. そして"text"の中身に変数を代入すれば一部に変数を代入することができます
という感じです。

####出力する項目数がその時々で変わるものを埋め込む
これは形の決まったboxに変数を埋め込み、それを追加挿入していくことで解決しました。

```Python:new_json.py
def Make_jsonfile(attraction,info):
    json_file = open('templates/recipt.json', 'r',encoding="utf-8-sig")
    json_object = json.load(json_file)

    new =   {
                "type": "box",
                "layout": "vertical",
                "margin": "xxl",
                "spacing": "sm",
                "contents": [
                {
                    "type": "box",
                    "layout": "horizontal",
                    "contents": [
                    {
                        "type": "text",
                        "text": str(attraction),
                        "size": "sm",
                        "color": "#555555",
                        "flex": 0
                    },
                    {
                        "type": "text",
                        "text": str(info),
                        "size": "md",
                        "color": "#111111",
                        "align": "end"
                    }
                    ]
                }
            ]
        }

    json_object["body"]["contents"][3]["contents"].append(new)

    new_json_file = open('templates/recipt.json', 'w',encoding="utf-8")
    json.dump(json_object, new_json_file, indent=2,ensure_ascii=False)
```
複雑なため分かりにくいと思いますが、大事なのはappend(new)を行っているところです。上のrecipt.jsonにある一番下の空のcontentsはリスト形式であるため、そこにappendすることによって表示するデータ数があらかじめ分かっていなくても対応できます。
new_json.pyではアトラクション名と待ち時間を変数で埋め込み、それらをまとめたboxをcontentsに挿入しています。

※ encodingをutf-8-sigで行なっているのは、エラー回避のためです。utf-8でエラーが出ている人はこのサイトをみてください→[UnicodeDecodeError: 'cp932'が出たとき](https://qiita.com/Yuu94/items/9ffdfcb2c26d6b33792e)

※ Herokuのログ確認でemptyと書いてあるときは大体ここが原因だと思います。元のcontentsが空であるため、何も追加されていないと出力するときにエラーが出ます。まずはcontentsにboxが追加されているか確認しましょう。

####ファイルの初期化
当然ながら、初期化せずにappendを繰り返すと今までの情報が残り続けてしまいます。reciptを作成する前に、上のrecipt.jsonを上書きすることで初期化しています。

#まとめと今後の課題
初投稿ながらかなり長くなってしまいました。今回は私自身が苦労したリッチメニューとFlex Messageに重点をおき、まとめてみたので誰かの参考になれば幸いです。
挙げられる課題としては、

- 公式サイトから情報をとって来れればより表示できる項目が増える（セキュリティ的に厳しいと思うが）
- リッチメニューを自作して、リッチメニュー自体を動的に使いたい（リッチメニューを押すとボタンではなく、新しいリッチメニューが出てくるような）

があります。特に2つ目のリッチメニューに関するPythonの記事が少ないので、たまたまこの記事を読んだ人の中に詳しい方がいらっしゃったらぜひ投稿していただけると嬉しいです。