PythonでJRA公式サイトから単勝オッズをスクレイピングする方法(doAction/POST対応)

2026年4月1日

JRAの自動投票システムを運用する上で避けて通れない「オッズ取得のリアルタイム性」問題。 JRDBのデータ仕様によるタイムラグを克服するため、JRA公式サイトから直接オッズをスクレイピングする手法についてまとめました。


衝撃の事実:JRDBのオッズは15分前だった

JRA自動投票を運用していて、ふと気づいたことがありました。 「直前に取得しているはずのオッズが、確定オッズと乖離しすぎている……」

調べてみたところ、今まで利用していたJRDBのオッズデータは「15分前」のものという仕様でした。自動投票において15分前のオッズはもはや別物。期待値計算が根底から崩れてしまいます。

改善案の検討

リアルタイムに近いオッズを取得するために、以下の3つのルートを検討しました。

  1. JRA-VANを利用する
    • 確実かつ公式なデータ。ただし、月額2,000円程度のコストが追加でかかる。
  2. netkeibaからスクレイピング
    • 有名な手段だが、更新頻度に限界があり、制限を回避するには有料会員登録が必要になる場合がある。
  3. JRA公式サイトからスクレイピング
    • 結論:今回はこれを採用。 無料で、かつ公式サイトなので反映も早い。

将来的には安定のJRA-VAN(①)へ移行する予定ですが、まずはコストをかけずに改善するため、JRA公式サイトから単勝オッズを取得する関数を実装しました。


JRA公式サイト スクレイピングの実装

JRAのサイトは、単純なURL指定ではなく、JavaScriptの doAction 関数(POSTパラメータ cname のリレー)によってページ遷移する特殊な構造をしています。そのため、requests.Session を使い、Cookieを維持しながら段階的にページを辿る必要があります。

実装コード (Python)

"""
JRA公式サイトから「単勝オッズ」を取得するスクレイピング。

JRAのオッズ関連ページは、単純なURL直叩きというよりも、画面上のリンクに埋め込まれた
JavaScript `doAction('<path>', '<payload>')` の引数(ここでは `cname` としてPOSTする値)を
リレーしながらページ遷移します。

このスクリプトは以下の導線を「セッション維持しつつ」辿って、最終的なオッズ表HTMLから
`馬番` と `単勝オッズ` を抜き出して `pandas.DataFrame` にします。
- トップページ → 「オッズ」入口
- 開催選択 → 対象開催(例: 3回中山2日)
- レース一覧 → 対象Rの「単勝」
- オッズ表 → 表から馬番と単勝オッズを抽出

注意:
- HTML構造や `doAction()` の仕様が変わると壊れやすい(スクレイピングの宿命)です。
- `year` 引数は現状URL生成に使っておらず、開催が「今週の開催」かどうかの前提説明用途です。
"""

import requests
from bs4 import BeautifulSoup
import re
import pandas as pd

# JRA場コードのマッピング辞書(JRDB等の仕様に準拠)
JRA_COURSE_CODES = {
    "01": "札幌",
    "02": "函館",
    "03": "福島",
    "04": "新潟",
    "05": "東京",
    "06": "中山",
    "07": "中京",
    "08": "京都",
    "09": "阪神",
    "10": "小倉",
}


def create_jra_session():
    """JRA側の導線を辿るため、Cookie等を保持する `requests.Session()` を作る。"""
    session = requests.Session()
    session.headers.update(
        {
            # JRA側がBot判定/ブロックをしている可能性があるため、ブラウザに寄せたUAにする
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
        }
    )
    return session


