AWSを利用したAtCoderコンテストの追加通知をするLINE BOTの作成

LINE以外のアプリの通知を切っていますか？ 僕は切っています。
AtCoderコンテストの予定が追加されたらLINEで通知してほしいなと思い、[Mr JJ](https://twitter.com/jj_kyopro) と共に LINE BOTを作成しました。

# 作成したもの
毎時00分に予定されたコンテストの検索を行い、新たなコンテストが追加されている場合BOT君が通知してくれます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308399/d68f6e1b-5ee0-04b0-09a6-317c8c23b286.png)

一応メッセージを送ると返してくれる機能もあります。(おまけ)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308399/8c834442-05dd-c187-43de-c5eaf1405d84.png)

作成したLINE BOTは以下のボタンから友達追加できます。
><a href="https://lin.ee/lKhM4s3"><img src="https://scdn.line-apps.com/n/line_add_friends/btn/ja.png" alt="友だち追加" height="36" border="0"></a>

# バックエンドの構成
![LINE_BOT_structure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308399/d98a570f-3c81-a5e4-b71e-8ac8182c1a31.png)
バックエンドの処理はすべてAWS(Amazon Web Services)上で行っています。
AWS上の動作は以下の2つに分割できます。

- [コンテスト通知セクション](#コンテスト通知セクション)
    - AtCoderコンテストの検索と新たなコンテストの通知を行う
- [LINE応答セクション](LINE応答セクション)
    - BOTに対しメッセージが送信された際に応答を行う

各セクションについて実装した機能を説明します。

# コンテスト通知セクション
## コンテスト通知セクションの流れ

1. Lambda関数: **AtCoder_contest_Search** が[AtCoderホームページ](https://atcoder.jp/home?lang=ja)のスクレイピングを行い、予定されたコンテスト一覧を取得する
2. **AtCoder_contest_Search** が S3 から通知済みコンテスト一覧 JSON ファイルを取得する。
3. 差集合を計算し、未通知コンテストを抽出する。未通知コンテストが空なら終了。
4. 未通知コンテストデータをLambda関数: **LINE_contest_notify** に送る。
5. **LINE_contest_notify** が送られたデータを [line-bot-sdk](https://github.com/line/line-bot-sdk-python) を用いてブロードキャスト形式で通知する。
6. [1] で得られたコンテストデータを S3 にアップロード



## スクレイピング機能

```python3:Python3
def get_contests():
    # URLからデータを取ってくる
    url = "https://atcoder.jp/home?lang=ja"
    html_data = requests.get(url)

    # htmlパース
    soup = BeautifulSoup(html_data.text, "html.parser")
    
    tags = ["upcoming", "recent"]
    ret_dic = {}
    for tag in tags:
        div = soup.find("div", id="contest-table-"+tag)
        table = div.find("table")
        times  = table.find_all("time")
        titles = table.find_all("a")

        ret_list = []
        for i in range(len(times)):
            dic = {}
            dic["start"] = times[i].text
            dic["title"] = titles[i*2+1].text
            dic["url"] = "https://atcoder.jp" + titles[i*2+1].get("href")
            ret_list.append(dic)
        ret_dic[tag] = ret_list
    return ret_dic
```

|          |     データ内容               |
|:-----------------:|:------------------:|
| 引数 | null |
| 返り値 | 各コンテストに対してデータ(開始時刻・タイトル・URL)を要素に持つ辞書のリスト |

本BOTでは *ret_dic["upcoming"]* のみ使用。



## S3からのファイル取得

```python3:Python3
def get_data_s3():
    # S3上にある通知済みコンテストjsonファイル"contests.json"の読み込み
    
    client = boto3.client("s3")
    try:
        response = client.get_object(Bucket = "bucket_name", Key = "json_file_name")
    except Exception as e:
        print(e)
    return json.loads(response["Body"].read().decode())
```

|          |     データ内容               |
|:-----------------:|:------------------:|
| 引数 | null |
| 返り値 | S3上のJSONファイルを読み込んだ辞書データ |

*get_object* メソッドの返り値は辞書であり、キー"Body"にファイルの内容が含まれる。



## S3へのファイルアップロード

```python3:Python3
def put_data_s3(save_dic):
    # 新たな通知済みコンテストjsonファイルのS3アップローダー
    
    text = json.dumps(save_dic, ensure_ascii = False)
    s3 = boto3.resource("s3")
    s3.Object("bucket_name", "json_file_name").put(Body = text)
```

|          |     データ内容               |
|:-----------------:|:------------------:|
| 引数 | 保存対象の辞書データ |
| 返り値 | null |

辞書をJSON形式のテキストにする際、*ensure_ascii* の値をFalseにしておくと、
JSONファイル内の日本語が読みやすくなり、ファイルサイズも小さくできる。



## ブロードキャスト形式のメッセージ送信

```python3:Python3
def DatetimeToString(date):
    if not isinstance(date, datetime.datetime):
        date = datetime.datetime.strptime(date, "%Y-%m-%d %H:%M:%S%z")
    week_list = "月火水木金土日"
    return f"{date.strftime('%m/%d')}({week_list[date.weekday()]}){date.strftime('%H:%M')}"

def MakeMessageText(event):
    text = DatetimeToString(event["start"]) + "\n" + event["title"] + "が開催されます！\n" + event["url"]
    return text

def main(event, context):    
    line_bot_api = LineBotApi("channel_access_token")
    message = MakeMessageText(event)
    line_bot_api.broadcast(TextSendMessage(text=message))

    # *** main関数を終える処理 ***
```

*DatetimeToString* 関数でコンテスト開始時間のメッセージフォーマットを作成し、
*MakeMessageText* 関数で通知するメッセージのフォーマットを作成した。

[line-bot-sdk](https://github.com/line/line-bot-sdk-python) の *LineBotApi* クラス *broadcast* メソッドを呼び出すだけでBOTの友達全員に通知を行うことができる。




#LINE応答セクション
<details><summary> 本システムの構成図 </summary>
![LINE_BOT_structure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308399/d98a570f-3c81-a5e4-b71e-8ac8182c1a31.png)</details>

## LINE応答セクションの流れ

1. LINE BOT がLINEメッセージを受け取るとWebhookを利用してAPI Gatewayへメッセージ内容をPOSTする。
2. API GatewayへのPOSTがトリガーとなりLambda関数: **LINE_reply_message** が実行される。
3. **LINE_reply_message** が [line-bot-sdk](https://github.com/line/line-bot-sdk-python) を用いてLINEのリプライメッセージを送信する。



## リプライメッセージ送信
```python:Python3
def main(event, context):
    handler = WebhookHandler("channel_secret")
    line_bot_api = LineBotApi("channel_access_token")
    body = event["body"]

    # handlerにリプライ関数を追加
    @handler.add(MessageEvent)
    def SendReply(line_event):
        message = MakeReplyMessage(line_event.message.text)
        line_bot_api.reply_message(line_event.reply_token, TextSendMessage(text=message))

    try:
        handler.handle(body, event["headers"]["X-Line-Signature"])
    except # エラー処理

    # *** main関数を終える処理 ***
```

*MakeReplyMessage* 関数はBOTが受け取ったメッセージ内容に応じたリプライメッセージを文字列で返す。
ここの部分に関しては特に [line-bot-sdkのドキュメント](https://github.com/line/line-bot-sdk-python/blob/master/README.rst) を読むことをおすすめします。


# 終わりに
作成した主な機能について紹介を行いました。
簡単にBOTの操作を行えるline-bot-sdk がとても便利です。
AWSに関する知識が少なく、かなり荒い設計になりましたが、気が向いたら今後機能追加＆改善します。(たぶん)