プロ野球ファンが"今日のベストナインbot"を作ってみた

# はじめに

その日の投球・打撃成績をスクレイピングし，その日のベストナイン自動で選出してツイートするTwitter botを作ってみたので，ざっくりまとめておきます．

興味ある方はぜひフォローしてみてください.
https://twitter.com/todaysbest9

<img width="400" alt="todaysbest9.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840171/48d7932c-a8b9-ec72-2c8f-05e24d79b124.png">

ツイートまでの大まかな流れは次の通りです．

1. プロ野球の試合成績のスクレイピング
1. スコアの算出
1. ベストナインの選出
1. ツイート

# 実装

それぞれについて説明していきます．

## 試合成績のスクレイピング

試合成績はいつも愛用しているYahooのスポーツナビからスクレイピングすることにしました．

また，スクレイピングには，PythonライブラリのrequestsとBeautifulSoupを使いました．

### 各試合成績へのURL取得

スポーツナビでは試合成績が載っているURLは日ごとに変化するようだったので，その日行われる試合日程がまとめられている https://baseball.yahoo.co.jp/npb/schedule から各試合の詳細ページへのURLを取得することにしました．

```python:index.py
import requests
from bs4 import BeautifulSoup
import datetime

# dateで受け取った日に開催された各試合の出場成績のリンクをリスト形式で返す
# date: 試合日（例：2021-09-24）
def fetch_game_links(date):
  params = { 'date': date }
  schedule_page = requests.get('https://baseball.yahoo.co.jp/npb/schedule', params=params)
  soup_schedule = BeautifulSoup(schedule_page.text, 'html.parser')
  game_link_elms = soup_schedule.find_all('a', class_='bb-score__content')
  game_links = list(map(lambda x: x['href'].replace('index', 'stats'), game_link_elms))
  return game_links

if __name__ == '__main__':
  d_today = datetime.date.today()
  game_links = fetch_game_links(d_today) # 試合リンクの取得
```

```:出力例
['https://baseball.yahoo.co.jp/npb/game/2021000857/stats', ...]
```

find_all内の条件は，サイトに応じてChromeのデベロッパーツールなどを使いながら考えます．

### 打撃・投手成績取得

```python:index.py
...(略)...

# game_linkで受け取ったリンク先の野手成績をリスト形式で返す
def fetch_batter_stats(game_link):
  game_page = requests.get(game_link)
  soup_game = BeautifulSoup(game_page.text, 'html.parser')
  batter_stats_rows = soup_game.find_all('tr', class_='bb-statsTable__row')
  stats_list = []
  # 野手成績
  for row in batter_stats_rows:
    stats_html = row.find_all('td', class_='bb-statsTable__data')
    if stats_html:
      stats_list.append(stats_html)
  return stats_list

# game_linkで受け取ったリンク先の投手成績をリスト形式で返す
def fetch_pitcher_stats(game_link):
  game_page = requests.get(game_link)
  soup_game = BeautifulSoup(game_page.text, 'html.parser')
  pitcher_stats_rows = soup_game.find_all('tr', class_='bb-scoreTable__row')
  stats_list = []
  # 投手成績
  for row in pitcher_stats_rows:
    stats_html = row.find_all('td', class_='bb-scoreTable__data')
    if stats_html:
      stats_list.append(stats_html)
  return stats_list

if __name__ == '__main__':
  d_today = datetime.date.today()
  game_links = fetch_game_links(d_today) # 試合リンクの取得
  all_batter_stats = []
  all_pitcher_stats = []
  for game_link in game_links:
    # 野手成績取得
    batter_stats = fetch_batter_stats(game_link)
    all_batter_stats.extend(batter_stats)
    # 投手成績取得
    pitcher_stats = fetch_pitcher_stats(game_link)
    all_pitcher_stats.extend(pitcher_stats)
```

```:出力例
[[<td class="bb-statsTable__data bb-statsTable__data--bat">(中)</td>, <td class="bb-statsTable__data bb-statsTable__data--player"><a href="/npb/player/1800065/top">近本 光司</a></td>, <td class="bb-statsTable__data">.315</td>, ...]
[[<td class="bb-scoreTable__data bb-scoreTable__data--state">勝</td>, <td class="bb-scoreTable__data bb-scoreTable__data--player"><a href="/npb/player/1700078/top">髙橋 遥人</a></td>, ...]
```

これで野手成績・投手成績に関する部分のみ抽出することはできましたが，まだHTMLのタグなど無駄な情報が多く，データの前処理が必要です．

### データの前処理

ここでは主に次のことをやっています．

1. タグの削除
1. ベストナイン選出に不要な情報の削除や置換
1. 型変換

