
【競馬AI開発#57】データ収集を「コマンド一発」で運用できるスクリプトを作成する
この【競馬AI開発】シリーズでは、競馬予想AIを作ることを通して、機械学習・データサイエンスの勉強になるコンテンツの発信や、筆者が行った実験の共有などを行っています。
■今回やること
前回の記事では、競馬AIの予測に必要なデータをnetkeiba.comから集めてくる仕組みについて、「期間を指定するだけで、今どこまで取得できているか・次に何を取りに行くべきかを自動で判断してくれる」notebookに作り直しました。
今回はその仕上げで、このデータ収集処理を「コマンド一発で完結するシステム」に完成させていきます。これまではnotebook上でセルを上から順に実行していく必要がありましたが、これからは次の1行をターミナル上で実行するだけで、データ収集がすべて完了します。
# 2020年1月〜2025年12月のデータをまとめて取得する
uv run python common/src/collect_data.py --from-ym 2020-01 --to-ym 2025-12また、--status を付けて実行すると、「今どこまでデータが取れているか」という期間内のデータ取得状況を一覧表示することができます。
# 取得状況だけを確認する(スクレイピングはしない)
uv run python common/src/collect_data.py --status --from-ym 2020-01 --to-ym 2025-12出力例:
=== 2020-01 〜 2025-12 のデータ取得状況 ===
[race_id] 開催 race_id 一覧 (race_id_list.csv)
チェック内容: 期間内の各月について race_id_list.csv に該当月の race_id が登録されているか
対象: 72 ヶ月 (2020-01 〜 2025-12)
登録済月数: 72 / 72 (100.00%)
期間内 race_id: 20,733 件
[race] レースページからのデータ取得状況 (results / race_info / return_tables)
チェック内容: 期間内の全 race_id が各 csv に登録されているか
対象 race_id: 20,733 件
results.csv: 20,733 / 20,733 (100.00%)
race_info.csv: 20,733 / 20,733 (100.00%)
return_tables.csv: 20,733 / 20,733 (100.00%)
[horse] 馬の過去成績 (horse_results.csv)
チェック内容: 期間内に出走する horse_id が最新まで取得済か
対象 horse_id: 35,891 種
最新まで取得済: 35,878 / 35,891 (99.96%)
古い (要再取得): 2 件
未取得: 11 件
[ped] 血統 (peds.csv)
チェック内容: 期間内に出走する horse_id が peds.csv に登録済か
対象 horse_id: 35,891 種
取得済: 35,891 / 35,891 (100.00%)
[leading] リーディング (jockey / trainer / sire)
チェック内容: 期間内の各年×タイプ について page_id が rawdf に登録済か
対象年: 6 年 (2019 〜 2024) ※後段で前年データとして使用
jockey_leading.csv: 6 / 6 年
trainer_leading.csv: 6 / 6 年
sire_leading.csv: 6 / 6 年本記事では、この新しい collect_data.py の使い方・実装方法を1から解説していきます。
■競馬AIについて
netkeiba.comから収集したデータを機械学習モデルに学習させることで期待リターンを計算し、リターンが最大化するように自動投票を行う競馬予想AIを開発しています。
すでにシステムとしては一通り完成しており、現在はこの完成したシステムをベースに、特徴量の追加やモデルの改善など、さらなる精度向上に取り組んでいる段階です。
このシリーズでは、こうしたシステムをゼロから構築する過程を、コード付きで詳しく解説しています。ソースコードは、下に進んでダウンロード・解凍すれば使うことができるので、ぜひ実際にコードを動かしてみてください。
1. なぜ、データ収集をスクリプト化するのか
1.1. notebook(.ipynb)とスクリプト(.py)の使い分け
そもそも、なぜnotebookをスクリプトに作り変えるのでしょうか。
notebook形式でのコーディングに慣れた方にとっては、動機がイマイチ分かりづらいかもしれないので、少し丁寧に説明します。

