AWS Lambda + Amazon SNSで電車遅延情報を毎朝通知

こんにちは✋電車遅延がある日は、在宅勤務にしたい...というモチベーションで、電車遅延通知の仕組みを組んだので、守備録です。

# まとめ

* AWS LambdaでJRのHPを確認（PythonのBeautifulsoupを使用）
* 遅延がある場合はAmazon SNSでSMS通知
* Lambdaへの毎朝のトリガーはAmazon EventBridge

# SMSの発行

個人的に、Email、ライン、スラックなどは通知を切ってしまっているので、唯一通知を切っていないSMSで通知することにしました。

Amazon SNSを使うと簡単にSMSを発行できます（SMS以外にもEmailも発行できます）。

[チュートリアル](https://docs.aws.amazon.com/sns/latest/dg/sns-getting-started.html)に従って、topicとsubscriptionを作ります。topic一つに対して、複数のsubscription（今回はSMSの電話番号）が紐づくという構成の様です、topicは送信先（subscription）を束ねる単位といったところでしょうか。

チュートリアルはEmailになっていますが、SMSも同じでした。

作成したtopicのARNは後で使います：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/530ea7e8-8502-a18a-1597-bc67bfc7cdde.png)

# Pythonコード

## HTMLの解読

[Yahooの路線情報](https://transit.yahoo.co.jp/diainfo/area/4)を読むことも考えましたが、より詳細な遅延情報が取得できる[JRのHP](https://traininfo.jreast.co.jp/train_info/kanto.aspx)を読むことにしました。

JRのページのイメージ:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/3241d3eb-4e82-8691-26bd-920f094acad5.png)

requestsでHTMLを読み込んで、BeautifulSoupで構文解析します：

```python
import requests
from bs4 import BeautifulSoup

url = 'https://traininfo.jreast.co.jp/train_info/kanto.aspx'
session = requests.Session()
response = session.get(url)
response.encoding = response.apparent_encoding

soup = BeautifulSoup(response.content, "html.parser")
```

TRタグが以下の黄色枠なので、これを全て確認することを考えます：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/467816f6-632d-d0d1-49e4-79c02462449d.png)

TRタグ内を確認すると、遅延がない場合は：

```HTML
<tr>
<th class="ta-l"><div>
<p class="rosen">
<span class="rosen_color sotetsuline"></span>
<span class="name">相鉄線直通列車</span>
</p>
<p class="btn pc"><a href="/train_info/line.aspx?gid=1&amp;lineid=sotetsuline">詳細</a></p>
</div></th>
<td><a href="/train_info/line.aspx?gid=1&amp;lineid=sotetsuline">
<div class="status normal"><p class="statusIcon"><img alt="平常運転" src="/train_info/img/ico_info_normal.svg"/></p><p>平常運転</p></div>
</a></td>
</tr>
```

遅延がある場合は：

```HTML
<tr>
<th class="ta-l"><div>
<p class="rosen">
<span class="rosen_ico">
<img alt="JA" src="/train_info/img/ico_rosen_ja.svg">
</img></span>
<span class="name">埼京線</span>
</p>
<p class="btn pc"><a href="/train_info/line.aspx?gid=1&amp;lineid=saikyoline">詳細</a></p>
</div></th>
<td><a href="/train_info/line.aspx?gid=1&amp;lineid=saikyoline">
<div class="status delay"><p class="statusIcon"><img alt="遅延" src="/train_info/img/ico_info_delay.svg"/></p><p>遅延</p></div>
<p class="mt10 sp_mt5 sp_mr25">埼京線は、新宿駅でのお客さま転落の影響で、下り線の一部列車に遅れがでています。</p>
</a></td>
</tr>
```

となっていました。ここから考えると：

* `tr_tag.find("span", {"class": "name"}).text`で電車名が取得できます
* `tr_tag.find("td").find_all("p")[1].text`が`"平常運転"`なら遅延なしです
* 遅延がある場合は`tr_tag.find("td").find_all("p")[2].text`で遅延内容が取得できます

確認したい路線のリストを`target_trains`として与え、これら以外は無視することにすると、Forループ全体は:

```python
target_trains = ["京葉線", "山手線"]

delay_messages = []

for tr_tag in soup.find_all("tr"):
    train_name = tr_tag.find("span", {"class": "name"}).text

    if train_name not in target_trains:
        continue

    if tr_tag.find("td").find_all("p")[1].text == "平常運転":
        continue

    delay_message = tr_tag.find("td").find_all("p")[2].text
    delay_messages.append(delay_message)
```

## SMSの発行

boto3を使ってSMSを発行できます：

```python
import boto3

sns = boto3.client('sns', region_name="ap-northeast-1")
res = sns.publish(
            TopicArn="arn:aws:sns:ap-northeast-1:724...:DelayNotification",
            Message=sms_message
        )
```

ここで、`region_name`は先ほど作成したAmazon SNSのtopicを作成したリージョンを指定します。`TopicArn`にはtipicのARNを指定します。