```python:index.py
import re

...(略)...

# game_linkで受け取ったリンク先の野手成績ををリスト形式で返す
def fetch_batter_stats(game_link):
  ...(略)...
  # 野手成績
  for row in batter_stats_rows:
    stats_html = row.find_all('td', class_='bb-statsTable__data')
    if stats_html:
      stats = list(map(lambda x: x.text, stats_html))[:14]
      stats[0] = re.sub("[()打走]", '', stats[0])
      stats[0] = '指' if len(stats[0]) == 0 else stats[0][0] # 最初に出場したポジションのみに変換（代打のみの場合は指名打者扱い）
      stats[3:] = list(map(int, stats[3:])) # 野手成績をintに変換
      stats_list.append(stats)
  return stats_list

# game_linkで受け取ったリンク先の投手成績ををリスト形式で返す
def fetch_pitcher_stats(game_link):
  ...(略)...
  # 投手成績
  for row in pitcher_stats_rows:
    stats_html = row.find_all('td', class_='bb-scoreTable__data')
    if stats_html:
      stats = list(map(lambda x: x.text, stats_html))[:14]
      stats[0] = '投'
      stats[1] = stats[1].replace('\n', '')
      stats[3] = float(stats[3]) # 投球回をfloatに変換
      stats[4:] = list(map(int, stats[4:])) # 投手成績をintに変換
      stats_list.append(stats)
  return stats_list
```

```:出力例
[['中', '近本 光司', '.315', 4, 1, 2, 0, 1, 0, 0, 0, 0, 0, 0], ['遊', '中野 拓夢', '.277', 4, 0, 2, 2, 0, 0, 0, 0, 1, 1, 0], ...]
[['投', '髙橋 遥人', '\n2.25\n', 9.0, 128, 34, 5, 0, 13, 1, 0, 0, 0, 0], ['投', '菅野 智之', '\n3.53\n', 7.1, 117, 29, 6, 1, 6, 1, 0, 0, 3, 3], ...]
```

## スコアの算出

ベストナイン選出のためにその日の成績を総合的に評価する指標が必要です．

今回は，安打数や打点などの取得した情報の重み付け和を算出し，それを指標として用いることにしました．

重みは自分で決めていますが，これがかなり難しい…

打点と安打の価値や投球回数と失点数のバランスなどを数値化する必要があるので，野球に対する思想が結構出ちゃいますね笑

```python:index.py
from operator import mul

...(略)...

# statsで受け取った成績から総合スコアを計算する
def calc_batter_score(stats):
  # 打数，　得点，　安打，　打点，　三振，　四球，　死球，　犠打，　盗塁，　失策，　本塁打
  eval_list = [-0.05, 0, 4, 2.5, 0, 1.5, 1, 1, 1.5, -2, 1] # statsの第3要素以降と加重和をとる
  score = sum(list(map(mul, stats[3:], eval_list)))
  return score

# statsで受け取った成績から総合スコアを計算する
def calc_pitcher_score(stats):
  # 投球回, 投球数, 打者, 被安打, 被本塁打, 奪三振, 与四球, 与死球, ボーク, 失点, 自責点
  eval_list = [3, 0, 0, -0.05, -0.05, 0.05, -0.1, -0.05, -0.05, -0.5, -2] # statsの第3要素以降と加重和をとる
  score = sum(list(map(mul, stats[3:], eval_list)))
  return score

# game_linkで受け取ったリンク先の野手成績ををリスト形式で返す
def fetch_batter_stats(game_link):
  ...(略)...
      stats[3:] = list(map(int, stats[3:])) # 野手成績をintに変換
      stats.append(calc_batter_score(stats)) # スコア算出
      stats_list.append(stats)
  return stats_list

# game_linkで受け取ったリンク先の投手成績ををリスト形式で返す
def fetch_pitcher_stats(game_link):
  ...(略)...
      stats[4:] = list(map(int, stats[4:])) # 投手成績をintに変換
      stats.append(calc_pitcher_score(stats)) # スコア算出
      stats_list.append(stats)
  return stats_list
```

```:出力例
[['中', '近本 光司', '.315', 4, 1, 2, 0, 1, 0, 0, 0, 0, 0, 0, 7.8], ['遊', '中野 拓夢', '.277', 4, 0, 2, 2, 0, 0, 0, 0, 1, 1, 0, 12.3], ...]
[['投', '髙橋 遥人', '\n2.25\n', 9.0, 128, 34, 5, 0, 13, 1, 0, 0, 0, 0, 27.299999999999997], ['投', '菅野 智之', '\n3.53\n', 7.1, 117, 29, 6, 1, 6, 1, 0, 0, 3, 3, 13.649999999999995], ...]
```

## ベストナインの選出

あとは各ポジションでスコアが最も高かった人をベストナインとして選出するだけです．