◆Notebook(.ipynb)が得意なこと
ノートブックは、セルを一つずつ実行しながら、出力を見て次の手を考えられる「対話的(インタラクティブ)」なツールです。開発時の試行錯誤段階では、
レスポンス(取得したHTML)の中身を1セルずつ確認したい。
取得した直後のDataFrameをその場で表示して、列の中身を見たい。
一部のセクションだけ実行して、エラーが出たら原因を調べたい。
といった「探索的な使い方」が欲しくなります。このような場合に、ノートブックは非常に強力なツールとなります。
一方で裏を返すと、セルをどの順番でも実行できてしまうため、実行したい処理が決まった後のワークフローとしては再現性の担保がしづらい・ユーザーが実行判断を迷いやすいといったデメリットがあります。
◆スクリプト(.py)が得意なこと
これに対してスクリプト(.py)は、常に上から順に・一本道で実行されるので再現性を担保しやすく、システムに組み込みやすいというメリットがあります。
特に最近では大きなメリットとなるのが、Claude Code等のAIエージェントからも呼び出しやすくなる点で、スクリプト化(CLI化)しておくと実行・モニタリング・デバッグ等をAIに任せることもできます。
つまり、やり方をまだ探っている処理はnotebook、手順が固まった定型処理はスクリプト、という使い分けが基本になります。データ収集は「やるべきことが固まっていて、欠損や重複なく回したい処理」なので、まさにスクリプト化に向いた題材というわけです。
1.2. 今回のゴール
上記を踏まえ、今回作るデータ収集スクリプト( collect_data.py )では、以下のことを実現したいと思います。
【ゴール】
どんな状況(新規実行時・再開時)でも「取得したい期間」のみ指定して1行だけコマンドを実行すれば、追加で必要なデータを判定し、自動で集めてくれる仕組みを完成させる
2. スクリプト版「データ取得システム」の使い方
具体的な実装方法は後ほど解説するとして、まずは新設したスクリプト版データ取得システム(collect_data.py) の使い方を解説しつつ、完成イメージを見ていきます。
2.1. 実行の前提と、3つの引数
本プロジェクトはパッケージ管理に uv を使っているので、実行は次の形になります。
uv run python common/src/collect_data.py [オプション]uvの導入方法・使い方については以下の記事で詳しく解説しているので、参考にしてください。
まずは、どんな引数(オプション)が使えるのかを確認しましょう。--help を付けて実行すると、使い方の一覧が表示されます。
uv run python common/src/collect_data.py --help出力:
usage: collect_data.py [-h] --from-ym YYYY-MM [--to-ym YYYY-MM] [--status]
netkeiba データ収集
options:
-h, --help show this help message and exit
--from-ym YYYY-MM 取得開始月 (yyyy-mm, 例: 2020-01)
--to-ym YYYY-MM 取得終了月 (yyyy-mm)。省略時は前月。当月以降は指定不可。
--status 現在のデータ取得状況 (show_status) のみ表示して終了する角括弧で囲まれている引数は「省略可能」という意味で、--from-ym だけは角括弧が付いておらず、これは省略できない必須の引数であることを表しています。