これを実行するには権限の設定が必要になります。LambdaのFunctionのコンソールから、ConfigurationタブのなかのPermissionsタブを開き、Role nameをクリックします:

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/d82079a9-09ff-d522-22af-4aa67ae22097.png)

これでroleの設定画面が開くので、Add permissions→Create inline policyとします:

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/0b310c3d-110a-4182-95d7-87756e565eae.png)

SNS:Publishの権限が必要なので、以下のように設定します:

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/4501479d-2fe4-267e-f017-213ee1793af1.png)

ResourcesはtipicのARNを設定するとより教科書的ですが、簡単に上記としました。最後にinline policyの名前を指定するところは何でもOKです（SNSなど）。

## 全体

AWS Lambdaにアップするには`def lambda_handler(event, context):`の中に実行したいコードを書く必要があります。

また、スクリプトファイル名は`lambda_function.py`とする必要があります。

遅延がない場合はSMSを発行しないで終了する構成にしました。

以上を踏まえて、全体は以下になります：

```python:lambda_function.py
import requests
import boto3
from bs4 import BeautifulSoup

def lambda_handler(event, context):
    url = 'https://traininfo.jreast.co.jp/train_info/kanto.aspx'
    session = requests.Session()
    response = session.get(url)
    response.encoding = response.apparent_encoding
    
    soup = BeautifulSoup(response.content, "html.parser")
    
    target_trains = ["京葉線", "山手線"]
    
    delay_messages = []
    
    for tr_tag in soup.find_all("tr"):
        train_name = tr_tag.find("span", {"class": "name"}).text
    
        if train_name not in target_trains:
            continue
    
        if tr_tag.find("td").find_all("p")[1].text == "平常運転":
            continue
    
        delay_message = tr_tag.find("td").find_all("p")[2].text
        delay_messages.append("🚃" + delay_message)
    
    if delay_messages:
        sms_message = "\n".join(delay_messages)
    else:
        sms_message = "🚃遅延はありません🚃"
        return {
            'statusCode': 200,
            'body': sms_message,
        }

    
    sns = boto3.client('sns', region_name="ap-northeast-1")
    res = sns.publish(
                TopicArn="arn:aws:sns:ap-northeast-1:724...:DelayNotification",
                Message=sms_message
            )

    return {
        'statusCode': 200,
        'body': sms_message,
        'res': res
    }

```


# AWS Lambdaへのアップ

[公式チュートリアル](https://docs.aws.amazon.com/lambda/latest/dg/getting-started.html)に従ってLambdaのfunctionを作成できます。

今回は標準外のパッケージ（requestsとBeautifulSoup）を使うので、AWS Lambdaのコンソール上からコードを打ちこむだけでは動かず、パッケージを含めてZIPファイルを作り、それをアップする必要があります。[ZIPファイルを使ってでデプロイする公式のチュートリアル](https://docs.aws.amazon.com/lambda/latest/dg/python-package.html)に従って以下にようにしました：

```shell
zip my-deployment-package.zip lambda_function.py
pip install --target ./package urllib3==1.26.15 requests beautifulsoup4
cd package
zip -r ../my-deployment-package.zip .
```

ここで、`urllib3==1.26.15`としないとうまくいかなくてはまりました（こうしないと、2023/05時点では`requests`と整合がとれないようです）。

これでできた`my-deployment-pakage.zip`を、LambdaコンソールのUplard fromボタンからアップします：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/6a95c92d-3dec-6d91-2857-694197ed5ef6.png)

# Amazon EventBridgeの設定

平日朝に定期実行したいので、EventBridgeを使ってLambdaを定期実行するようにします。

[チュートリアル](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-get-started.html)に従ってruleを作成します。Rule typeではScheduleを選択します：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/aead472c-fdb9-76dd-3f9c-506114c5989c.png)

Schedule patternは以下のようにしました：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/19eb7dc0-c4db-aefd-3c81-7e743ee55c0f.png)

Cron表現で指定しています、これで平日の07:30に実行されます。注意点としては、Day of monthとDay of the weekは、片方だけを指定して、もう片方は「?」にする必要があります。

Flexibule time windowはOffにしました：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/098ef5f0-56b2-a9c0-2590-289bb73125c2.png)

同時に沢山のイベントトリガーをかけるような場合はFlexibule time windowを指定すると、処理が時間的に分散してよいことがある[ようです](https://www.reddit.com/r/aws/comments/137by03/whats_the_use_case_for_the_flexible_time_setting/)。今回は1日に1回実行するのみなのでOffとしています。

Time zoneは東京を指定します：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/a7c9572c-b26d-1d37-01ea-48d9a729304e.png)

Select targetでLambdaを選択し、Deploy済みのLambda functionを指定します（ここで、EventBridgeを作っているリージョンとLambdaをdeployしたリージョンが一致していないと、deployしたfunctionが選択肢に現れれないので注意です）。

# おわりに

これで、遅延がある場合には、出勤前にSMS通知が来るようになりました。この先の発展版としては、Googleカレンダーを読んで、在宅予定日のみ動作するようにしようかと思っています。

Happy coding!