```python:index.py
...(略)...

# ベストナインを返す
def select_best9(batter_stats, pitcher_stats):
  best9_dic = {
      '投': None,
      '捕': None,
      '一': None,
      '二': None,
      '三': None,
      '遊': None,
      '右': None,
      '左': None,
      '中': None,
      '指': None,
  }
  count = 0
  best9_dic['投'] = pitcher_stats[0]
  for stats in batter_stats:
    if stats[0] in best9_dic.keys() and best9_dic[stats[0]] == None:
        best9_dic[stats[0]] = stats
        count += 1
        if count == 9:
          break
  return best9_dic

if __name__ == '__main__':
  d_today = datetime.date.today()
  game_links = fetch_game_links(d_today) # 試合リンクの取得
  all_batter_stats = []
  all_pitcher_stats = []
  for game_link in game_links:
    # 野手成績取得
    batter_stats = fetch_batter_stats(game_link)
    all_batter_stats.extend(batter_stats)
    all_batter_stats = sorted(all_batter_stats, key=lambda x: x[-1], reverse=True)
    # 投手成績取得
    pitcher_stats = fetch_pitcher_stats(game_link)
    all_pitcher_stats.extend(pitcher_stats)
    all_pitcher_stats = sorted(all_pitcher_stats, key=lambda x: x[-1], reverse=True)
```

```:出力例
{'投': ['投', '髙橋 遥人', '\n2.25\n', 9.0, 128, 34, 5, 0, 13, 1, 0, 0, 0, 0, 27.299999999999997], '捕': ['捕', '會澤 翼', '.238', 4, 1, 3, 1, 0, 0, 0, 0, 0, 0, 1, 15.3], ...}
```

## ツイート

## ツイートする
tweepyを使用して実装しました．

なお，TwitterAPIの使用には申請が必要です．こちらの記事などが参考になると思います．

https://www.itti.jp/web-direction/how-to-apply-for-twitter-api/

```python:index.py
import tweepy

...(略)...

# ツイート内容（ベストナイン）
def tweet_content_best9(date, best9_stats):
  content = f'{date}のベストナイン\n\n'
  for (position, stats) in best9_stats.items():
    row = f'【{position}】'
    if position == '投':
      row += f'{stats[1]}（{stats[3]}-{stats[-2]}）'
    else:
      row += f'{stats[1]}（{stats[3]}-{stats[5]}-{stats[6]}）'
    content += row + '\n'
  return content

# ツイートする
def tweet(tweet_content):
  API_KEY="APIKeyを設定"
  API_KEY_SECRET="APIKeySecretを設定"
  ACCESS_TOKEN="AccessTokenを設定"
  ACCESS_TOKEN_SECRET="AccessTokenSecretを設定"
  # Twitterオブジェクトの生成
  auth = tweepy.OAuthHandler(API_KEY, API_KEY_SECRET)
  auth.set_access_token(ACCESS_TOKEN, ACCESS_TOKEN_SECRET)
  api = tweepy.API(auth)
  # ツイート
  api.update_status(tweet_content)

if __name__ == '__main__':
  d_today = datetime.date.today()
  game_links = fetch_game_links(d_today) # 試合リンクの取得
  all_batter_stats = []
  all_pitcher_stats = []
  for game_link in game_links:
    # 野手成績取得
    batter_stats = fetch_batter_stats(game_link)
    all_batter_stats.extend(batter_stats)
    all_batter_stats = sorted(all_batter_stats, key=lambda x: x[-1], reverse=True)
    # 投手成績取得
    pitcher_stats = fetch_pitcher_stats(game_link)
    all_pitcher_stats.extend(pitcher_stats)
  if len(all_batter_stats) == 0 or len(all_pitcher_stats) == 0:
    print("No data")
    sys.exit()
  best9_stats = select_best9(all_batter_stats, all_pitcher_stats)
  best9_content = tweet_content_best9(d_today, best9_stats)
  tweet(best9_content)
```

これでツイートできるようになりました！
実際のツイートがこちらです！

<img width="400" alt="todaysbest9.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840171/48d7932c-a8b9-ec72-2c8f-05e24d79b124.png">


## 定期自動ツイート

プログラムを実行することでベストナインを自動で選出しツイートすることができるようになりましたが，プログラムの実行も自動化したいところです．

今回はAWS LambdaとCloudWatch Eventsを用いてスケジューリングしました．

本記事ではこの辺りの詳細は割愛しますが，以下のドキュメントが参考になると思います．

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-handler.html

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/RunLambdaSchedule.html


# おわりに

このbotを作ってから野球を見る楽しさが倍増しました笑

このアカウントではその日のベストナインの他に，野手成績ランキングや投手成績ランキングも自動投稿しているので，是非フォローしてみてください！
https://twitter.com/todaysbest9

<img width="400" alt="batterbest6.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840171/6629344b-0a57-2c3a-3e83-229fd45f1fe4.png">