【手順解説付き】LINEBotでグラフ付きの天気予報を毎朝スマホに通知する

### 【2023/09/12追記】
**依存するAPIの仕様変更により、現在そのままでは動作しません**。

# 🌤 はじめに

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/5431cdbf-544b-456e-34a4-9b0aa38a2bc7.gif" width=50%>
</p>

自分が住んでいる場所や、入力した地名の天気予報をグラフとテキストで分かりやすく通知するLINEBotを作成しました。

---

**この記事は、以下のような読者の方々に向けて書いています。**

#### 1. LINEBotを作成してみたいけど、Botの実装がめんどくさい

**記事の読み方**
[導入方法](#-導入方法)に従って手順を進めていけば、コードを書くことなくこの天気予報Botをデプロイできます！

LINEBotをはじめとするWebアプリケーションのデプロイをやって理解してみたい方は、まずそちらから読んでみてください。

#### 2. 普通に天気予報を毎朝通知するBotがほしい

**記事の読み方**
[導入方法](#-導入方法)に従って手順を進めていけば誰でもゲットできます。

#### 3. LINEBotをPythonで作成してみたい

**記事の読み方**
[コードの説明](#-コード説明)から実装についての説明を、[導入方法](#-導入方法)から各APIの取得からデプロイまでの説明を記載しています。

手を動かしながら追っていけば、LINEBot作成のやり方がなんとなく分かってくると思います。

#### 4. ユーザからのメッセージに応答するだけではなく、こちらから自発的にメッセージを送信するLINEBotを作成したい

**記事の読み方**
cronを使わずにこれをやっている記事があまり無いため、個人的に苦労しました。
ユーザからアクションを起こさずとも、メッセージを定期送信するLINEBotの実装については[2. 気象情報の取得とメッセージ作成](#-2-気象情報の取得とメッセージ作成-atomosbotforecast_atomos_phenompy)と[herokuにスケジューラを導入](#herokuにスケジューラを導入)をご覧ください。

**

結果的に結構な文章量になってしまったので、ところどころ読み飛ばしながら少しずつご覧ください。

# ⛄️ 目次

- [Botの主な機能](#-botの主な機能)
- [送信メッセージの詳細](#-送信メッセージの詳細)
- [実装に使用したもの](#-実装に使用したもの)
- [コードの説明](#-コード説明)
    - [1. LINEBotの基幹処理設定](#-1-linebotの基幹処理設定-atomosbotclipy)
    - [2. 気象情報の取得とメッセージ作成](#-2-気象情報の取得とメッセージ作成-atomosbotforecast_atomos_phenompy)
    - [3. その他ユーティリティ関数](#-3-その他ユーティリティ関数)
    - [4. Procfile, runtime.txt, requirements.txt](#-4-procfile-runtimetxt-requirementstxt)
- [導入方法](#-導入方法)
    - [LINE Messaging APIの設定](#-1-line-messaging-apiの設定)
    - [OpenWeather APIキーを取得](#-2-openweather-apiキーを取得)
    - [Gyazo APIキーを取得](#3-gyazo-apiキーを取得)
    - [リポジトリをFork](#%EF%B8%8F-4-リポジトリをfork)
    - [Herokuの設定を行い、リポジトリをデプロイ](#%EF%B8%8F-5-herokuの設定を行いこのリポジトリをデプロイ)
- [実行テスト](#-実行テスト)
- [おわりに](#%EF%B8%8F-おわりに)

# ⛈ Botの主な機能

ざっくり言うと3つの機能があります。

#### 1. ユーザから指定された地点の天気予報を返信する

Botとのトーク画面で任意の住所を打ち込むと、その場所の天気予報を返信します。
（たまに返信がおかしいこともあります）

**例： 「東京都千代田区千代田1-1」と打ち込んだ場合**

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/b6a72709-216c-a654-9228-8bfc33db37f3.jpeg" width=50%>
</p>

#### 2. 決まった時間に天気予報を定期的に通知する

Herokuのスケジューラと連携することで、今日一日の気温や天気を毎朝LINEに通知します。
(例: 毎朝7時に自分が住んでいる地域の天気予報を表示する)

#### 3. 気圧変動が要警戒の時間帯を可視化

気圧の乱高下から来る、体調の不調が起きやすい時間帯をプロット上に赤斜線でハイライトし、文面上でも記載します。

# 🌈 送信メッセージの詳細

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/84bad414-fd63-e666-dbec-2466d330cd40.jpeg" width=50%>
</p>

### プロット部分

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/cc568247-b160-7ffe-d12f-6fbb697c6d36.jpeg" width=80%>
</p>

- 背景色 : 天気と気圧の要警戒時間帯を示します

  - 🌤 : オレンジ色
  - ☁️ : 灰色
  - ☔️ : 水色
  - 気圧の要警戒時間帯 : 赤斜線

- 折れ線グラフ（赤）: 1時間ごとの気温の変化
- 折れ線グラフ（青）: 1時間ごとの気圧の変化

**Tips: 天気予報の取得先にしているOpenweathermap.orgの特徴として、少しでも雲が出そうだと晴れではなく曇りとする傾向があります**

### テキスト部分

- 日付
- 表示対象のアドレス
- 今後24時間の最高・最低気温
- 気圧変動が大きい時間帯

# 🌦 実装に使用したもの

#### Webサービス

- LINE Messaging API
    - 言わずもがな
- Heroku
    - アプリケーションのデプロイに使用
- Openweathermap.org
    - 気象情報の取得先として使用
- gyazo
    - プロットの保存先に使用

#### pythonライブラリ
- Flask 2.1.1
    - Webサーバの立ち上げに使用
- beautifulsoup4 4.10.0
    - スクレイピングにほんの少しだけ使用
- line-bot-sdk 2.2.1
    - LINEBotの開発キット
- plotly 5.6.0
    - プロットの表示用ライブラリ。matplotlibよりカッコいい（気がする）
- kaleido 0.2.1
    - プロットの保存に使用

# 🌩 コード説明

はじめに、Githubへのリンクを貼っておきます。

https://github.com/discus0434/atomosbot

ソースコードは次のような構成です。

 - atomosbot/cli.py
    - LINEBotの基幹となる処理で、ユーザからのメッセージに応答
 - atomosbot/forecast_atomos_phenom.py
    - 天気予報を取得し、データを加工してLINEのメッセージのフォーマットに変換するほか、天気予報の定期通知を行う
 - utils
    - 便利な自作関数モジュール
 - Procfile, runtime.txt, requirements.txt
    - herokuのデプロイに使用するファイル群で、動作環境を定義する

1つ1つ説明していきますが、Heroku×LINE Messaging APIの導入について興味がある方は[導入方法](#-導入方法)まで飛ばしてしまっても構いません。

## 🌪 1. LINEBotの基幹処理設定 (atomosbot/cli.py)

このファイルでは以下のことを行っています。
- アプリケーションを起動
- LINE Messaging APIとの通信を確立
- Webhookからのリクエストが正当であれば、handlerの処理を呼び出す
- handlerにLINEのメッセージ返信処理を定義
- **ユーザからのメッセージに返信する**

Webアプリケーションを立ち上げ、LINE Messaging APIとアプリケーションの通信を確立し、LINEBotに対してユーザが送信したメッセージに応答する、というBotの中心的な処理を担っているファイルです。

まずはAPI通信の確立に欠かせない、各ライブラリのクラスオブジェクトを作成します。

```python
# flaskオブジェクトを作成
app = Flask(__name__)
# 各クライアントライブラリのクラスオブジェクト作成
line_bot_api = LineBotApi(os.environ["CHANNEL_ACCESS_TOKEN"])
handler = WebhookHandler(os.environ["CHANNEL_SECRET"])
```

ここでLINE Developersの設定画面から取得したアクセストークンや署名検証用のキーを使用します。

---
次に、Webアプリケーションへ通知されるリクエストを確認して署名検証を行い、正当なリクエストかどうかを確認します。

正当なリクエストとは、当該LINEBotに対してユーザからのメッセージがポストされた場合にLINE Messaging APIから送信されるリクエストで、そのヘッダーを確認することで署名の検証を行うことができます。

仮に正当なリクエストだった場合、以降で定義するhandle内の処理を行い、そうでなければ例外処理を行います。

```python
@app.route("/callback", methods=["POST"])
def callback() -> str:
    """Webhookからのリクエストの正当性をチェックし、handlerに処理を渡す

    /callbackにPOSTリクエストがあった場合、それが正当なLINEBotのWebhookからの
    リクエストであるかどうかをチェックし、署名が正当であればhandlerに処理を渡します。

    Returns
    -------
    str
        例外が発生しなかった場合は"OK"を返します。
    """

    # リクエストヘッダーから署名検証のための値を取得
    signature = request.headers["X-Line-Signature"]

    # リクエストボディの取得
    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    try:
        # 署名を検証し、問題なければhandleに定義している関数を呼び出す
        handler.handle(body, signature)
    except InvalidSignatureError:
        app.logger.warn("Invalid Signature.")
        abort(400)

    # 例外が発生せず、handlerの処理が終了すればOK
    return "OK"
```

---

LINEBotとの通信を確立する処理ができたので、次にhandler部分で返信のメッセージを作成する処理を記述します。

ここでは、後述する自作クラスである**ForecastAtomosPhenom**を使用してメッセージを作成します。

クラスオブジェクト作成の際にユーザから受け取ったメッセージを引数として渡すことで、ユーザが求める住所の気象情報を持ってくるようにします。

また、ユーザから受け取ったメッセージに問題がある場合は例外処理を行います。

```python
# addメソッドの引数にはイベントのモデルを入れる
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event) -> None:
    """返信メッセージを作成

    Parameters
    ----------
    Any
        ユーザからのメッセージイベント
    """
    try:
        # クラスオブジェクトを作成
        forecast = ForecastAtomosPhenom(address=event.message.text, duration=30)

        # メッセージを作成
        messages = forecast.make_linebot_messages()
    except Exception:
        # 例外が発生した場合はプロットを作成せず代わりのテキストを返す
        messages = TextSendMessage(text="都市名もしくは住所を入力してください。")

    line_bot_api.reply_message(event.reply_token, messages=messages)
```

最後に、ファイルがコンソールから呼び出された際実行するメイン関数を設定します。

```python
def main() -> None:
    # Usage Messageの作成
    arg_parser = ArgumentParser(
        usage="Usage: python " + __file__ + " [--port <port>] [--help]"
    )

    # 環境変数PORTのと同じポート番号でAppを起動する
    arg_parser.add_argument(
        "-p", "--port", type=int, default=int(os.environ.get("PORT", 8000)), help="port"
    )
    arg_parser.add_argument("-d", "--debug", default=False, help="debug")
    arg_parser.add_argument("--host", default="0.0.0.0", help="host")
    
    options = arg_parser.parse_args()
    app.run(debug=options.debug, host=options.host, port=options.port)
```

## 🌏 2. 気象情報の取得とメッセージ作成 (atomosbot/forecast_atomos_phenom.py)

このファイルでは以下のことを行っています。
- 指定された住所を緯度・経度に変換
- 緯度・経度から気象情報を取得
- 気象情報からテキストメッセージを作成
- 気象情報からプロットを作成し、画像をgyazoにアップロード
- アップロードした画像で画像メッセージを作成
- **定期実行用メッセージを送信する**

気象情報をfetchし、ユーザに分かりやすいデータとして加工した上でLINEのメッセージを作成するという、結構盛りだくさんの機能を含んでいます。

また、メインファイルでは定義していなかった、**毎日○時にXX県XX市の気象情報を通知する**ような機能はこちらが持っているため、それも含めて処理を記述していきます。

まずは、定期実行用メッセージ通知に使用するmain関数を定義します。

```python
def main() -> None:
    # Usage Messageの作成
    arg_parser = ArgumentParser(
        usage="Usage: python "
        + __file__
        + " [--address <address>] [--duration <duration>] [--help]",
    )

    # 気象情報の表示対象とする地名を指定
    arg_parser.add_argument(
        "-a",
        "--address",
        type=str,
        default="東京都千代田区千代田1-1",
        help="address you want to get weather info",
    )
    # 何時間分の気象情報を表示するか指定（<48）
    arg_parser.add_argument("-d", "--duration", type=int, default=30, help="duration")
    options = arg_parser.parse_args()

    # 気象情報を取得
    forecast = ForecastAtomosPhenom(address=options.address, duration=options.duration)

    # メッセージを作成
    messages = forecast.make_linebot_messages()

    # メッセージを送信
    line_bot_api = LineBotApi(os.environ["CHANNEL_ACCESS_TOKEN"])
    line_bot_api.push_message(forecast.user_id, messages=messages)
```

**--address**に定期実行の際気象情報の表示対象とする住所、**--duration**に今後何時間分の天気予報を表示してほしいか指定できるように設定します。

例)
```zsh
python <実行ファイル名>.py --address 東京都千代田区千代田1-1 --duration 30
# 東京都千代田区千代田1-1の今後30時間の気象情報を表示
```

この定期実行プログラムはherokuにデプロイした後、スケジューラを導入した際に使用します。

---

次に、送信メッセージを作成するクラス本体を作成します。

まずはコンストラクタを作成し、今後の処理に必要な情報を一通り揃えます。

```python
class ForecastAtomosPhenom:
    def __init__(self, address: str, duration):
        """コンストラクタ

        Parameters
        ----------
        address : str
            気象情報を取得する地名
        duration : int
            気象情報を設定した時間(<48時間後)まで表示する
        """
        # 環境変数
        self.user_id = os.environ["USER_ID"]
        self.openweather_api_key = os.environ["OPENWEATHER_API_KEY"]
        self.gyazo_api_key = os.environ["GYAZO_API_KEY"]

        # プロットの出力先を指定
        self.save_plot_path = "plot.jpeg"

        # 取得対象を指定
        self.address = address
        self.duration = duration

        # 天気予報を取得し、タイムスタンプで取得された日時をdatetime型に変換
        self.res = self.convert_timestamp_into_datetime(res=self.get_forecast())

        # 天気ごとの表示色を指定
        self.cmap = {
            "Clear": "orange",
            "Clouds": "grey",
            "Rain": "skyblue",
        }
```

今回、気象情報の取得に使用するOpenweathermap.org、画像の一次保存先に使用するgyazo.comはAPIを提供しているため、後述する[導入方法](#-導入方法)に従ってAPIキーをあらかじめ取得しておきます。

そして、上で呼び出している、天気予報の取得メソッドは下のコードになります。
住所を緯度・経度に変換した上で、Openweathermap.orgにリクエストを送信します。

```python
    def get_forecast(self) -> Dict[str, Any]:
        """天気予報をOpenWeathermap.orgから取得

        Returns
        -------
        Dict[str, Any]
            取得したjsonファイル
        """
        # URL
        url = "http://api.openweathermap.org/data/2.5/onecall"

        # 都市名を緯度・経度に変換
        lon, lat = get_lon_lat_from_address(address=self.address)

        params = {
            # 経度
            "lon": lon,
            # 緯度
            "lat": lat,
            # 気温を摂氏で取得
            "units": "metric",
            # 必要のない情報をexclude
            "exclude": "daily,minutely",
            # APIキー
            "appid": self.openweather_api_key,
            # 取得情報の一部を日本語化
            "lang": "ja",
        }

        # データの取得
        res = get_api_data(url=url, params=params)

        return res
```

Openweathermap.orgから返ってきたレスポンスのうち、日時のフォーマットがtimestampになっていて扱いにくいので、こちらのメソッドの戻り値をコンストラクタ内でdatetime型に変換しています。

また、上に登場した**get_lon_lat_from_address**, **get_api_data**は自作のユーティリティ関数です。後ほどご紹介します。

---

次に、Botが送信するメッセージ全体を作成します。

プロット部分とテキスト部分を別々に作成し、後でまとめて1つのメッセージにします。

**メッセージの例(再掲)**
<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/84bad414-fd63-e666-dbec-2466d330cd40.jpeg" width=50%>
</p>

Openweathermap.orgから返ってきたレスポンスから必要な情報を抽出し、気圧変動の大きい時間帯を算出したら、それらの情報をメッセージのプロット部分作成のメソッド・テキスト部分作成のメソッドに渡します。

プロット部分とテキスト部分が完成したら、プロット部分はgyazoにアップロードしてLINEBotに投げやすくした上でテキスト部分と結合し、1つのメッセージとしてリターンします。

```python
    def make_linebot_messages(self) -> List[Any]:
        """Botが送信するメッセージ全体を作成

        Returns
        -------
        List[Any]
            Botが送信するメッセージで、画像とテキストを含む
        """
        # 取得したデータから必要な情報を抽出
        r = self.res["hourly"]
        # 気温
        self.temp = [r[idx]["temp"] for idx in range(self.duration)]
        # 日時
        self.dates = [r[idx]["dt"] for idx in range(self.duration)]
        # 気圧
        self.atomos_phenomena = [r[idx]["pressure"] for idx in range(self.duration)]
        # 天気
        self.weather = [r[idx]["weather"][0]["main"] for idx in range(self.duration)]

        # 気圧が変動する時間帯を算出
        self.alarming_dates = self.calc_when_to_cautious_pressure_change(
            dates=self.dates, atomos_phenomena=self.atomos_phenomena
        )

        # メッセージの画像部分（プロット）を作成・保存
        self.make_image_message()

        # 作成したプロットをgyazoにアップロード
        response = upload_image_to_gyazo(
            save_path=self.save_plot_path,
            api_key=self.gyazo_api_key,
        )

        # メッセージを作成
        messages = [
            ImageSendMessage(
                original_content_url=json.loads(response.text)["url"],
                preview_image_url=json.loads(response.text)["thumb_url"],
            ),
            TextSendMessage(text=f"{self.make_text_message()}"),
        ]

        return messages
```

そして、プロット部分・テキスト部分それぞれの作成メソッドは以下です。


#### プロット部分

プロットの作成には**plotly**を使用しています。

```python
    def make_image_message(self) -> None:
        """取得した予報データをPlotlyでプロットし、jpegとして保存

        取得時刻から約{duration}時間後までの毎時の気温・気圧・天気と、
        気圧が変動する時間帯を可視化し、LineBotが送信するメッセージの画像部分として
        保存する
        """
        # プロット
        fig = go.Figure()

        # 気圧の折れ線グラフ
        fig.add_trace(
            go.Scatter(x=self.dates, y=self.atomos_phenomena, mode="lines", name="気圧")
        )

        # 気温の折れ線グラフ
        fig.add_trace(
            go.Scatter(x=self.dates, y=self.temp, mode="lines", name="気温", yaxis="y2")
        )

        # 天気の棒グラフ（実質的には背景色を天気で変化させるためのグラフ）
        fig.add_trace(
            go.Bar(
                x=self.dates,
                y=[1050] * len(self.dates),
                name="天気",
                marker={"color": [self.cmap[w] for w in self.weather], "opacity": 0.4},
            )
        )

        # 要警戒時間帯の棒グラフ（実質的には背景色を要警戒か否かで変化させるためのグラフ）
        fig.add_trace(
            go.Bar(
                x=self.alarming_dates,
                y=[1050] * len(self.dates),
                name="要警戒の時間帯",
                marker_pattern_shape="\\",
                marker=dict(color="red"),
                opacity=0.2,
            )
        )

        # グラフの装飾
        fig.update_layout(
            dict(
                font=dict(family="Kiwi Maru", size=20),
                title=f"今日から明日にかけての{self.address}の気象情報",
                width=1300,
                height=1000,
                xaxis=dict(
                    title="日時",
                    type="date",
                    tickformat="%-m/%-d %-H時",
                    tickfont=dict(size=18),
                ),
                yaxis=dict(
                    title="気圧[hPa]",
                    range=(
                        min(self.atomos_phenomena) - 1,
                        max(self.atomos_phenomena) + 1,
                    ),
                    tickfont=dict(size=18),
                ),
                yaxis2=dict(
                    title="気温[℃]",
                    overlaying="y",
                    side="right",
                    showgrid=False,
                    tickfont=dict(size=18),
                ),
            )
        )

        # プロットをjpegで保存
        fig.write_image(self.save_plot_path, width=1300, height=1000)

        return None
```

#### テキスト部分

```python
    def make_text_message(self) -> str:
        """LineBotが送信するメッセージのうち、テキスト部分を作成

        最高・最低気温、気圧変動の時間帯をテキストでも表示する

        Returns
        -------
        str
            LineBotが送信するメッセージのテキスト部分
        """

        # 気圧変動が大きい時間のリストの中で値が連続しているものをグループ化し、
        # テキストとして表示しやすくする
        alarming_list = []

        # 気圧変動が大きい時間のリストでループを回す
        for idx, date in enumerate(self.alarming_dates):

            # ループ変数が始端か終端の要素の場合は別処理を行う
            if idx == 0:
                continuous = [date]
                continue
            elif idx == len(self.alarming_dates) - 1:
                if len(continuous) > 1:
                    alarming_list.append([continuous[0], continuous[-1]])
                    continue

            # 連続の有無を判別
            is_continuous = self._validate(
                cur=self.alarming_dates[idx], pre=self.alarming_dates[idx - 1]
            )

            # 連続している場合、一時的にリストに加えて続行
            if is_continuous:
                continuous.append(date)
            # 連続していない場合、現時点での一時リストの始端と終端を連続した時間帯と
            # みなして保存し、現在のループ変数を要素として一時リストを初期化
            else:
                if len(continuous) > 1:
                    alarming_list.append([continuous[0], continuous[-1]])
                continuous = [date]

        # 要素の型をdatetime型からstr型に変更
        alarm_list = [
            list(map(lambda x: dt.strftime(x, "%d日 %-H時"), duration))
            for duration in alarming_list
        ]

        # メッセージのテキスト部分を記述
        alarm_text = (
            f"{(dt.strftime(datetime.datetime.now(), '%Y年%-m月%-d日(%a)'))}\n"
            + f"{self.address}の気象情報です。\n\n"
            + f"今度24時間の最高気温は{max(self.temp[:24])}度、\n"
            + f"最低気温は{min(self.temp[:24])}度です。\n\n"
            "気圧が変動するのは以下の時間帯です。\n\n"
            + "".join(
                "から".join(
                    list(
                        map(
                            lambda x: x.replace(x[:4], "今日")
                            if alarm_list[0][0][:4] == x[:4]
                            else x.replace(x[:4], "明日"),
                            li,
                        )
                    )
                )
                + "\n"
                for li in alarm_list
            )
            + "詳細は画像をご覧ください。"
        )

        return alarm_text
```

これで、今回のBotの核となる部分は実装できました。

## 💨 3. その他ユーティリティ関数

ここまでで使用した自作のユーティリティ関数をご紹介します。

**@staticmethod**がついているものは**ForecastAtomosPhenom**クラス内に定義したもので、それ以外は**utils**モジュールにまとめたものですが、区別した理由は特にありません。

### _validate
```python
    @staticmethod
    def _validate(cur: datetime.datetime, pre: datetime.datetime) -> bool:
        """今の値が前の値と連続しているかどうか判別

        Parameters
        ----------
        cur : datetime.datetime
            現在の値
        pre : datetime.datetime
            1つ前のインデックスの値

        Returns
        -------
        bool
            連続しているかどうか
        """
        return cur == (pre + datetime.timedelta(seconds=3600))
```

### convert_timestamp_into_datetime

```python
    @staticmethod
    def convert_timestamp_into_datetime(res: Dict[str, Any]) -> Dict[str, Any]:
        """タイムスタンプで表現された日時をdatetime型に変換

        OpenWeathermap.orgから取得したデータの日時がタイムスタンプで得られるため、
        変換が必要となる

        Parameters
        ----------
        res : Dict[str, Any]
            取得したjsonファイル

        Returns
        -------
        Dict[str, Any]
            日時の型を変換したjsonファイル
        """
        for idx in range(len(res["hourly"])):
            res["hourly"][idx]["dt"] = dt.fromtimestamp(res["hourly"][idx]["dt"])

        return res
```

### calc_when_to_cautious_pressure_change

```python
    @staticmethod
    def calc_when_to_cautious_pressure_change(
        dates: List[datetime.datetime], atomos_phenomena: List[int]
    ) -> List[datetime.datetime]:
        """気圧が大きめに変動する時間帯を算出

        ある日時とその3時間後の気圧差を比較して2hPa以上の差がある場合、その時間帯を
        ピックアップし、更にその1時間後, 2時間後,...の気圧変動も確認して気圧変動が
        一方向に大きい時間帯がどこからどこまで続いているのか算出する

        Parameters
        ----------
        dates : List[datetime.datetime]
            取得した気象情報の日時データ
        atomos_phenomena : List[int]
            取得した気象情報の気圧データ

        Returns
        -------
        List[datetime.datetime]
            気圧が大きめに変動する時間のリスト
        """
        alarming = []
        for idx in range(len(atomos_phenomena) - 3):
            if abs(atomos_phenomena[idx + 3] - atomos_phenomena[idx]) > 2:
                inc = 1
                while (idx + 3 + inc < len(atomos_phenomena)) and (
                    abs(atomos_phenomena[idx + 3 + inc] - atomos_phenomena[idx])
                    > abs(atomos_phenomena[idx + 3 + inc - 1] - atomos_phenomena[idx])
                ):
                    inc += 1

                alarming.append([dates[i] for i in range(idx, idx + 3 + inc)])

        alarming_dates = sorted(
            list(set([item for sublist in alarming for item in sublist]))
        )

        return alarming_dates
```

### get_api_data

```python
def get_api_data(url: str, params: Dict[str, Any]) -> Dict[str, Any]:
    """APIを叩く際に使用するWrapper関数

    Args:
        url (str): URL
        params (Dict[str, Any]): APIに渡すパラメータ

    Returns:
        Dict[str, Any]: 取得したjsonファイル
    """
    url = url
    params = params

    return requests.get(url, params=params).json()
```

### get_lon_lat_from_address

```python
def get_lon_lat_from_address(address: str) -> Tuple[str, str]:
    """渡したアドレスの緯度・経度を返すメソッド

    Parameters
    ----------
    address : str
        任意のアドレス

    Returns
    -------
    Tuple[str, str]
        経度・緯度

    Examples
    --------
    >>> get_lon_lat_from_address('東京都文京区本郷7-3-1')
    ['35.712056', '139.762775']
    """
    url = "http://www.geocoding.jp/api/"

    payload = {"q": address}
    html = requests.get(url, params=payload)
    soup = BeautifulSoup(html.content, "html.parser")
    if soup.find("error"):
        raise ValueError(f"Invalid address submitted. {address}")
    longitude = soup.find("lng").string
    latitude = soup.find("lat").string

    return longitude, latitude
```

### upload_image_to_gyazo

```python
def upload_image_to_gyazo(save_path: str, api_key: str) -> requests.Response:
    """画像をgyazoにアップロードし、レスポンスを取得

    Parameters
    ----------
    save_path : str
        画像の保存先
    api_key : str
        画像アップロードサービスのAPIキー

    Returns
    -------
    requests.Response
        画像がアップロードされたURLの情報を含むレスポンス
    """
    # 作成したプロットをgyazoにアップロード
    with open(save_path, "rb") as f:
        files = {"imagedata": f.read()}

        res = requests.request(
            method="post",
            url="https://upload.gyazo.com/api/upload",
            headers={"Authorization": f"Bearer {api_key}"},
            files=files,
        )

    return res
```

## 🌙 4. Procfile, runtime.txt, requirements.txt

次に、herokuでアプリケーションをデプロイするには必ず作成しておかなければいけない、3つのファイルを紹介します。

3つのファイルは、デプロイしたいアプリがどんな環境下で、どんなプロセスで、どんなコマンドによって実行可能なのかをherokuに伝える役割があります。

### Procfile

アプリのプロセスタイプと、実行したいコマンドをherokuに教えるファイルです。

今回のプロセスタイプは<code>web</code>、コマンドは<code>python atomosbot</code>なので、以下のように記述してアプリのルートディレクトリに設置します。

```zsh
web: python atomosbot
```

### runtime.txt

アプリを実行する<code>python</code>のバージョンを書きます。今回の場合は、

```zsh
python-3.8.13
```

となります。

### requirements.txt

アプリに用いたライブラリと、そのバージョンをセットで記載します。今回の場合は以下のような感じです。

```zsh
Flask==2.1.1
line-bot-sdk==2.2.1
plotly==5.6.0
kaleido==0.2.1
beautifulsoup4==4.10.0
```


# 🌦 導入方法

実装が完了したため、アプリケーションとして導入する手順に入ります。

LINE上でBotが利用できるまでに必要な手順は主に5つです。

1. LINE Messaging APIの設定
2. OpenWeather APIキーを取得
3. Gyazo APIキーを取得
4. このリポジトリをFork
5. Herokuの設定を行い、このリポジトリをデプロイ

## 🌂 1. LINE Messaging APIの設定

LINEの公式アカウントとして、自分にメッセージを送るためのBotを作成します。公式というと何か大変なものを想像しますが、無償の個人利用も可能です。

まず、[こちら](https://account.line.biz/login?)にアクセスし、ご自分のLINEアカウントでログインします。（普段使用しているものでOKです）

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/ccad939a-1103-c92c-5d5b-101cf480a9dd.png" width=80%>
  <br>
</p>

次に、Providersの横にある**Create**をクリックし、プロバイダーを作成します。名前は適当でOKです（自分は名前を入れました）。

一覧から作成したプロバイダーを選択すると、以下のような画面になるので、**Create a new Channel**をクリックし、チャンネルタイプは**Messaging API**を選びます。

するとチャンネルの詳細を設定する画面になるので、必要事項を入力します。

チャンネルの作成が完了したら、設定画面に遷移します。

設定画面のページに表示されているもののうち、必要なものが3つあるので、控えておきましょう。

**1. Basic settings > Your user ID**

ユーザIDです。
User IDがなくとも会話形式のBotは成り立ちますが、Botからの自発的なメッセージ送信は行えません。

**2. Basic settings > Channel secret**

秘密鍵のようなもので、LINEBotと外部サービスを繋ぐために必須です。

**3. Messaging API > Messaging API settings > Channel access token**

LINEBotと外部サービスを繋ぐために必須なもの2です。
こちらは新しくIssueする必要があります、

これでLINE側の設定は一旦終わりです（Herokuの設定後に一瞬だけ戻ってきます）。

## 🌀 2. OpenWeather APIキーを取得

[OpenWeathermap.org](OpenWeathermap.org)という、過去から現在に渡って世界中の気象情報を収集・提供し、天気予報も行っているというイギリスのすごいサイトから気象情報を取得します。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/8ad6f5f0-e72d-6fd4-dde1-b5682b4c7599.png" width=80%>
  <br>
</p>

まず、[こちら](https://home.openweathermap.org/users/sign_in)にアクセスし、アカウントを作成します。
既にアカウントがある場合はスキップしてください。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/ead1922d-7b9c-400b-83c6-06a609deb3a0.png" width=80%>
  <br>
</p>

アカウントの作成が完了したら、上のバー右上のユーザー項目から**My API Keys**を選択します。

ページ右側の**Create key**項目でAPI key name（なんでもいいです。画面ではatomosbotとしました）を入力し、**Generate**をクリックするとAPIキーを作成できます。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/9b8cd030-f9f9-3bb1-a7ff-fb6ea7bb5d80.png" width=80%>
  <br>
</p>

ここで**Key**の部分にある文字列を使用するので、これも控えておきます。
これでOpenWeather APIキー取得は完了です。

## 🌪　3. Gyazo APIキーを取得

プロットの保存先としてGyazoを使用するので、GyazoのAPIキーを作成します。

しかし、こちらはOpenWeather APIキー取得と方法がさほど変わりないため、手順の説明は他サイト様にお任せします。

こちらのAPIキーも後々のために控えておきましょう。

**参考サイト**

Gyazoのアクセストークンを取得する方法
[https://blog.naichilab.com/entry/gyazo-access-token](https://blog.naichilab.com/entry/gyazo-access-token)

## ❄️ 4. このリポジトリをFork

GithubのリポジトリをHerokuと連携する際、自身のリポジトリとしてこのリポジトリを持っておく必要があるため、Forkを行います。

まず、Githubにログインしている状態で[こちら](https://github.com/discus0434/atomosbot)にアクセスし、右上の**Fork**をクリックします。

**Create fork**をクリックします。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/3cdc12ca-a194-b4a9-7202-0f1697906f44.png" width=80%>
  <br>
</p>

すると、自身をownerとしてコピーしたリポジトリを作成できます *1。

これでGithub上の準備は完了です。

***1 : なお、このリポジトリはMIT License です**

## ⚡️ 5. Herokuの設定を行い、このリポジトリをデプロイ

Herokuとは、こちらが動かしたいアプリケーションをクラウド上でデプロイ（実行・運用）してくれるPaaS（Platform as a Service）です。

アカウントを持っていない方は、まず[こちら](https://signup.heroku.com/login)にアクセスしてアカウントを作成します。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/b93f96db-fdd6-17f3-3b12-f93f747eddd5.png" width=80%>
  <br>
</p>

### HerokuにGithubのリポジトリをデプロイさせる

登録ばかりでだいぶ面倒になってきた頃合いだと思いますが、このステップが最後です。

アカウントの作成が完了したら、ダッシュボードが表示されるので、右上の**New**から**Create New App**を選択します。

遷移した画面では、新しいアプリの名前とリージョンを入力します。ここも名前は適当でOKです。リージョンは残念ながら日本が存在しないため、「United States」で進めます *1。

***1 : リージョンの違いによって起きる不具合は後ほど設定から対処します**

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/1c4d76c9-9c78-7e62-2b5a-0c90e6dff7a3.png" width=80%>
  <br>
</p>

アプリを作成したら、**Deploy > Deployment Method**からGithubを選択し、HerokuとGithubを連携します。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/8788da50-c0e5-201f-c0f1-4611d9019c61.png" width=80%>
  <br>
</p>

そして、自分のリポジトリから先ほどフォークしたものを選び、**Connect**をクリックします。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/cde47567-4e56-14b9-714e-b329579ac6a1.png" width=80%>
  <br>
</p>

**Connect**が完了したら、**Automatic deploys**の**Enable Automatic Deploys**をクリックして自動デプロイを有効にしておきましょう。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/099635ac-02d8-ac9e-ece7-44054d6dc94d.png" width=80%>
  <br>
</p>

この設定によって、forkした自身のリポジトリのmasterブランチに変更があった際、自動で変更したものをデプロイしてくれます。

### Herokuでデプロイする

次は、Herokuの環境変数の設定です。ダッシュボードの**Settings**から**Config Vars > Reveal Config Vars**を選択します。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/e7fb18d4-1ca3-8f18-f860-8842915af1e8.png" width=80%>
  <br>
</p>

すると、このようなKey-Valueペアの入力画面が出てくるので、以下のように入力してください。

| KEY                  | VALUE                   |
|:---------------------|:------------------------|
| CHANNEL_ACCESS_TOKEN | LINEのAccess Token      |
| CHANNEL_SECRET       | LINEのSecret Key        |
| USER_ID              | LINEのUser ID           |
| OPENWEATHER_API_KEY  | OpenWeatherのAPI Key    |
| GYAZO_API_KEY        | GyazoのAPI Key          |
| TZ                   | Asia/Tokyo              |
| LANG                 | ja_JP.UTF-8             |

これで、Herokuがこのアプリケーションをデプロイする際、必要に応じてGyazoだったりOpenWeatherのサービスを利用してくれます。

また、タイムゾーンと言語を日本のものに設定することで、アプリケーションのデプロイを行っているリージョンが米国になっていることから起きる不具合を解消します。

続けて、すぐ下の**Buildpacks**からアプリケーションをデプロイするときのパッケージを設定します。

コードはpythonで書いているので、**python**を選択します。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/7d7b2362-da38-05e2-b7fc-0abd12c1a87f.png" width=80%>
  <br>
</p>

すると、次のデプロイのときにこのビルドパックを使用すると言われるので、手動でデプロイしてしまいましょう。

**Deploy > Manual deploy**を選択し、masterブランチを**Deploy Branch**でデプロイします。

すると、リポジトリ内のファイルで設定した要求環境のビルドが勝手に進行し、デプロイしてくれます。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/0000af98-0094-b82e-ac88-9d2d9e5e4426.png" width=80%>
  <br>
</p>

### Herokuにスケジューラを導入

毎日決まった時間にBotから気象情報を通知してもらうために、Heroku SchedulerをHerokuのアプリケーションに導入します。

まず、Herokuにログインしている状態で、[こちら](https://elements.heroku.com/addons/scheduler)にアクセスし、右上の**Install Heroku Scheduler**をクリックします。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/879cda29-7cfb-a1d2-751d-09bf78865e1a.png" width=80%>
  <br>
</p>

そして、**App to provision to**から自身のアプリケーションを選択し、**Submit Order Form**で導入します。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/e2b63d3a-5407-cb58-7030-33f7e4a419bb.png" width=80%>
  <br>
</p>

導入したらHeroku Scheduler自体の設定画面を開き、**Create job**からスケジュールと実行するコマンドを入力します。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/df7330c8-547a-f233-d85c-5ad3f2775d2b.png" width=80%>
  <br>
</p>

ここで注意すべきなのが、時刻の表示がUTCになっているため、日本標準時とは異なります。毎日AM7:00に通知してもらいたい場合は、

**Every day at...** + **10:00 PM** と設定します。

また、定期実行するコマンドは以下に設定します。

```zsh
python atomosbot/forecast_atomos_phenom.py --address <住所> --duration 30
```

設定が終わったら、**Save Job**で定期実行の設定を保存します。

### LINE Messanging APIとHerokuを連携

あとちょっとです。次にLINEの方に戻り、アプリの設定のうち**Messaging API > Webhook settings**から**Webhook URL**を指定します。

Webhook URLは以下のものを入力してください。

> https:// <herokuで設定したアプリ名> .herokuapp.com/callback

連携がうまくいくと、**Success**と表示されます。

---

これで設定終了です。お疲れ様でした！

# 🌬 実行テスト

LINE Developersのチャンネル設定画面の**Messaging API > QR code**にあるQRコードをお手元のスマホのLINEから読み込み、友だち追加します。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/435e8d7d-d222-5ebc-8e6e-7914ec3e982f.jpeg" width=30%>
  <br>
</p>

そのままトークを開きます。このよく見る友だち追加文が、デフォルトのものだったという必要のないトリビアが得られます。

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/98ad2c8a-cbe1-a168-29a2-ef3e3b5dc41f.jpeg" width=30%>
  <br>
</p>

この状態で、Herokuのダッシュボードを開き、右上にある**More**から**Run console**を選択し、以下のコマンドを実行してみましょう。

```zsh
python atomosbot/forecast_atomos_phenom.py
```

設定がうまくいった場合、このようにメッセージが送られてきます！

<p align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277578/cdcda2f0-245e-71df-bfa6-84d4e76514d1.jpeg" width=30%>
  <br>
</p>

# ☔️ おわりに

結果的にかなり長い記事になってしまいました。すみません。

自分自身、このLINEBotから毎朝7時に自宅付近の天気予報が送られてくるように設定していますが、毎日の天候チェックがかなり楽で便利だな〜と思っているので、作って良かったです。

また、今回のBotの機能を流用して、毎朝7時にユーザ（自分）が興味あるニュースをfetchしてきて表示するとか、ユーザ側が要求してきたジャンルのニュースを返信するとか、そういうこともできそうでかなり夢が広がっています。

LINEの通知音に対する反応性の高さは長年培われてきているので、毎朝欠かさず見たい情報はガンガンLINEBotに通知させてみようかなと思います。