2.2. まずは現在の状況を見る(--status)
何かを取得する前に、「いま何がどこまで取れているのか」を確認してみましょう。--status を付けると、状況だけを表示して終了します。
ここでは例として、2025年9月分について「まだ何も取得していない状態」で実行したとします。
uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-09 --status出力:
対象期間: 2025-09 〜 2025-09
=== 2025-09 〜 2025-09 のデータ取得状況 ===
[race_id] 開催 race_id 一覧 (race_id_list.csv)
チェック内容: 期間内の各月について race_id_list.csv に該当月の race_id が登録されているか
対象: 1 ヶ月 (2025-09 〜 2025-09)
登録済月数: 0 / 1 (0.00%)
未取得月: 2025-09
[race] レースページからのデータ取得状況 (results / race_info / return_tables)
チェック内容: 期間内の全 race_id が各 csv に登録されているか
→ race_id_list.csv の取得完了後に確認可能
[horse] 馬の過去成績 (horse_results.csv)
チェック内容: 期間内に出走する horse_id が最新まで取得済か
→ results.csv の取得完了後に確認可能
[ped] 血統 (peds.csv)
チェック内容: 期間内に出走する horse_id が peds.csv に登録済か
→ results.csv の取得完了後に確認可能
[leading] リーディング (jockey / trainer / sire)
チェック内容: 期間内の各年×タイプ について page_id が rawdf に登録済か
対象年: 1 年 (2024 〜 2024) ※後段で前年データとして使用
jockey_leading.csv: 未作成 (0 / 1 年)
trainer_leading.csv: 未作成 (0 / 1 年)
sire_leading.csv: 未作成 (0 / 1 年)今のデータ収集の仕組みは race_id(レースID)→ race(レース結果)→ horse(馬の過去成績)/ ped(血統) という順番に依存関係があります。
まだ race_id が取れていない段階では、その先の race や horse は「→ race_id_list.csv の取得完了後に確認可能」というように案内されます。

2.3. データ収集を実行する
それでは、実際にデータを集めてみましょう。--status を外して実行すると、収集モードになります。ここでは1ヶ月分だけまず指定してみましょう。
uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-09このコマンド1つで、すべてのデータが順番に収集されます。
出力:
対象期間: 2025-09 〜 2025-09
race_id を取得
追加スクレイピング対象月数: 1
100%|██████████| 1/1 [00:01<00:00, 1.20s/it]
取得した開催日数: 9
[race_id plan]
対象期間: 2025-09 〜 2025-09
対象開催日 (kaisai_calendar から抽出): 9 日
race_id スクレイピング予定: 9 日
追加スクレイピング対象日数: 9
100%|██████████| 9/9 [00:21<00:00, 2.34s/it]
取得した race_id 数: 240
race ページからのデータ取得
[race plan]
対象期間: 2025-09 〜 2025-09
対象 race_id (race_id_list から抽出): 240 件
HTML スクレイピング予定: 240 件
results.csv create 予定: 240 件
race_info.csv create 予定: 240 件
return_tables.csv create 予定: 240 件
100%|██████████| 240/240 [05:53<00:00, 1.47s/it]
100%|██████████| 240/240 [00:08<00:00, 28.05it/s]
100%|██████████| 240/240 [00:05<00:00, 44.26it/s]
100%|██████████| 240/240 [00:07<00:00, 30.01it/s]
horse ページからのデータ取得
[horse plan]
HTML 取得予定 horse_id: 2,784 件 (古い 0 + 未取得 2,784)
horse_results.csv create 予定: 2,784 件
100%|██████████| 2784/2784 [2:33:21<00:00, 3.30s/it]
100%|██████████| 2784/2784 [00:59<00:00, 46.85it/s]
血統ページからのデータ取得
[ped plan]
HTML スクレイピング予定: 2,784 件
peds.csv create 予定: 2,784 件
100%|██████████| 2784/2784 [2:41:06<00:00, 3.47s/it]
100%|██████████| 2784/2784 [01:02<00:00, 44.30it/s]
リーディングページからのデータ取得
[jockey leading plan]
scrape 予定 year: 1 年 [2024]
jockey_leading.csv create 予定: 0 件
scrape_html_leading[jockey, 2024]: 取得 4 件, スキップ 0 件
[trainer leading plan]
scrape 予定 year: 1 年 [2024]
trainer_leading.csv create 予定: 0 件
scrape_html_leading[trainer, 2024]: 取得 5 件, スキップ 0 件
[sire leading plan]
scrape 予定 year: 1 年 [2024]
sire_leading.csv create 予定: 0 件
scrape_html_leading[sire, 2024]: 取得 15 件, スキップ 0 件
=== 収集完了 ===ユーザーは期間を渡しただけで、「何を・どの順で・何件取るか」をすべてスクリプト側が判断してくれます。
最後に === 収集完了 === が表示されれば、その期間のデータは一通り揃った状態になります。
2.4. 初回・追加・中断後全て「同じ1コマンド」でよい
ここがこのシステムで一番こだわったところです。
どんな場面でも、実行するコマンドは同じでよく、「何を・どの順で・何件取るか」をすべてスクリプト側が判断してくれます。

