競馬予想AIを作りたい（データ収集編）

# まえがき
とりあえず作ってみようのノリなので、精度とかはそれほど考えてません。
[netkeiba.com](https://www.netkeiba.com/?rf=logo)に記録されている全レース（約16.8万レース）を収集する関係でハチャメチャに時間がかかるので、参考にする人は気をつけてください。

> 【追記】
馬柱が表示され始めたのは2007年の後半からだったので、最終的には2008年からのデータのみを使うことにした。

環境は以下の通り。
- Windows10
- Python3.12

# 集めるデータ

今回は、レース情報から各馬の着順を予想するAIを目指す。

人間（一般人）が予想に使うデータを考えると、以下の要素に主に注目するはず。
- 人気
- 単勝オッズ
- 馬番
- 距離
- レース場
- 芝 or ダート
- 馬場
- 直近の成績
- ジョッキー
- 馬主
- 血統
- 前走からの期間

これらのうち、今回は以下のデータを収集する。
- 人気
- 単勝オッズ
- 馬番
- 直近の成績（直近5レース分）
- 前走からの期間（日数）

芝 or ダート、距離を選ばない理由は、直近5レースと大きく変わることは少ないだろうから。
馬場を選ばない理由は、~~よくわからない~~あまり影響しない気がするから。
レース場、ジョッキー、馬主、血統を選ばない理由は、学習時にはユニークIDにする必要があるから。
ユニークIDにしたところで、AI内部では条件分岐とかしないので無駄な気がする。（IDの大小と影響力に相関が無い）

# データスクレイピングの方針

今回は中央競馬の全レースを対象としてスクレイピングする。スクレイピングするサイトは[netkeiba.com](https://www.netkeiba.com/?rf=logo)とする。レース情報が一通り取得できるので選択した。

このサイトから、次の2段階に分けてデータを収集する。
1. レースIDを収集
2. レース情報を収集

レース情報を閲覧するには、以下のようなページにアクセスする必要がある。
```
https://race.netkeiba.com/race/shutuba.html?race_id=202305021011&rf=race_submenu
```

URLを見ると`race_id=202305021011`というパラメータが存在する。この`race_id`はただの日付ではないので、総当たり的なことは難しい（というかカッコ悪いのでヤダ）。
ということで、事前にレースIDを全て収集しておき、そのIDを順番に使いながら予想に使うデータを集めることにする。

## レースIDを収集
### スクレイピングのアプローチを検討
__全レースを検索__
レースIDを収集するため、[レース検索サービス](https://db.netkeiba.com/?pid=race_list&word=)を使用する。
中央のレース場を全て選択し、期間についても初年度っぽい1975年1月から2022年12月までとする。（2023年以降はテスト用に残しておく）
これで検索すると`160,886レース`あることが確認できる。
これらを全て記録していくためにURLを分析する。
最初のページURLを見ると以下のようになっているが、このままではページ番号の指定ができないので、seleniumが必要になってしまう。
```
https://db.netkeiba.com/?pid=race_list&word=&start_year=1975&start_mon=1&end_year=2022&end_mon=12&（省略）&sort=date&list=100
```

しかし、往々にしてURLにはページ番号が付くもの。2ページ目を開いてURLを見直してみるとアラ不思議。
```
https://db.netkeiba.com//?（省略）&list=100&page=2
```
という感じで、末尾に`page=2`という表示が確認できる。このパラメータを`1`に変えてアクセスしてみると1ページ目が表示されたので、これで`requests.get`で読み込めそうだと分かる。

また、何ページまであるか確認するために計算すると、1609ページになる。

以上のことから、1ページ目から1609ページ目までをスクレイピングすれば良いと分かった。

レースIDを取得するには、各レースのURLを解析する。
デベロッパツールでレース名を選択すると、以下のように`href`で指定されていることが確認できる。
```HTML
<a href="/race/202209060605/" title="2歳新馬">2歳新馬</a>
```
この`/race/202209060605/`の`202209060605`がレースIDとなるので、これを片っ端から収集する。

__そのURLにアクセスすれば良いのでは？__
開けば分かるけど、レース結果しか無いので直近5レースの情報が得られない。そのため、一旦レースIDを記録しておいて、あとからレースごとに解析する。



### 解析プログラム
以下のコマンドで必要なライブラリをインストールしておく。
```
pip install requests beautifulsoup4
```

まずはHTMLを読み込むプログラムから記述する。
```Python
import requests as req
from bs4 import BeautifulSoup

def parseHTML(url):
    res = req.get(url)
    res.encoding = "euc-jp"
    bs = BeautifulSoup(res.text, features="html.parser")
    return bs

url = "https://db.netkeiba.com//?pid=race_list&word=&start_year=1975&start_mon=1&end_year=2022&end_mon=12&jyo%5B0%5D=01&jyo%5B1%5D=02&jyo%5B2%5D=03&jyo%5B3%5D=04&jyo%5B4%5D=05&jyo%5B5%5D=06&jyo%5B6%5D=07&jyo%5B7%5D=08&jyo%5B8%5D=09&jyo%5B9%5D=10&kyori_min=&kyori_max=&sort=date&list=100&page={}"

bs = parseHTML(url.format(1))
print(bs.body)
```

以下の部分で読み込むページ番号を指定している。`requests.get`でHTMLを`euc-jp`で読み込み、BS4でHTMLをパースしている。
```Python
bs = parseHTML(url.format(1))
```

今度はレース一覧のテーブルから1行ずつ読み込む必要がある。
デベロッパツールを使って解析すると、目的のテーブルは`race_table_01`というclass属性を持っていることが確認できるため、以下のように書くことで抽出できる。
```Python
table = bs.body.find("table", class_="race_table_01")
```

あとは1行ずつ読み込んでから、目的の列情報を取ってくるだけ。
```Python
trs = table.find_all("tr")[1:]
for tr in trs:
    title = tr.find_all("td")[4]
    raceID = title.find("a").get("href").split("/")[-2]
    print(raceID)
```

これで1ページ目のレース100件のIDを一通り取得できた。他のページについてはループでぶん回せば取得可能。
あとは取得したレースIDをテキストファイルなどに記録しておく。

## レース情報を取得
（再掲）以下の情報をスクレイピングしていく。
- 人気
- 単勝オッズ
- 馬番
- 直近の成績（直近5レース分）
- 前走からの期間（日数）

これらに加えて、着順の情報も集める。

### スクレイピングのアプローチを検討
まず着順と馬番、人気と単勝オッズから収集する。これは以下のURLのレース結果から取得する。
```
https://db.netkeiba.com/race/201608030901
```
このURLは、末尾にレースIDが付くことが分かる。レースIDはさっき集めたものを使う。

次に直近5レースの成績を収集する。
これは馬柱と呼ばれる情報であり、以下のURLから取得できる。
```
https://race.netkeiba.com/race/shutuba_past.html?race_id=201608030901&rf=shutuba_submenu
```
このURLもド直球に`race_id=`なんて書いてくれてるので分かりやすい。
ただし、このページは動的生成なのでseleniumが必要である。

### 解析プログラム
まずはseleniumをインストールする。
```
pip install selenium
```
あとはseleniumのセットアップを以下のプログラムで行う。
```Python
from selenium import webdriver
from time import sleep
from datetime import datetime

options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
```

__着順などをスクレイピングする__
レースによっては出走中止や取り消し、除外などによって一部情報が取得できない。
そういった欠落情報は全て0で埋める。

```Python
raceResult = "https://db.netkeiba.com/race/{}/"
raceID = "202405021001"

# レースごとの馬情報を辞書で管理
raceInfo = {}
for i in range(1, 19):
    raceInfo[i] = {
        "着順": 0,
        "単勝": 0.0,
        "人気": 0,
        "前走からの日数": 0,
        "前走順位": 0,
        "2走順位": 0,
        "3走順位": 0,
        "4走順位": 0,
        "5走順位": 0
    }
rankingBS = parseHTML(raceResult.format(raceID))

# レース開催日を取得
raceDay = rankingBS.body.find("p", class_="smalltxt").text.split(" ")[0]
raceDay = datetime.strptime(raceDay, "%Y年%m月%d日")

# 着順、馬番、単勝、人気を取得
table = rankingBS.body.find("table", class_="race_table_01")
trs = table.find_all("tr")
for tr in trs[1:]:
    tds = tr.find_all("td")
    number = int(tds[2].text)  # 馬番
    if tds[0].text.isdigit():
        raceInfo[number]["着順"] = int(tds[0].text)
        raceInfo[number]["単勝"] = float(tds[12].text)
        raceInfo[number]["人気"] = int(tds[13].text)
    else:
        if tds[0].text in ["除", "取"]:
            raceInfo[number]["人気"] = 0
        else:
            raceInfo[number]["人気"] = int(tds[13].text)
        raceInfo[number]["着順"] = 0
        raceInfo[number]["単勝"] = 0.0
```

__直近5レース（馬柱）を取得__
このページは、前述のとおりseleniumが必要となる。
馬柱テーブルが動的生成されるため、1秒待ってからHTMLを取得している。
このページで前走の日付も取得できるので、さっき抽出したレース日との差分を取って`前走からの期間（日数）`を求めている。

```Python
hasira5 = "https://race.netkeiba.com/race/shutuba_past.html?race_id={}&rf=shutuba_submenu"

driver.get(hasira5.format(raceID))
sleep(1)
hasiraBS = BeautifulSoup(driver.page_source, features="html.parser")
body = hasiraBS.body
table = body.find("table", class_="Shutuba_Past5_Table").find("tbody")

trs = table.find_all("tr")
for tr in trs:
    tds = tr.find_all("td")
    number = int(tds[1].text)
    zenso = tds[5:10]
    for i in range(5):
        if zenso[i].get("class")[0] == "Past":
            data = zenso[i].find("div", class_="Data_Item")
            data1 = data.find("div", class_="Data01")
            data1Span = data1.find_all("span")
            lastDay = data1Span[0].text.split(" ")[0]
            data1Rank = data1Span[1].text
            data1Rank = int(data1Rank) if data1Rank.isdigit() else 0
            raceInfo[number][hasiraTitle[i]] = data1Rank
            del data, data1, data1Span, data1Rank

            # 前走からの経過日数を計算
            if i == 0:
                lastDay = datetime.strptime(lastDay, "%Y.%m.%d")
                dd = raceDay - lastDay
                raceInfo[number]["前走からの日数"] = dd.days
                del lastDay, dd  # メモリリーク対策
                
# メモリリーク対策に、不要なものは削除
del number, raceDay, trs, tds, zenso
del rankingBS, hasiraBS, body, table
```

__レース情報を保存__

あとは順番に保存するだけ。

```Python
# レース情報を保存
with open(f"race_data/{raceID}.csv", "w", encoding="utf-8") as f2:
    f2.write("着順,馬番,単勝,人気,前走からの日数,前走順位,2走順位,3走順位,4走順位,5走順位\n")
    for i in range(1, 19):
        row = f"{raceInfo[i]["着順"]},"
        row += f"{i},"
        row += f"{raceInfo[i]["単勝"]},"
        row += f"{raceInfo[i]["人気"]},"
        row += f"{raceInfo[i]["前走からの日数"]},"
        row += f"{raceInfo[i]["前走順位"]},"
        row += f"{raceInfo[i]["2走順位"]},"
        row += f"{raceInfo[i]["3走順位"]},"
        row += f"{raceInfo[i]["4走順位"]},"
        row += f"{raceInfo[i]["5走順位"]}\n"
        f2.write(row)

del raceInfo, row

driver.close()
```

# あとがき
これで全160,886レースの収集が可能となる。
ただ、1万レースくらいごとにseleniumの読み込みが固まって無限Loading編が始まるし、1件ごとに数秒かかるので死ぬほど時間がかかる。
執筆時点で24時間が経過したが、未だに2.3万レース分しか終わってない。
まぁ、気長に集めましょう。