大谷選手がホームランを打ったことをLINE通知してみた。

##はじめに

####<font color="Red">2021/10/10（日）　追記</font>
####<font color="Red">MLBの2021年シーズンの終了に伴い、本日をもってサービスの運用を終了しました!!</font>

大谷選手の活躍が目覚ましいですね！！
普段野球はほとんど観ない私ですら、ついついホームランを打ったかどうか、結果が気になってしまいます。

しかし、結果は気になるものの、
・試合日程は把握していない
・そのため、適宜Yahoo!ニュースをチェックして大谷選手がホームランを打っていないかチェックする
という状態であり、段々とチェックするのが面倒になってきてしまいました。
（試合日程を確認すればいいだけなのですが・・・）

そこで、大谷選手がHRを打ったことをLINE通知する仕組みを作ってみました。

↓マーベラス、オオタニサン！！
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626568/a1798208-c109-06c3-4204-15c2b7dcb14e.png">

## 通知内容

通知内容は
・大谷選手がHRを打った旨のメッセージ（１パターンのみ）
・画像
・Yahooの記事
の３点です。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626568/68fc3900-fb1b-083a-f52e-23fe3a175ad5.png">

※「現在の大谷選手のホームラン数は○○号です。」は自分の確認用通知です。

画像はアフロから買えないかなと思って調べてみたのですが、大谷選手の画像は報道関係者しか買えないとのことで断念。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626568/d5aa7114-245f-b7f2-54a5-71bc43201eb1.png">