def get_jra_odds_df(course_code, year, kai, day, race_num):
    """
    指定したレースの単勝オッズを取得し、DataFrameとして返却する関数

    Parameters:
        course_code (str/int): 場コード (例: '06' または 6)
        year (str/int)       : 年 (例: 2026) ※JRA公式サイトの仕様上、今週開催分の年である必要があります
        kai (str/int)        : 回 (例: 3)
        day (str/int)        : 日 (例: 2)
        race_num (str/int)   : レース番号 (例: 11)

    Returns:
        pd.DataFrame: ['馬番', '単勝オッズ'] のカラムを持つデータフレーム。失敗時は空のDF。
    """
    # 引数のフォーマット整形
    course_code_str = str(course_code).zfill(2)
    # ★ JRDBの16進数仕様('a'->10, 'b'->11)に対応するため int(val, 16) を使用
    kai_int = int(str(kai), 16)
    day_int = int(str(day), 16)
    # R(レース)はJRDBでも通常 '01'〜'12' の10進数ですが、念のため str() で安全にキャスト
    race_str = f"{int(str(race_num))}R"

    if course_code_str not in JRA_COURSE_CODES:
        print(f"エラー: 不正な場コードです ({course_code_str})")
        return pd.DataFrame()

    course_name = JRA_COURSE_CODES[course_code_str]
    target_course_str = f"{kai_int}回{course_name}{day_int}日"  # 例: "3回中山2日"

    session = create_jra_session()

    try:
        # [STEP 1] トップページから「オッズ」入口の doAction 情報を取得
        # - JRAのリンクは `<a onclick="doAction('...', '...')">` のように埋め込まれており、
        #   第2引数(payload)を POST パラメータ `cname` として送ることで遷移します。
        # - ここでは「オッズ」と書かれたリンクを探し、その遷移先URLとpayloadを抜きます。
        res_top = session.get("https://www.jra.go.jp/", timeout=10)
        res_top.encoding = res_top.apparent_encoding
        soup_top = BeautifulSoup(res_top.text, "html.parser")

        odds_payload = None
        odds_target_url = None
        for link in soup_top.find_all("a", onclick=re.compile(r"doAction")):
            if "オッズ" in link.get_text():
                match = re.search(
                    r"doAction\('([^']+)',\s*'([^']+)'\)", link["onclick"]
                )
                if match:
                    odds_target_url = "https://www.jra.go.jp" + match.group(1)
                    odds_payload = match.group(2)
                    break

        if not odds_payload:
            print(
                "エラー: トップページにオッズのリンクが見つかりません(時間外の可能性)"
            )
            return pd.DataFrame()

        # [STEP 2] 開催選択ページで、対象開催(例: "3回中山2日")の doAction 情報を取得
        # - STEP1で得たURLに、payloadを `cname` としてPOST
        # - 返ってきた開催一覧から `target_course_str` を含むリンクを探して次のpayloadを抜く
        res_odds = session.post(odds_target_url, data={"cname": odds_payload})
        res_odds.encoding = res_odds.apparent_encoding
        soup_odds = BeautifulSoup(res_odds.text, "html.parser")

        course_target_data = None
        for link in soup_odds.find_all("a", onclick=re.compile(r"doAction")):
            if target_course_str in link.get_text(strip=True):
                match = re.search(
                    r"doAction\('([^']+)',\s*'([^']+)'\)", link["onclick"]
                )
                if match:
                    course_target_data = {
                        "url": "https://www.jra.go.jp" + match.group(1),
                        "payload": match.group(2),
                    }
                    break

        if not course_target_data:
            print(f"エラー: 開催一覧に '{target_course_str}' が見つかりません")
            return pd.DataFrame()

        # [STEP 3] レース一覧ページで、対象Rの「単勝」リンクの doAction 情報を取得
        # - 開催を確定するため、STEP2のURLへ `cname`=payload をPOSTしてレース一覧へ
        # - レース一覧の中から「単勝」を含むリンクを拾い、そのpayloadに含まれるR番号を見て絞り込む
        res_race = session.post(
            course_target_data["url"], data={"cname": course_target_data["payload"]}
        )
        res_race.encoding = res_race.apparent_encoding
        soup_race = BeautifulSoup(res_race.text, "html.parser")

        race_target_data = None
        for link in soup_race.find_all("a", onclick=re.compile(r"doAction")):
            # テキストだけでなく img.alt に「単勝」等が入っているケースがあるので連結して判定する
            content = link.get_text(strip=True)
            for img in link.find_all("img"):
                content += img.get("alt", "")

            if "単勝" in content:
                match_action = re.search(
                    r"doAction\('([^']+)',\s*'([^']+)'\)", link["onclick"]
                )
                if match_action:
                    payload = match_action.group(2)
                    try:
                        # payload 内の固定位置からR番号を取り出している(JRA側の仕様に依存)
                        # - ここが変わると取得できなくなるため、壊れた場合はまずここを疑う
                        extracted_race_num = str(int(payload[19:21])) + "R"
                        if extracted_race_num == race_str:
                            race_target_data = {
                                "url": "https://www.jra.go.jp" + match_action.group(1),
                                "payload": payload,
                            }
                            break
                    except ValueError:
                        pass

        if not race_target_data:
            print(f"エラー: レース一覧に '{race_str}' が見つかりません")
            return pd.DataFrame()

        # [STEP 4] オッズ表ページへ遷移し、HTMLテーブルから「馬番」と「単勝」を抽出してDF化
        res_odds_table = session.post(
            race_target_data["url"],
            data={"cname": race_target_data["payload"]},
            timeout=10,
        )
        res_odds_table.encoding = res_odds_table.apparent_encoding
        soup_odds_table = BeautifulSoup(res_odds_table.text, "html.parser")

        odds_list = []
        for table in soup_odds_table.find_all("table"):
            if "馬番" in table.text and "単勝" in table.text:
                for row in table.find_all("tr"):
                    cols = row.find_all(["th", "td"])

                    horse_num_str = ""
                    odds_str = ""

                    # 枠番セルが存在する行(1頭目)
                    if len(cols) >= 4 and cols[1].get_text(strip=True).isdigit():
                        horse_num_str = cols[1].get_text(strip=True)
                        odds_str = cols[3].get_text(strip=True)
                    # 枠番セルが結合されている行(2頭目以降)
                    elif len(cols) >= 3 and cols[0].get_text(strip=True).isdigit():
                        horse_num_str = cols[0].get_text(strip=True)
                        odds_str = cols[2].get_text(strip=True)
                    else:
                        continue

                    try:
                        horse_num = int(horse_num_str)
                        odds = float(odds_str)
                        odds_list.append({"馬番": horse_num, "単勝オッズ": odds})
                    except ValueError:
                        # 取消/除外などで数値にならない場合がある(Pandas側では NaN 相当になる)
                        odds_list.append(
                            {"馬番": int(horse_num_str), "単勝オッズ": None}
                        )
                break

        # DataFrame化して返却
        df = pd.DataFrame(odds_list)
        return df

    except Exception as e:
        print(f"予期せぬエラーが発生しました: {e}")
        return pd.DataFrame()