いくつかの典型的なケースを見てみましょう。
◆もう一度実行しても安全(冪等性)
データ収集が完全に終わっている状態で、もう一度まったく同じコマンドを実行してみます。
uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-09出力(抜粋):
対象期間: 2025-09 〜 2025-09
race_id を取得
追加スクレイピング対象月数: 0
0it [00:00, ?it/s]
取得した開催日数: 0
[race_id plan]
対象期間: 2025-09 〜 2025-09
対象開催日 (kaisai_calendar から抽出): 9 日
race_id スクレイピング予定: 0 日
追加スクレイピング対象日数: 0
race ページからのデータ取得
[race plan]
対象期間: 2025-09 〜 2025-09
対象 race_id (race_id_list から抽出): 240 件
HTML スクレイピング予定: 0 件
results.csv create 予定: 0 件
race_info.csv create 予定: 0 件
return_tables.csv create 予定: 0 件
0it [00:00, ?it/s]
0it [00:00, ?it/s]
create_results: 処理対象が0件のためスキップ
0it [00:00, ?it/s]
create_race_info: 処理対象が0件のためスキップ
0it [00:00, ?it/s]
create_return_tables: 処理対象が0件のためスキップ
horse ページからのデータ取得
[horse plan]
HTML 取得予定 horse_id: 0 件 (古い 0 + 未取得 0)
horse_results.csv create 予定: 0 件
0it [00:00, ?it/s]
0it [00:00, ?it/s]
create_horse_results: 処理対象が0件のためスキップ
血統ページからのデータ取得
[ped plan]
HTML スクレイピング予定: 0 件
peds.csv create 予定: 0 件
0it [00:00, ?it/s]
0it [00:00, ?it/s]
create_peds: 処理対象が0件のためスキップ
リーディングページからのデータ取得
[jockey leading plan]
scrape 予定 year: 0 年 []
jockey_leading.csv create 予定: 0 件
0it [00:00, ?it/s]
create_jockey_leading: 処理対象が0件のためスキップ
[trainer leading plan]
scrape 予定 year: 0 年 []
trainer_leading.csv create 予定: 0 件
0it [00:00, ?it/s]
create_trainer_leading: 処理対象が0件のためスキップ
[sire leading plan]
scrape 予定 year: 0 年 []
sire_leading.csv create 予定: 0 件
0it [00:00, ?it/s]
create_sire_leading: 処理対象が0件のためスキップ
=== 収集完了 ===すべてのセクションが「予定 0 件」「処理対象が0件のためスキップ」となり、CSVの行数は実行前と完全に一致(何も追加されない)します。
◆期間を追加したいとき
前回の記事と同様、様々なケースでの挙動を見ていきましょう。
例えば、すでに2025年9月分が揃っている状態で、10月分も追加したくなったとします。やることは、--to-ym を 2025-10 に変えるだけです。
uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-10出力例:
対象期間: 2025-09 〜 2025-10
race_id を取得
追加スクレイピング対象月数: 1
HTML取得中...: 100%|██████████| 1/1 [00:01<00:00, 1.35s/it]
取得した開催日数: 9
[race_id plan]
対象期間: 2025-09 〜 2025-10
対象開催日 (kaisai_calendar から抽出): 18 日
race_id スクレイピング予定: 9 日
追加スクレイピング対象日数: 9
HTML取得中...: 100%|██████████| 9/9 [00:21<00:00, 2.40s/it]
取得した race_id 数: 264
race ページからのデータ取得
[race plan]
対象期間: 2025-09 〜 2025-10
対象 race_id (race_id_list から抽出): 504 件
HTML スクレイピング予定: 264 件
results.csv create 予定: 264 件
race_info.csv create 予定: 264 件
return_tables.csv create 予定: 264 件
HTML取得中...: 100%|██████████| 264/264 [06:31<00:00, 1.48s/it]
rawdf作成中...: 100%|██████████| 264/264 [00:09<00:00, 28.51it/s]
rawdf作成中...: 100%|██████████| 264/264 [00:05<00:00, 44.26it/s]
rawdf作成中...: 100%|██████████| 264/264 [00:08<00:00, 30.01it/s]
horse ページからのデータ取得
[horse plan]
HTML 取得予定 horse_id: 2,161 件 (古い 0 + 未取得 2,161)
horse_results.csv create 予定: 2,161 件
HTML取得中...: 100%|██████████| 2161/2161 [54:18<00:00, 1.51s/it]
rawdf作成中...: 100%|██████████| 2161/2161 [01:21<00:00, 26.54it/s]
血統ページからのデータ取得
[ped plan]
HTML スクレイピング予定: 2,161 件
peds.csv create 予定: 2,161 件
HTML取得中...: 100%|██████████| 2161/2161 [56:02<00:00, 1.56s/it]
rawdf作成中...: 100%|██████████| 2161/2161 [00:51<00:00, 42.18it/s]
リーディングページからのデータ取得
[jockey leading plan]
scrape 予定 year: 0 年 []
jockey_leading.csv create 予定: 0 件
create_jockey_leading: 処理対象が0件のためスキップ
[trainer leading plan]
scrape 予定 year: 0 年 []
trainer_leading.csv create 予定: 0 件
create_trainer_leading: 処理対象が0件のためスキップ
[sire leading plan]
scrape 予定 year: 0 年 []
sire_leading.csv create 予定: 0 件
create_sire_leading: 処理対象が0件のためスキップ
=== 収集完了 ===期間内のレースは合計504件に増えましたが、すでに取得済みの9月分240件は自動で除外され、新しく追加された10月分264件だけが取得対象になります。horse / ped も同様に、10月分の出走馬で更新が必要なものだけが対象になり、リーディングは2024年分が揃っていれば再取得は不要です。
◆更新が必要な馬がいるとき
次に、「HTMLは持っているが、その後に走った新しいレースが反映されていない馬」が存在するパターンです。
こうした馬がいる状態で --status を見ると、次のように表示されます。
[horse] 馬の過去成績 (horse_results.csv)
チェック内容: 期間内に出走する horse_id が最新まで取得済か
対象 horse_id: 2,784 種
最新まで取得済: 2,779 / 2,784 (99.82%)
古い (要再取得): 5 件
未取得: 0 件「古い(要再取得)」が5件と表示されています。この状態で収集を実行すれば、「horse plan」がその5頭だけを再取得対象として拾ってくれます。
[horse plan]
HTML 取得予定 horse_id: 5 件 (古い 5 + 未取得 0)
horse_results.csv create 予定: 5 件馬を「最新まで取得済(up_to_date)/古い(stale)/未取得(missing)」の3つに分類する仕組みは#56の記事で詳しく解説しているので、ここでは「古い馬も自動で見つけて取り直してくれる」という結果だけ押さえておけば十分です。
◆スクレイピングが途中で止まったとき
スクレイピングは長時間かかるため、ネットワークエラーやPCのスリープで途中で止まることがよくあります。そんなときも、特別な操作は不要で、もう一度同じコマンドを実行するだけです。

