メインコンテンツへスキップ
見出し画像

【競馬AI開発#56】データ収集を「再実行するだけ」で復旧できる仕組みに作り直す

    この【競馬AI開発】シリーズでは、競馬予想AIを作ることを通して、機械学習・データサイエンスの勉強になるコンテンツの発信や、筆者が行った実験の共有などを行っています。

    ‍■今回やること
    今回は、競馬AIの予測に必要なデータをnetkeiba.comから集めてくるシステムについて、‍「今どこまで取得できているかを一目で把握でき、次に何を取りに行くべきかを自動で決める」‍仕組みを構築していきます。

    例えば以下のようなコードを実行すると、現状のデータ取得状況が一覧表示されるようになります。

    planner.show_status()

    出力例:

    === 2020-01 〜 2025-12 のデータ取得状況 ===
    
    [calendar] 開催カレンダー (kaisai_calendar.csv)
      チェック内容: 期間内の各月について kaisai_calendar.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 年

    そして「次にどのデータを取りに行けばいいか」も自動で確定します。

    [horse plan]
      HTML 取得予定 horse_id: 13 件 (古い 2 + 未取得 11)
      horse_results.csv create 予定: 13 件

    例えばこの例では、35,891頭のうち‍「古い2頭 + 未取得11頭」の合計13頭だけが自動で対象に絞られます。あとは続くセルを実行するだけで、足りない分だけ追加されていきます。

    ‍■競馬AIについて
    netkeiba.comから収集したデータを機械学習モデルに学習させることで期待リターンを計算し、リターンが最大化するように自動投票を行う競馬予想AIを開発しています。

    すでにシステムとしては一通り完成しており、現在はこの完成したシステムをベースに、特徴量の追加やモデルの改善など、さらなる精度向上に取り組んでいる段階です。

    このシリーズでは、こうしたシステムをゼロから構築する過程を、コード付きで詳しく解説しています。ソースコードは、下に進んでダウンロード・解凍すれば使うことができるので、ぜひ実際にコードを動かしてみてください。

    1. なぜ、システムを新しくするか

    1.1. これまでのデータ取得処理の課題

    競馬予想AIの学習データは、netkeiba.comからスクレイピングして集めています。これまでは1枚のノートブックの中に、

    • 開催日(kaisai_date)の取得

    • レースID(race_id)の取得

    • レースページのHTML取得・テーブル化(‍results.csv‍/‍race_info.csv‍/‍return_tables.csv‍)

    • 馬の過去成績ページのHTML取得・テーブル化(‍horse_results.csv‍)

    • 血統ページのHTML取得・テーブル化(‍peds.csv‍)

    • 騎手・調教師・種牡馬リーディングページのHTML取得・テーブル化

    といった処理を、上から順にセル実行していくことで集めていく構成になっていました。それぞれの個別の処理については、シリーズ初期の記事でゼロから解説しています。

    データ収集まわりの全体像についてはこちら。

    この作りは、最初に動かしているうちは特に問題を感じませんが、運用しているうちに不便な点がいくつか見えてきます。

    ‍■課題①:処理が途中で止まったときに迷う
    スクレイピングは、ネットワークエラーやnetkeiba.com側のレート制限、PCのスリープなどで途中で止まることがしばしばあります。馬の過去成績ページの取得は1万〜数万件単位になることもあり、特に長時間かかる処理です。

    これまでは、たとえば300頭目で止まってしまったら、次のように手動でリストをスライスして再開していました。

    # 途中から再開する例。進捗を見て途切れた所からリストを切り出す。
    html_paths_horse = scraping.scrape_html_horse(
        horse_id_list=horse_id_list[300:], skip=False
    )

    これでも良いのですが、再開のたびに手作業で判断する必要があり、人力的な運用となっています。

    ‍■課題②:新しい期間を追加したいときに、何のセルをどの順で叩けばいいか分かりにくい
    たとえば、すでに2024年12月までのデータが手元にある状態で、2025年1月分を追加で取りたくなったとします。

    旧来のノートブックでは、「どのセルを実行するか・何を入力して実行するか」を全てユーザー側が組み立てる必要がありました。判断ポイントが多数点在していて、初めて触る人にはどこを直せば「期間追加」が成立するのかが見えづらい状態でした。

    ‍■課題③:今、どこまでデータが取れているのかを一覧で確認できない
    ノートブックを久しぶりに開いたとき、「いま手元のデータって、どこまで揃ってるんだっけ?」を確かめるには、CSV を読み込んで「レース結果テーブル(results.csv) にどこまで入っているか」「馬の過去成績テーブル(horse_results.csv)はどの程度最新になっているか」等、自分で確認するコードを書く必要がありました。

    1.2. 今後実現したいこと

    #54の記事でも少し触れましたが、現在のソースコードは、適宜その場で様々なデータ確認や柔軟な実行ができるよう、notebook形式(.ipynb形式)で実行する仕組みになっていますが、裏を返すとそれは、毎回ユーザーがコードを組み立てる必要があるということでもあります。
    データ収集のように「やるべきことが固まっていて、ただ抜けや重複なく回せばよい処理」については、自由度より処理内容が固定されたスクリプトの形式(.py形式)で実行できる方が嬉しい場面が多いです。

    ただし、collect_data.ipynb 全体をいきなりスクリプト化するのには、少しジャンプがあります。スクレイピングの試行錯誤段階では、

    • レスポンスの中身を1セルずつ確認したい。

    • 取得後の DataFrame をその場で表示して、列の中身を見たい。

    • 一部のセクションだけ実行して、エラーが出たら原因を調べたい。

    といった「探索的な使い方」が依然として欲しいからです。.ipynbの強みはまさにここにあります。

    そこで今回は、まず notebook 上で「迷わず使える賢い設計」に作り直すところを先に整えます。CLI化はその先にあるステップで、notebook側の責務がきれいに分かれていれば、同じロジックを.pyスクリプトに薄く乗せるだけで済むはずです。


    以上の課題と方針を踏まえ、新しいデータ収集システムで実現したいことは以下のようになります。

    • 状態の見える化:期間を指定するだけで、「どのデータがどこまで取れていて、何が足りないのか」が表示される。

    • 冪等性:同じセルを何度実行しても、不要な処理や重複データの混入が起きない。

    • 途中中断・部分欠損からの自然回復:ノートブックを頭から流し直せば、足りない部分だけが自動で取り直される。

    2. 新しい「競馬データ取得システム」の使い方

    解説は後ほどするとして、まずは新しくなったデータ取得システムの使い方を見ていきましょう。
    ただ、使い方といってもシンプルで、データ取得用notebookであるcommon/src/collect_data.ipynb の「期間設定」部分のみ指定して、上から全て実行するだけです。(ソースコードは本記事の最後で案内しています)

    2.1. 初回実行の場合

    まずは「初回実行で2025年9月の1ヶ月分を取得する」という場面で、どのような挙動をするか見てみましょう。

    ‍▼common/src/collect_data.ipynb

    # 取得対象とする期間をyyyy-mm形式で指定する。
    FROM_YM = "2025-09"
    TO_YM = "2025-09"
    planner = CollectPlanner(FROM_YM, TO_YM)

    各開催日のレースID(race_id)を取得し、‍(kaisai_date, race_id)‍ の DataFrame を kaisai_calendar.csv に追記します。

    ‍▼common/src/collect_data.ipynb

    # 開催日一覧の取得
    kaisai_date_list = scraping.scrape_kaisai_date(from_=FROM_YM, to_=TO_YM)
    
    # race_id 一覧を取得し、kaisai_calendar.csv に追記
    kaisai_calendar = scraping.scrape_race_id_list(kaisai_date_list)
    create_rawdf.update_rawdf(
        kaisai_calendar, key="kaisai_date", save_filename="kaisai_calendar.csv"
    )
    kaisai_calendar

    DataFrame の中身は次のような形です。

        kaisai_date       race_id
    0      20250906  202506040101
    1      20250906  202506040102
    2      20250906  202506040103
    3      20250906  202506040104
    4      20250906  202506040105
    ..          ...           ...
    235    20250928  202509040908
    236    20250928  202509040909
    237    20250928  202509040910
    238    20250928  202509040911
    239    20250928  202509040912
    
    [240 rows x 2 columns]

    ‍scrape_kaisai_date()‍ が「期間内の開催日」を、scrape_race_id_list()‍ が「各開催日のレースID一覧」を取得します。

    次に、show_status()で現在のデータ取得状況を確認できます。

    ‍▼common/src/collect_data.ipynb

    planner.show_status()

    実行すると、期間内のデータ取得状況がセクションごとに一覧表示されます。

    === 2025-09 〜 2025-09 のデータ取得状況 ===
    
    [calendar] 開催カレンダー (kaisai_calendar.csv)
      チェック内容: 期間内の各月について kaisai_calendar.csv に該当月の race_id が登録されているか
        対象: 1 ヶ月 (2025-09 〜 2025-09)
        登録済月数: 1 / 1 (100.00%)
        期間内 race_id: 240 件
    
    [race] レースページからのデータ取得状況 (results / race_info / return_tables)
      チェック内容: 期間内の全 race_id が各 csv に登録されているか
        対象 race_id: 240 件
        results.csv: 未作成
        race_info.csv: 未作成
        return_tables.csv: 未作成
      → そのまま「4. race ページからのデータ取得」のセルから順に実行してください

    コメントの通り、「4. race ページからのデータ取得」のセルから順に実行します。

    ‍▼common/src/collect_data.ipynb

    planner.plan_race()
    [race plan]
      対象期間: 2025-09 〜 2025-09
      対象 race_id (kaisai_calendar から抽出): 240 件
      HTML スクレイピング予定: 240 件
      results.csv create 予定: 240 件
      race_info.csv create 予定: 240 件
      return_tables.csv create 予定: 240 件

    期間内 race_id 240件すべてが scrape & create 対象として確定しました。
    注目したいのは、plan_race()‍ によって「処理すべき内容」が自動的にplanner.race_ids_to_scrape等に格納されるので、ユーザーはそのまま次のセルを実行するだけでよい、という点です。「期間内 race_id のうち、HTMLが未取得のものはどれか」「results.csv に未登録のものはどれか」を、plan_race()‍ が自動で計算してくれているからです。

    ‍▼common/src/collect_data.ipynb

    # race ページの HTML を取得(既存はスキップ)
    _ = scraping.scrape_html_race(race_id_list=planner.race_ids_to_scrape)
    # レース結果テーブルの作成・更新
    results = create_rawdf.create_results(
        race_id_list=planner.race_ids_to_create_results
    )
    # レース情報テーブルの作成・更新
    race_info = create_rawdf.create_race_info(
        race_id_list=planner.race_ids_to_create_race_info
    )
    # 払い戻しテーブルの作成・更新
    return_tables = create_rawdf.create_return_tables(
        race_id_list=planner.race_ids_to_create_return_tables
    )

    「horse ページからのデータ取得」「pedページからのデータ取得」「リーディングページからのデータ取得」についても同様に、そのまま上から実行するだけで「何が不足しているか」を自動計算し、必要なデータを取得してくれます。

    ‍▼common/src/collect_data.ipynb

    planner.plan_horse()

    出力:

    ‍▼common/src/collect_data.ipynb

    [horse plan]
      HTML 取得予定 horse_id: 249 件 (古い 0 + 未取得 249)
      horse_results.csv create 予定: 249 件
    # stale な馬は HTML を再取得する必要があるので skip=False
    _ = scraping.scrape_html_horse(
        horse_id_list=planner.horse_ids_to_update, skip=False
    )
    # 馬の過去成績テーブルの作成
    horse_results = create_rawdf.create_horse_results(
        horse_id_list=planner.horse_ids_to_update
    )

    次に、ped(血統)ページからのデータ取得です。

    ‍▼common/src/collect_data.ipynb

    planner.plan_ped()

    出力:

    [ped plan]
      HTML スクレイピング予定: 249 件
      peds.csv create 予定: 249 件
    _ = scraping.scrape_html_ped(horse_id_list=planner.horse_ids_to_scrape_peds)
    peds = create_rawdf.create_peds(
        horse_id_list=planner.horse_ids_to_create_peds
    )

    次に、騎手リーディングページからのデータ取得です。

    ‍▼common/src/collect_data.ipynb

    planner.plan_jockey_leading()

    出力:

    [jockey leading plan]
      scrape 予定 year: 1 年 [2024]
      jockey_leading.csv: html_dir 配下の全 .bin から再生成

    leading_years はFROM_YM の前年〜TO_YM の前年が必要なので、2025-09 を期間としたこのケースでは‍[2024]‍ の1年だけが対象になります。

    for year in planner.jockey_years_to_scrape:
        _ = scraping.scrape_html_leading(leading_type="jockey", year=year)

    出力:

    scrape_html_leading[jockey, 2024]: 取得 4 件, スキップ 0 件
    jockey_leading = create_rawdf.create_jockey_leading()

    調教師・種牡馬リーディングページからのデータ取得についても、同様にそのまま上から実行するだけです。

    2.2. 必要なデータが取得済みの場合

    次に、初回実行以外のケース(既にデータが揃っている状態での再実行、期間延長、中断後の再開、など)について見ていきますが、ポイントは「どのような場面(初回実行・途中で処理が途切れた場合・期間を追加する場合でも、この実行方法で良い」という所です。

    まずは、データ収集が完全に終わっている状態で、もう一度 collect_data.ipynb を頭から実行するケースです。期間内(2025-09)の全 race / horse / ped / leading データが揃っているとします。

    ‍show_status()‍ は全セクションが100%と表示されます。

    === 2025-09 〜 2025-09 のデータ取得状況 ===
    
    [calendar] 登録済月数: 1 / 1 (100.00%) / 期間内 race_id: 240 件
    [race] results.csv: 240 / 240 (100.00%)
           race_info.csv: 240 / 240 (100.00%)
           return_tables.csv: 240 / 240 (100.00%)
    [horse] 最新まで取得済: 2,784 / 2,784 (100.00%) / 古い 0 件 / 未取得 0 件
    [ped] 取得済: 2,784 / 2,784 (100.00%)
    [leading] jockey/trainer/sire 全 1/1 年

    ‍plan_xxx()‍ はすべて「予定 0 件」を返します。

    [race plan]   HTML スクレイピング予定: 0 件 / create 予定: 0 件
    [horse plan]  HTML 取得予定 horse_id: 0 件 (古い 0 + 未取得 0)
    [ped plan]    HTML スクレイピング予定: 0 件 / peds.csv create 予定: 0 件
    [jockey leading plan]  scrape 予定 year: 0 年 [] / jockey_leading.csv create 予定: 0 件
    [trainer leading plan] scrape 予定 year: 0 年 [] / trainer_leading.csv create 予定: 0 件
    [sire leading plan]    scrape 予定 year: 0 年 [] / sire_leading.csv create 予定: 0 件

    各 scrape セルは引数が空リストなので何もせず、create セルは「処理対象が0件のためスキップ」と表示されて静かに終了します。CSVの行数は実行前後で完全に一致します。

    このように、何度実行しても同じ結果になる性質のことを冪等性といいます。スクレイピングのように途中で止まったり再実行したりすることが多い処理では、「もう一度実行しても、すでに取れている分を二重に取ったり壊したりしない」ことが重要になります。

    ここでは、ファイルがあるかどうかを「取得済みフラグ」代わりに使うというシンプルな方法で冪等性を実現しています。common/data/html/horse/result/{horse_id}.bin が存在すれば取得済み、無ければ取得する、というだけなので、別途データベースを用意する必要もありません。CollectPlanner は、その「ファイルの存在をチェックして、未取得分だけを計画する」役割を担っているわけです。

    2.3. 新しい期間を追加する場合

    ここでは期間を遡る場合:「9月までデータが揃っていて、これから8月分も遡って追加で取りたい」というケースを見てみましょう。FROM_YM を 2025-08 に変えるだけで対応できます。

    FROM_YM = "2025-08"‍ に書き換えて、改めて開催日と race_id を取得すると、8月分のレコードだけがkaisai_calendar.csvに追記されます(9月分は重複しません)。

    ‍show_status()‍ の出力は次のようになります。

    === 2025-08 〜 2025-09 のデータ取得状況 ===
    
    [calendar] 開催カレンダー (kaisai_calendar.csv)
        対象: 2 ヶ月 (2025-08 〜 2025-09)
        登録済月数: 2 / 2 (100.00%)
        期間内 race_id: 600 件
    
    [race] レースページからのデータ取得状況 (results / race_info / return_tables)
        対象 race_id: 600 件
        results.csv: 240 / 600 (40.00%)
        race_info.csv: 240 / 600 (40.00%)
        return_tables.csv: 240 / 600 (40.00%)
      → そのまま「4. race ページからのデータ取得」のセルから順に実行してください

    期間内 race_id が 240 から 600 に増え、既存の 9 月分は 40% カバーと表示されます。

    plan_race()‍ は以下のようになります。

    [race plan]
      対象 race_id (kaisai_calendar から抽出): 600 件
      HTML スクレイピング予定: 360 件
      results.csv create 予定: 360 件
      race_info.csv create 予定: 360 件
      return_tables.csv create 予定: 360 件

    既存 9 月分 240 件は想定通り除外され、8 月分 360 件のみが scrape & create 対象になります。

    horse / ped セクションも同じく、8 月分の出走馬で horse_results.csv や peds.csv に未登録の馬だけが対象になります。

    [horse plan]
      HTML 取得予定 horse_id: 2,647 件 (古い 0 + 未取得 2,647)
      horse_results.csv create 予定: 2,647 件

    リーディングは8 月を追加しても 2024 年のデータが揃っていれば再 scrape は不要です。

    [jockey leading plan]   scrape 予定 year: 0 年 [] / jockey_leading.csv create 予定: 0 件
    [trainer leading plan]  scrape 予定 year: 0 年 [] / trainer_leading.csv create 予定: 0 件
    [sire leading plan]     scrape 予定 year: 0 年 [] / sire_leading.csv create 予定: 0 件

    2.4. 更新が必要な馬がいる場合

    netkeiba.comの「馬の過去成績ページ」は、その馬が新しく出走したレースの成績を同じページに追加していく仕様のため、例えば「horse_results.csv は3ヶ月前のもので、その後に走った新しいレースが反映されていない馬が何頭かいる」という状態が存在します。

    画像

    そのような「データは存在するが、更新が必要な馬」がいる状態で show_status()‍ を実行すると、こうなります。

    [horse] 馬の過去成績 (horse_results.csv)
      チェック内容: 期間内に出走する horse_id が最新まで取得済か
        対象 horse_id: 2,784 種
        最新まで取得済: 2,779 / 2,784 (99.82%)
        古い (要再取得): 5 件
        未取得: 0 件

    ‍plan_horse()‍ は次のようになります。

    [horse plan]
      HTML 取得予定 horse_id: 5 件 (古い 5 + 未取得 0)
      horse_results.csv create 予定: 5 件

    内部の処理で、馬の過去成績ページの「最新日付」と、レース結果テーブル(results.csv)上での「期間内最新出走日」を比較して、以下の3つに分類してくれます。

    分類          条件                                        次の処理
    ──────────────────────────────────────────────────────────────
    up_to_date    horse_results.csv の最新日 ≧ 出走日         再取得不要
    stale         horse_results.csv の最新日 < 出走日         再scrape
    missing       horse_results.csv に未登録                 新規 scrape

    2.5. スクレイピングが途中で中断した場合

    スクレイピングは長時間に渡るため、途中で処理が途切れてしまうケースが多々あります。
    そのような場合でも、特別な操作は必要なく、再度実行するだけで必要な箇所から再開できる仕組みになっています。
    例えばraceページのスクレイピングが途中で止まってしまった場合、plan_race()‍ を再度実行した場合の出力はこうなります。

    [race plan]
      対象 race_id (kaisai_calendar から抽出): 240 件
      HTML スクレイピング予定: 120 件
      results.csv create 予定: 240 件
      race_info.csv create 予定: 240 件
      return_tables.csv create 予定: 240 件

    スクレイピング予定は120件となっています。

    horseページのスクレイピングが途中で切れてしまった場合も同様。

    # 残り100件で途切れてしまい、再度上から実行したケース
    [horse] 馬の過去成績 (horse_results.csv)
        最新まで取得済: 2,684 / 2,784 (96.41%)
        古い (要再取得): 0 件
        未取得: 100 件
    [horse plan]
      HTML 取得予定 horse_id: 100 件 (古い 0 + 未取得 100)
      horse_results.csv create 予定: 100 件

    3. 既存のソースコードを使っている人向けの差分

    このセクションでは、これまでのバージョンを使ってきた人向けに、変更点をまとめておきます。

    3.1. CollectPlanner の利用が前提になった

    期間内の race_id / horse_id / leading_years 等を自分で特定して渡す必要が無くなり、各セルから planner.race_ids_to_scrape などを参照する形に変わっています。

    # race ページの HTML を取得(既存はスキップ)
    _ = scraping.scrape_html_race(race_id_list=planner.race_ids_to_scrape)
    # レース結果テーブルの作成・更新
    results = create_rawdf.create_results(
        race_id_list=planner.race_ids_to_create_results
    )

    plannerによって作られる内部変数は以下のようになります。

    変数名                                対応する処理
    ──────────────────────────────────────────────────────────────
    race_ids_in_period                    期間内 race_id 一覧
    race_ids_to_scrape                    HTML 未取得の race_id
    race_ids_to_create_results            results.csv 未登録の race_id
    race_ids_to_create_race_info          race_info.csv 未登録の race_id
    race_ids_to_create_return_tables      return_tables.csv 未登録の race_id
    horse_ids_in_period                   期間内に出走した horse_id 一覧
    horse_ids_to_update                   古い + 未取得の horse_id
    horse_ids_to_create_peds              peds.csv 未登録の horse_id
    horse_ids_to_scrape_peds              上記のうち ped HTML が未取得の horse_id
    {jockey,trainer,sire}_years_to_scrape 各リーディングの scrape 対象 year

    そのため、各セクションでplan_xxx()‍ を呼んでから scrape / create セルを実行する、という順序を守ってください。

    3.2. create_rawdf 関数の引数が html_path_list から ID リストに変わった

    旧:
        html_paths = scraping.scrape_html_race(race_id_list)
        results = create_rawdf.create_results(html_path_list=html_paths)
    
    新:
        scraping.scrape_html_race(race_id_list=planner.race_ids_to_scrape)
        results = create_rawdf.create_results(
            race_id_list=planner.race_ids_to_create_results
        )

    旧バージョンでは「scrape_html_race の戻り値(HTMLファイルパスのリスト)を create_results に渡す」というスタイルでしたが、新バージョンでは‍「ID のリスト」を直接渡す形に変わっています。create_results は内部で html_dir / {race_id}.bin を組み立てて HTML を読み込みます。

    3.3. scraping 関数の戻り値・引数の変更

    scrape_race_id_list()‍ は戻り値が list[str]‍ から‍(kaisai_date, race_id) の DataFrame に変わりました。新しくkaisai_calendar.csv が kaisai_date 単位で更新される設計になったためです。
    あわせて ‍skip / kaisai_calendar_path 引数が追加され、デフォルト(‍skip=True‍)では kaisai_calendar.csv に既に登録済みの開催日を自動でスキップします(強制的に再取得したいときは ‍skip=False‍ を指定)。

    ‍▼common/src/modules/scraping.py

    def scrape_race_id_list(
        kaisai_date_list: list[str],
        sleep_time: float = 1.0,
        skip: bool = True,
        kaisai_calendar_path: Path = CommonPaths.rawdf / "kaisai_calendar.csv",
    ) -> pd.DataFrame:
        ...

    3.4. scrape_html_leading の自動ページネーション化

    ‍pages 引数の型が list[int] | range | None に拡張されました。None を渡すと「次」リンク追従モードになります。

    # 旧来の呼び出し (引き続き動作する)
    scraping.scrape_html_leading(leading_type="jockey", year=2024, pages=range(1, 6))
    
    # 新しい標準
    for year in planner.jockey_years_to_scrape:
        scraping.scrape_html_leading(leading_type="jockey", year=year)

    新しいワークフローでは、ユーザーが‍「何ページまで取るか」を考える必要がないのがポイントです。

    3.5. 中断後の再開方法

    旧バージョンでは、処理が止まった後は horse_id_list[21:]‍ のように渡すなど、手動で再開する必要がありました。

    新バージョンでは、何も気にせずノートブックを再実行するだけで済みます。Plannerが‍.bin と CSV の差分を見て scrape & create 予定を再計算してくれるので、ユーザーが再開位置を意識する必要はありません。

    4. 実装の詳しい解説

    ここからは新規追加されたcommon/src/modules/collect_planner.py の中身を解説します。

    4.1. CollectPlanner クラスの全体像

    まずはinit部分から

    ‍▼common/src/modules/collect_planner.py

    import pandas as pd
    
    from .paths import CommonPaths
    from .scraping import has_next_leading_page
    
    
    class CollectPlanner:
        """
        指定期間 (from_ym 〜 to_ym) のデータ取得を計画する Planner。
    
        - `show_status()`: 現在のデータ取得状況をサマリ表示する。
        - `plan_xxx()`: 各セクションで「これからスクレイピング・rawdf作成すべき ID 一覧」を確定し、
          インスタンス変数に格納する。
        """
    
        def __init__(
            self,
            from_ym: str,
            to_ym: str,
            kaisai_calendar_filename: str = "kaisai_calendar.csv",
        ) -> None:
            self.from_ym = from_ym
            self.to_ym = to_ym
            self.period_start = pd.Timestamp(f"{from_ym}-01")
            self.period_end = pd.Timestamp(f"{to_ym}-01") + pd.DateOffset(months=1)
            self.target_months = [str(p) for p in pd.period_range(from_ym, to_ym, freq="M")]
            # リーディングデータは前年までのデータを使用するため n-1 まで
            self.leading_years = list(range(int(from_ym[:4]) - 1, int(to_ym[:4])))
            self.kaisai_calendar_path = CommonPaths.rawdf / kaisai_calendar_filename

    ユーザーが指定するのはfrom_ym と to_ym だけで、Plannerはそこから次の派生情報をすべて自動計算します。

    インスタンス変数         意味
    ──────────────────────────────────────────────────────────────
    period_start            期間の開始 (例: 2025-09-01 00:00:00)
    period_end              期間の終了 (排他的, 例: 2025-10-01 00:00:00)
    target_months           期間内の月リスト (例: ["2025-09"])
    leading_years           リーディング対象年リスト ([n-1, ..., n-1])
                            ※ from_ym[:4]-1 〜 to_ym[:4]-1
    kaisai_calendar_path    開催カレンダーCSVのパス

    ‍leading_years が n-1 始まりになっているのは、リーディングは前年のデータを特徴量として使用するためです(例えば2025年のレースを予測するときは、2024年のリーディング成績を参照する)。

    メソッドは大きく以下の2系統に分かれます。

    公開メソッド             内部メソッド
    ──────────────────────────────────────────────────────────────
    show_status           _status_calendar / _status_race / _status_horse /
                          _status_ped / _status_leading
    plan_race             _read_race_ids_in_period / _ids_to_create
    plan_horse            _read_horse_ids_in_period / _classify_horses
    plan_ped              _ids_to_create
    plan_jockey_leading   _find_years_to_scrape
    plan_trainer_leading  _find_years_to_scrape
    plan_sire_leading     _find_years_to_scrape

    show_status() で現状把握 → plan_xxx() で計画を確定 → 計画したインスタンス変数を scrape / create に渡すというのが大まかな流れです。

    4.2. 詳細:馬の状態分類(_classify_horses)

    horse_id ごとに「最新まで取得済か / 古いか / 未取得か」を3分類するメソッドを‍_classify_horses()‍ に追加しました。

    先ほども少し触れましたが、馬の過去成績ページは同じURLに新しいレースの結果が追加されていく仕様のため、HTMLファイルが存在していても、それが最新まで取れているとは限りません。「3ヶ月前にスクレイピングしたまま放置されたbin」と「昨日スクレイピングした最新のbin」が、ファイルの存在だけからは区別できないのです。

    そこで‍_classify_horses()‍ は、‍「horse_results.csv に登録されている各馬の最新日付」と「results.csv での期間内最新出走日」を比較することで、各馬が最新まで取れているかを判定します。

    ‍▼common/src/modules/collect_planner.py

    def _classify_horses(self, target_horse_ids: list[str]) -> dict[str, list[str]]:
        """
        target_horse_ids を以下の 3 つに分類する。
        - up_to_date: horse_results.csv の最終日付が results.csv での最新出走日以上 (再取得不要)
        - stale: horse_results.csv に登録はあるが、results.csv での最新出走日のほうが新しい (再取得が必要)
        - missing: horse_results.csv に未登録 (新規取得が必要)
        """
        targets = list(dict.fromkeys(str(horse_id) for horse_id in target_horse_ids))
        horse_results_path = CommonPaths.rawdf / "horse_results.csv"
        if not horse_results_path.exists():
            return {"up_to_date": [], "stale": [], "missing": targets}
        horse_results = pd.read_csv(
            horse_results_path,
            sep="\t",
            usecols=["horse_id", "日付"],
            dtype={"horse_id": str},
        )
        horse_results_latest = (
            pd.to_datetime(horse_results["日付"], errors="coerce")
            .groupby(horse_results["horse_id"])
            .max()
        )
    
        results = pd.read_csv(
            CommonPaths.rawdf / "results.csv",
            sep="\t",
            usecols=["race_id", "horse_id"],
            dtype={"race_id": str, "horse_id": str},
        )
        kaisai_calendar = pd.read_csv(
            self.kaisai_calendar_path,
            sep="\t",
            dtype={"race_id": str, "kaisai_date": str},
        )
        kaisai_calendar["kaisai_date"] = pd.to_datetime(
            kaisai_calendar["kaisai_date"], format="%Y%m%d"
        )
        results_latest = (
            results.merge(kaisai_calendar, on="race_id", how="left")
            .groupby("horse_id")["kaisai_date"]
            .max()
        )
    
        classified: dict[str, list[str]] = {
            "up_to_date": [],
            "stale": [],
            "missing": [],
        }
        for horse_id in targets:
            horse_results_date = horse_results_latest.get(horse_id)
            if horse_results_date is None or pd.isna(horse_results_date):
                classified["missing"].append(horse_id)
                continue
            results_date = results_latest.get(horse_id)
            if results_date > horse_results_date:
                classified["stale"].append(horse_id)
            else:
                classified["up_to_date"].append(horse_id)
        return classified

    4.3. 詳細: リーディングデータの自動 year 判定(_find_years_to_scrape)

    リーディングデータは year 単位で複数ページに分かれています。

    画像
     https://db.netkeiba.com/jockey/jockey_leading_jra.html?year=2026 

    これについて‍「どの year を scrape する必要があるか」を自動判定する のが‍_find_years_to_scrape()‍ の役目です。

    ‍▼common/src/modules/collect_planner.py

    def _find_years_to_scrape(
        self, leading_type: str, max_pages: int = 15
    ) -> list[int]:
        """
        self.leading_years のうち scrape が未完了の year を返す。
    
        判定 (year × leading_type):
          - .bin が無い → 未着手なので scrape 対象
          - 最終ページ番号が max_pages 以上 → 上限到達済みとみなして対象外
            (これ以上 scrape しても scrape_html_leading の auto_paginate で
            同じ上限に到達するだけなので無駄)
          - 最終ページに「次」リンクが残る → 未完なので scrape 対象
          - 「次」リンクが無ければ完了済みで対象外
    
        max_pages のデフォルトは scraping.scrape_html_leading の同名引数と
        一致させること。
        """
        leading_dir = CommonPaths.html_leading / leading_type
        years_to_scrape: list[int] = []
        for year in self.leading_years:
            if not leading_dir.exists():
                years_to_scrape.append(year)
                continue
            bin_files = sorted(
                leading_dir.glob(f"{year}_*.bin"),
                key=lambda p: int(p.stem.split("_")[1]),
            )
            if not bin_files:
                years_to_scrape.append(year)
                continue
            last_page_num = int(bin_files[-1].stem.split("_")[1])
            if last_page_num >= max_pages:
                continue
            with open(bin_files[-1], "rb") as f:
                last_html = f.read()
            if has_next_leading_page(last_html):
                years_to_scrape.append(year)
        return years_to_scrape

    判定は4段階で、上から順に評価していきます。

    段階  条件                                  結果
    ──────────────────────────────────────────────────────────────
    1     .bin が 1 個も無い year                未着手 → scrape 対象
    2     最終ページ番号 ≧ max_pages (=15)       上限到達済 → 対象外
    3     最終ページの HTML に「次」リンクあり      未完 → scrape 対象
    4     「次」リンク無し                        完了済 → 対象外

    段階2 の判定があるのは、netkeiba.comの仕様で2024年のsire(種牡馬)ページだけページ数が何故か多く、上限を15ページに設けているためです。

    画像
    画像

    ‍■「次」リンクの判定
    「次」リンクの有無を判定するhas_next_leading_page()‍ は scraping.py に定義されています。

    ‍▼common/src/modules/scraping.py

    def has_next_leading_page(html: bytes) -> bool:
        """leadingページのHTMLに「次」リンクが存在するかを判定する。"""
        soup = BeautifulSoup(html, "lxml")
        for a in soup.find_all("a"):
            # 騎手名などにも「次」を含む文字列があるので、完全一致のリンクを探す
            if a.get_text(strip=True) == "次":
                return True
        return False

    ‍■scrape_html_leading() の auto_paginate ループ
    判定された year に対して、実際にページを取りに行くのがscrape_html_leading()‍ です。

    ‍▼common/src/modules/scraping.py

    def scrape_html_leading(
        leading_type: str,
        year: int,
        pages: list[int] | range | None = None,
        save_dir: Path = CommonPaths.html_leading,
        skip: bool = True,
        sleep_time: float = 1.0,
        max_pages: int = 15,
    ) -> list[Path]:
        """
        リーディングページのhtmlをスクレイピングしてsave_dirに保存する関数。
    
        pages=None を指定すると、HTMLの「次」リンクが無くなるまで自動的に
        ページネーションを進める(上限は max_pages)。明示的にページ範囲を
        指定したい場合は list[int] や range で渡す(max_pages は無視される)。
        """
        updated_html_path_list = []
        save_dir = save_dir / leading_type
        save_dir.mkdir(parents=True, exist_ok=True)
    
        auto_paginate = pages is None
        if auto_paginate:
            page_iter = list(range(1, max_pages + 1))
        else:
            page_iter = list(pages)
    
        skipped = 0
        last_html: bytes | None = None
        hit_limit = auto_paginate
        for page in tqdm(page_iter):
            filepath = save_dir / f"{year}_{str(page).zfill(2)}.bin"
            if skip and filepath.is_file():
                skipped += 1
                if auto_paginate:
                    # 次へリンクの有無を判定するため既存HTMLを読む
                    with open(filepath, "rb") as f:
                        last_html = f.read()
            else:
                if leading_type in ["jockey", "trainer", "owner", "breeder"]:
                    url = f"https://db.netkeiba.com/{leading_type}/{leading_type}_leading_jra.html&year={year}&page={page}"
                elif leading_type in ["sire", "bms"]:
                    url = f"https://db.netkeiba.com/horse/{leading_type}_leading_jra.html&year={year}&page={page}"
                else:
                    raise ValueError(f"不正な leading_type: {leading_type}")
                request = Request(url, headers={"User-Agent": random.choice(USER_AGENTS)})
                html = urlopen(request).read()
                time.sleep(sleep_time)
                with open(filepath, "wb") as f:
                    f.write(html)
                updated_html_path_list.append(filepath)
                last_html = html
    
            if auto_paginate:
                if last_html is None or not has_next_leading_page(last_html):
                    hit_limit = False
                    break
    
        print(
            f"scrape_html_leading[{leading_type}, {year}]: "
            f"取得 {len(updated_html_path_list)} 件, スキップ {skipped} 件"
        )
        # 2024 × sire は netkeiba 側ページ構造の不具合により最終ページに「次」リンクが
        # 残り続けるため、auto_paginate で必ず上限到達となるが原因はサイト側で確認済み。
        # WARNING を抑制する
        is_known_buggy = leading_type == "sire" and year == 2024
        if auto_paginate and hit_limit and not is_known_buggy:
            print(
                f"[WARNING] scrape_html_leading[{leading_type}, {year}]: "
                f"自動ページネーションが上限 {max_pages} に到達しました。"
                "データが欠落している可能性があります。"
            )
        return updated_html_path_list

    ロジックの要点は次の通りです。

    • ‍pages=None なら range(1, max_pages+1)‍ で巡回しつつ、各ページ取得後に「次」リンクが消えていたら break で抜ける。

    • ‍skip=True で既存 ‍.bin がある場合、HTTPアクセスは発生せず last_html だけ読み直して「次」リンク判定を続ける。

    • 上限 max_pages に到達した時 (hit_limit==True のままループを抜けた時) は ‍[WARNING]‍ メッセージを出力する。ただし 2024×sire のように上限到達の原因が netkeiba 側の不具合と分かっている既知ケースは、ノイズになるだけなので WARNING を抑制している。

    5. ソースコードのダウンロード

    完成した競馬AIのソースコードは、以下の記事に最新版が添付されているので、そこからダウンロードすることができます。

    上記記事には、今回の記事で紹介したソースコード含め、データ収集・機械学習モデル構築・サーバー上での自動投票など、本格的な競馬AIの運用に必要な全工程のコードが含まれています。

    また、この【競馬AI開発】シリーズでは、#1から上記コードの作り方を詳しく解説しているので、ご自身で競馬予想AIを開発・活用してみたいとお考えの方は、ぜひ過去の記事も併せてご覧ください。

     
     
    競馬予想AIを作ることを通して、機械学習・データサイエンスの勉強になるコンテンツの発信や、筆者が行った実験の共有などを行っていきます。

    あなたへのおすすめ