フリーの画像を作成されている方がいらっしゃったので、こちらからお借りしました。
[大谷翔平動くイラスト無料GIF](https://freesozaixtrain.web.fc2.com/gif-ohtani.html)

また、本当はgif画像を送信したかったのですが、LINE MESSAGING　APIの仕様でできないとのこと。
（2021年7月30日時点）

####参考
- [Messaging APIリファレンス - 画像メッセージ](https://developers.line.biz/ja/reference/messaging-api/#image-message)

## 実装手順
1. LambdaでYahoo!ニュースのトップ画面から見出しの情報をスクレイピングし、「大谷　○○号」という情報があればそれを取り出す。
2. DynamoDBから大谷選手のHRの数を取り出し、記事から取り出した数字「○○」と比較する。
3. 記事から取り出した数字と次のホームラン数が一致すれば、LINE Messaging APIで通知を送る。
4. 通知を送った後に、DynamoDBのHRの数を更新する。
5. 作成したLambda関数をCloudWatchで5分間隔で実行する。

## 使用技術・構成図

- Python3.8
- AWS Lambda
- Amazon DynamoDB
- Amazon S3
- Amazon CloudWatch
- LINE Messaging API

<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626568/f8553817-f920-d554-f535-2f6d76c5317b.png">

## 1. LambdaでYahoo!ニュースのトップ画面から見出しの情報をスクレイピングし、「大谷　○○号」という情報があればそれを取り出す。
スクレイピングはサイトによっては禁止されている場合もあるので、右上のヘルプからYahoo Japanの利用規約をチェックします。
見たところ、特に規定はないようでした。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626568/23c643b6-75cd-63f3-a697-dd19ad40d703.png">

（Yahooファイナンスだと下のようにスクレイピングは禁止と明記されています。）
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626568/0be8a186-3850-b160-a956-d40422905611.png">



まずはスクレイピングしてYahooの見出し情報を取得します。

```py:lambda_function.py
import json
import requests
from bs4 import BeautifulSoup
import re
import boto3

dynamodb = boto3.resource('dynamodb') 
# DynamoDBのテーブル名
table_name ="MLB-Homerun"
table = dynamodb.Table(table_name)
primary_key = {'id':1}

# Yahoo Japanトップの見出し記事から大谷関連の記事のみ取り出す
def scrape():
    # Yahoo Japanのトップページ情報を取得する
    URL = "https://www.yahoo.co.jp/"
    rest = requests.get(URL)

    # BeautifulSoup4でYahoo Japanのトップページの見出し内容を読み込む
    soup = BeautifulSoup(rest.text, "html.parser")

    # Yahoo Japanの見出しとURLの情報を取得する
    data_list = soup.find_all(href=re.compile("news.yahoo.co.jp/pickup"))
    
    # 大谷選手関連の記事情報
    ohtani_related_article = ''
    ohtani_related_article_url = ''
    
    # 大谷選手関連の見出しのみ取り出す
    for data in data_list:
        article = data.span.string
        if ('大谷' in article) & ('号' in article):
            ohtani_related_article = article
            ohtani_related_article_url = data.attrs["href"]
            break
    return ohtani_related_article, ohtani_related_article_url
```

※lambda関数の作成に当たっては下記ライブラリをローカル環境でインストール&zip化してlambdaにアップする必要があります。
beautifulsoup4/requests/line-bot-sdk

バンドルしていない状態でlambda関数を実行するとこんなエラーが出ます。

```
[ERROR] Runtime.ImportModuleError: Unable to import module 'app': No module named 'bs4'
```

これまでにみてきた感じでは見出しのトップに「大谷、30号」のように記載されていたので、「大谷」と「（数字）号」の２つのキーワードを含むものを取り出しています。
（このやり方では問題がありますが、それは最後にまとめています。）

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626568/0a2476ce-19e1-3a36-e8ed-cec050e9f6d6.png">

####参考
- [lambdaに外部モジュールを読み込ませる](https://hacknote.jp/archives/48083/)
- [【Webスクレイピング入門】ヤフーニュース の見出しとURLを取得する方法](https://rurukblog.com/post/python-webscraping-ynews/)
- [【AWS】Lambdaでpipしたいと思ったときにすべきこと](https://qiita.com/Hironsan/items/0eb5578f3321c72637b4)
- [毎日定時に推し画像が送られてくるLINE Bot を作った](https://qiita.com/soma_sekimoto/items/4c01d0ab890024d6f87c)

##2. DynamoDBから大谷選手のHRの数を取り出し、記事から取り出した数字「○○」と比較する。

DynamoDBのテーブルはこんな感じ。
（こんなさみしいテーブル見たことない・・・）
現在大谷選手とHR数を競っているゲレロJr.選手のHR数も通知できればと思ったのですが、そちらは方法が思いつかず断念しました。

<img width="250%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626568/96d41687-0b60-6460-62aa-fe00b502f8da.png">

```:lambda_function.py
from botocore.exceptions import ClientError

# DynamoDBから大谷選手のホームランの数を取得
def get_homerun():
    try:
        response = table.get_item(Key = primary_key)
    except ClientError as e:
        print(e.response['Error']['Message'])
    else:
        return response['Item']
```
primary_keyにはlambda_functionのグローバルスコープで定義しているid=1が入ります。

###ホームランの数を比較する

```py:lambda_function.py
def homerun_comparison():
    ohtani_related_article, ohtani_related_article_url = scrape()
    url = ''
    
    # DynamoDBから大谷選手のホームラン数を取得
    ohtani_homerun = get_homerun()
    next_ohtani_homerun = ohtani_homerun['homerun'] + 1
    
    # 見出しから数字だけ取り出す
    result = re.findall(r"\d+", ohtani_related_article)
    # 取り出した数字から大谷選手の次のホームランの数に一致するものがあればその記事のURLをリターンする
    for numberInArticle in result:
        if int(numberInArticle) == next_ohtani_homerun:
            url = ohtani_related_article_url
            break
    return next_ohtani_homerun, url
```

## 3. 記事から取り出した数字と次のホームラン数が一致すれば、LINE Messaging APIで通知を送る。

```py
from linebot import LineBotApi
from linebot.models import (TextSendMessage, ImageSendMessage) 

def lambda_handler(event, context):
    access_token = "自分のチャネルアクセストークン"
    line_bot_api = LineBotApi(access_token)
    
    next_ohtani_homerun, url = homerun_comparison()
    image_message = ImageSendMessage(
        original_content_url = '<S3に格納した画像のURL>',
        preview_image_url = '<S3に格納した画像のURL>'
        )
    
    if url:
        # LINEメッセージを通知
        message = '大谷選手が%d本目のホームランを打ちました！！' % next_ohtani_homerun
        line_bot_api.broadcast(TextSendMessage(text = message))
        line_bot_api.broadcast(image_message)
        line_bot_api.broadcast(TextSendMessage(url))
```

## 4. 通知を送った後に、DynamoDBのHRの数を更新する。

```py
# DynamoDBの大谷選手のホームランの数を更新
def update_homerun(ohtani_total_homerun):
    homerun = ohtani_total_homerun
    response = table.update_item(
        Key = primary_key,
        UpdateExpression = 'set homerun = :newHomerun',
        ExpressionAttributeValues = {
            ':newHomerun':homerun   
        })

 # ホームランの数を更新
def lambda_handler(event, context):
        update_homerun(next_ohtani_homerun)
```

全てまとめたコードはこちらです。

```py
import json
import requests
from bs4 import BeautifulSoup
import re
from linebot import LineBotApi
from linebot.models import (TextSendMessage, ImageSendMessage) 
import boto3
from botocore.exceptions import ClientError

dynamodb = boto3.resource('dynamodb') 
# DynamoDBのテーブル名
table_name ="MLB-Homerun"
table = dynamodb.Table(table_name)
primary_key = {'id':1}

# Yahoo Japanトップの見出し記事から大谷関連の記事のみ取り出す
def scrape():
    # Yahoo Japanのトップページ情報を取得する
    URL = "https://www.yahoo.co.jp/"
    rest = requests.get(URL)

    # BeautifulSoup4でYahoo Japanのトップページの見出し内容を読み込む
    soup = BeautifulSoup(rest.text, "html.parser")

    # Yahoo Japanの見出しとURLの情報を取得する
    data_list = soup.find_all(href=re.compile("news.yahoo.co.jp/pickup"))
    
    # 大谷選手関連の記事情報
    ohtani_related_article = ''
    ohtani_related_article_url = ''
    
    # 大谷選手関連の見出しのみ取り出す
    for data in data_list:
        article = data.span.string
        if ('大谷' in article) & ('号' in article):
            # ペレス、ゲレロが見出しの先頭にある場合は更新しない
            if (('ペレス' in article) or ('ゲレロ' in article) or ('ゲレーロ' in article)):
                perez_position = article.find('ペレス')
                gurrero_position1 = article.find('ゲレロ')
                gurrero_position2 = article.find('ゲレーロ')
                ohtani_position = article.find('大谷')
                if (perez_position or gurrero_position1 or gurrero_position2) < ohtani_position:
                    break
                else:
                    ohtani_related_article = article
                    ohtani_related_article_url = data.attrs["href"]
                    break
            else:
                ohtani_related_article = article
                ohtani_related_article_url = data.attrs["href"]
                break
    return ohtani_related_article, ohtani_related_article_url
# ホームランの数が正しいか判定
def homerun_comparison():
    ohtani_related_article, ohtani_related_article_url = scrape()
    url = ''
    
    # DynamoDBから大谷選手のホームラン数を取得
    ohtani_homerun = get_homerun()
    # 現在のホームラン数の次のホームラン数
    next_ohtani_homerun = ohtani_homerun['homerun'] + 1
    
    # 見出しから数字だけ取り出す
    result = re.findall(r"\d+", ohtani_related_article)
    # 取り出した数字から大谷選手の次のホームランの数に一致するものがあればその記事のURLをリターンする
    for numberInArticle in result:
        if int(numberInArticle) == next_ohtani_homerun:
            url = ohtani_related_article_url
            break
    return next_ohtani_homerun, url

def lambda_handler(event, context):
    access_token = "自分のチャネルアクセストークン"
    line_bot_api = LineBotApi(access_token)
    
    next_ohtani_homerun, url = homerun_comparison()
    image_message = ImageSendMessage(
        original_content_url = '<S3に格納した画像のURL>',
        preview_image_url = '<S3に格納した画像のURL>'
        )
    
    if url:
        # LINEメッセージを通知
        message = '大谷選手が%d本目のホームランを打ちました！！' % next_ohtani_homerun
        line_bot_api.broadcast(TextSendMessage(text = message))
        line_bot_api.broadcast(image_message)
        line_bot_api.broadcast(TextSendMessage(url))
        # ホームランの数を更新
        update_homerun(next_ohtani_homerun)

# DynamoDBから大谷選手のホームランの数を取得
def get_homerun():
    try:
        response = table.get_item(Key = primary_key)
    except ClientError as e:
        print(e.response['Error']['Message'])
    else:
        return response['Item']

# DynamoDBの大谷選手のホームランの数を更新
def update_homerun(ohtani_total_homerun):
    homerun = ohtani_total_homerun
    response = table.update_item(
        Key = primary_key,
        UpdateExpression = 'set homerun = :newHomerun',
        ExpressionAttributeValues = {
            ':newHomerun':homerun   
        })
```

##5. 作成したLambda関数をCloudWatchで5分間隔で実行する。
あまりにも短い間隔でリクエストを送ってしまうと、DoS攻撃と認定されてしまう可能性もあるとのことでしたが、5分間隔程度であれば問題ないとのことで、CloudWatchを活用し、5分間隔で定期実行しています。

- [1秒に1リクエストするクローラーは常識的か](http://blog.mwsoft.jp/article/39619765.html)
- [岡崎市立中央図書館事件](https://ja.wikipedia.org/wiki/%E5%B2%A1%E5%B4%8E%E5%B8%82%E7%AB%8B%E4%B8%AD%E5%A4%AE%E5%9B%B3%E6%9B%B8%E9%A4%A8%E4%BA%8B%E4%BB%B6#cite_note-atarashi_pp14-15-6)

## 問題点

Yahoo JapanのHPに依存している以上、実装している中でいくつかの問題点が浮かび上がってきました。

####①そもそもYahooの見出しに表示されなければ通知が届かない
これまで見てきた感じでは大谷選手がホームランを打つたびに見出しには表示されていたので、この点は問題ないと考えました。

####②見出しに表示されている数字によっては誤報を通知してしまう
例えば、現在のホームラン数が30本の時に、見出しに「大谷、今日31号なるか！？」や「大谷、31号はお預け」のように表示されていた場合、現在のロジックではLINEで通知されてしまいます。　

対処法：思いつかず・・・。
特に今の大谷選手のホームランのペースは、1961年にヤンキースのロジャー・マリス選手が樹立したリーグ記録61本のペースらしく、その記録に近づくにつれて見出しに表示される確率が高まります・・・。
※バリー・ボンズ選手が73本の記録をマークしているものの、ステロイド使用のために記者投票による殿堂入りは果たしていないとのこと。

参考
[大谷翔平ア・リーグ記録シーズン61発ペース！「限りなく黒に近い灰色」ではない「真の記録」に挑戦](https://www.chunichi.co.jp/article/288525)

####<font color="Red">2021年9月10日（金）　追記</font>
最近はペレス選手、ゲレロ選手の追い上げが凄いですね汗
ペレス選手が大谷選手のHR数まで1本差に迫っているらしく、確か下のような感じでYahooの見出しにも表示されていました。
「ペレスが猛追42号、本塁打トップ大谷翔平に１本差　ゲレロも41号放つ」
そして、現在のロジックでは赤字部分を元に処理判定をしているため、下記のような見出しの場合に通知が飛んでしまうことに気がつきました。
「ペレスが<font color="Red">44号</font>、<font color="Red">大谷</font>を抜かし本塁打トップに」
これでは誤報を通知してしまうことになってしまうので、下記改修を実施しました。
・「ペレス」「ゲレロ」「ゲレーロ」の文字が「大谷」の前に存在する場合、通知を飛ばさない
ペレス選手やゲレロ選手がHRトップになった場合、
「ペレスが44号、大谷超え」
のように表示される可能性が高く、逆に
「大谷抜かし、ペレスが44号」
のように表示される可能性は低いと判断したためです。
ただし、もちろん上記見出しが表示される可能性もなきにしもあらず、この辺りがこの通知機能の限界かな・・・？と思いました。

```py
    # 大谷選手関連の見出しのみ取り出す
    for data in data_list:
        article = data.span.string

        # ここから追加

        if ('大谷' in article) & ('号' in article):
            # ペレス、ゲレロが見出しの先頭にある場合は更新しない
            if (('ペレス' in article) or ('ゲレロ' in article) or ('ゲレーロ' in article)):
                perez_position = article.find('ペレス')
                gurrero_position1 = article.find('ゲレロ')
                gurrero_position2 = article.find('ゲレーロ')
                ohtani_position = article.find('大谷')
                if (perez_position or gurrero_position1 or gurrero_position2) < ohtani_position:
                    break
                else:
                    ohtani_related_article = article
                    ohtani_related_article_url = data.attrs["href"]
                    break
            else:
　　　　　　　　　　　　　　　　　　# ここまで

                ohtani_related_article = article
                ohtani_related_article_url = data.attrs["href"]
                break
    return ohtani_related_article, ohtani_related_article_url

```

###③ホームランを打ってから見出しに表示されるまでタイムラグがある
先日、早朝目が覚めてYahooのリアルタイムを見てみると、、、

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626568/0a005ba5-9ce9-f7c7-0734-360d1b4e418c.png">

なんと、通知は来ていないのに大谷選手がホームランを打っていました。

調べてみると、4:38分の時点で記事にはなっているものの、見出しには表示されていないようでした。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626568/0cd1fbe3-26e7-10df-d099-921e877e1293.png">

その後、6:18には見出しに表示され無事通知は届きましたが、大谷選手がホームランを打ってから約1時間半のタイムラグの後、通知が届く形となりました。

対処法：エンゼルスの試合開始時刻をDBに登録しておき、試合開始時刻以降に「大谷、○○号」という内容の通知があればLINEに通知する

Yahooのスポーツ記事の方ではいち早く通知が出ていたので、そちらを読み取ることも考えましたが、それでも試合開始時刻以降に「大谷、○○号なるか！？」みたいな記事があればそちらを読み取ってしまうので、完全な対処法とはならず、こちらも断念しました・・・。


<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626568/bf2da9a4-02d3-bdb1-b938-284d8a33c10f.png">


###さいごに
スクレイピング、LINE Botの実装も初めてで、pythonもほぼ経験なしなので、手探りでの実装でしたが、なんとか形にできてよかったです。
問題点は残ってしまってどのように対処すればいいのか考えていたんですが、絶対的な解決方法が思いつきませんでした・・・。
もし解決策を思いついた方がいらっしゃいましたら教えてください。