たとえばhorseページのスクレイピング中に、処理が途切れてしまった場合の動作例を見てみましょう。
uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-09出力:
対象期間: 2025-09 〜 2025-09
race_id を取得
追加スクレイピング対象月数: 1
HTML取得中...: 100%|██████████| 1/1 [00:01<00:00, 1.20s/it]
取得した開催日数: 9
[race_id plan]
対象期間: 2025-09 〜 2025-09
対象開催日 (kaisai_calendar から抽出): 9 日
race_id スクレイピング予定: 9 日
追加スクレイピング対象日数: 9
HTML取得中...: 100%|██████████| 9/9 [00:21<00:00, 2.34s/it]
取得した race_id 数: 240
race ページからのデータ取得
[race plan]
対象期間: 2025-09 〜 2025-09
対象 race_id (race_id_list から抽出): 240 件
HTML スクレイピング予定: 240 件
results.csv create 予定: 240 件
race_info.csv create 予定: 240 件
return_tables.csv create 予定: 240 件
HTML取得中...: 100%|██████████| 240/240 [05:53<00:00, 1.47s/it]
rawdf作成中...: 100%|██████████| 240/240 [00:08<00:00, 28.05it/s]
rawdf作成中...: 100%|██████████| 240/240 [00:05<00:00, 44.26it/s]
rawdf作成中...: 100%|██████████| 240/240 [00:07<00:00, 30.01it/s]
horse ページからのデータ取得
[horse plan]
HTML 取得予定 horse_id: 2,784 件 (古い 0 + 未取得 2,784)
horse_results.csv create 予定: 2,784 件
HTML取得中...: 86%|████████▌ | 2388/2784 [49:36<08:05, 1.22s/it]
↑ここで処理が途切れたとするこの場合も「どこから再開するか考える」必要はなく、もう一度同じコマンドを実行するだけです。
uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-09出力:
対象期間: 2025-09 〜 2025-09
race_id を取得
追加スクレイピング対象月数: 0
HTML取得中...: 0it [00:00, ?it/s]
取得した開催日数: 0
[race_id plan]
対象期間: 2025-09 〜 2025-09
対象開催日 (kaisai_calendar から抽出): 9 日
race_id スクレイピング予定: 0 日
追加スクレイピング対象日数: 0
race ページからのデータ取得
[race plan]
対象期間: 2025-09 〜 2025-09
対象 race_id (race_id_list から抽出): 240 件
HTML スクレイピング予定: 0 件
results.csv create 予定: 0 件
race_info.csv create 予定: 0 件
return_tables.csv create 予定: 0 件
HTML取得中...: 0it [00:00, ?it/s]
rawdf作成中...: 0it [00:00, ?it/s]
create_results: 処理対象が0件のためスキップ
rawdf作成中...: 0it [00:00, ?it/s]
create_race_info: 処理対象が0件のためスキップ
rawdf作成中...: 0it [00:00, ?it/s]
create_return_tables: 処理対象が0件のためスキップ
horse ページからのデータ取得
[horse plan]
HTML 取得予定 horse_id: 2,784 件 (古い 0 + 未取得 2,784)
うち bin 取得済み・csv 未登録: 2,388 件 (先行 csv 化対象)
horse_results.csv create 予定: 2,784 件
rawdf作成中...: 100%|██████████| 2388/2388 [01:32<00:00, 25.84it/s]
[horse plan]
HTML 取得予定 horse_id: 396 件 (古い 0 + 未取得 396)
horse_results.csv create 予定: 396 件
HTML取得中...: 100%|██████████| 396/396 [08:16<00:00, 1.25s/it]
rawdf作成中...: 100%|██████████| 396/396 [00:13<00:00, 28.53it/s]
血統ページからのデータ取得
[ped plan]
...処理が終わっているraceページ部分まではスキップされ、horseページ部分は、
まず先ほど取得した2388件のHTMLをcsv化
→残りの396件をスクレイピング
→csvに追加
という処理が走ることがわかります。
3. 実装の詳しい解説
ここからは具体的な実装コードについて詳しく解説していきます。