# --- テスト実行 ---
if __name__ == "__main__":
    # 引数: 場コード(06=中山), 年(2026), 回(3), 日(2), R(11)
    # ※注意事項: 過去のレースはJRAトップページから辿れないため、必ず「今週開催しているレース」を指定してください。
    df_odds = get_jra_odds_df(course_code="07", year=2026, kai=1, day=6, race_num=11)

    if not df_odds.empty:
        print("\n=== 取得成功!データフレームの中身 ===")
        print(df_odds.to_string(index=False))
        print("\nデータ型確認:")
        print(df_odds.dtypes)
    else:
        print("データフレームの取得に失敗しました。")

運用上の注意点と今後の課題

  • 動的仕様変更へのリスク JRAのサイト構造や doAction のパラメータ仕様が変わると、コードの修正が必要になります。特に payload[19:21] のように決め打ちでR番号を取得している箇所は要注意です。
  • アクセス制限 短時間に大量のリクエストを送ると、サイト側に負荷をかけたりBot判定でブロックされたりするリスクがあります。適切なスリープ処理や節度ある利用が不可欠です。
  • リアルタイム性の検証 日曜のレース終了後に動作確認をしたところ、無事に取得できました。
    しかし、開催終了後だと若干表示が変わっているように見えたので、ちゃんと動くかは謎・・・
    次回の開催でじっくり検証してみたいと思います。

これでようやく「15分前の悪夢」から解放される……はず!

クリックしていただけると記事を書くモチベーションになります!