プログラミング初心者がChatGPTを使って競馬予想AIを作ることで、生成AIとプログラミングについて学んでいく企画の第5回です。
前回の記事はこちらから
前回は任意の期間のrace_idをスクレイピングするプログラムを生成し、さらにその結果をcsvとして保存しました。
今回は、このrace_idを用いてレース結果をスクレイピングします。
今後はそのレース結果から特徴量を抽出し、AIモデルでの予測を行っていきます。
レース結果のスクレイピング
今回スクレイピングする対象のページはこちら

URLとして末尾にrace_idを指定すると、レース情報や結果、払い戻し額などの情報が書かれたページが表示されます。
今後特徴量を増やすたびにページにアクセスし直すといつかBANされそうなので、このページ全体を保存しておきます。
ページのスクレイピング方法はいつも通りです。
requestモジュールを使ってスクレイピングして保存するだけ。
url = "https://race.netkeiba.com/race/result.html?race_id=202309040910"
res = requests.get(url, headers={"User-Agent": "Mozilla/5.0"})
with open("result.bin", "wb") as f:
f.write(res.content)
ここでは最もシンプル、かつ展開いらずでそのまま中身を確認できるファイルとしてbinaryを選んでいますが、あまりにデータサイズが大きくなるようでしたらzstandardなど圧縮した保存方法について考えていきましょう。
ちなみに1レース分のファイルサイズは75kBでした。
一つの競馬場で年間500レースほど行われるため、37.5MBといったところでしょうか。
思ったより多くない…?
取得したbinファイルを確認すると以下の通り。

文字化けが多発していますが、binaryファイルなので必要な情報は入っており、デコーディングだけ"EUC-JP"で行うように注意しましょう。
というわけでこのファイルをBeautifulSoupで解析していきましょう。
HTMLの解析
実は以前の記事でスクレイピングは行っており、今回はここで書かれているコードを元にプログラムを書いていきます。
前回はレース結果テーブルの中でも一部の特徴量しか抽出していませんでしたが、これも拡張してレース結果テーブルをそのままpandasのDataFrameとしてまとめましょう。
# what: HTMLを解析して着順・馬名・騎手・オッズをDataFrame化する関数
def parse_race_html(html_text):
soup = BeautifulSoup(html_text, "html.parser")
table = soup.find("table", class_="RaceTable01")
if not table:
raise ValueError("レース結果テーブルが見つかりません。")
rows = table.find_all("tr")[1:] # ヘッダを除外
race_data = []
for row in rows:
cols = row.find_all("td")
if len(cols) < 15:
continue
race_data.append([
cols[0].get_text(strip=True), # 着順
cols[3].get_text(strip=True), # 馬名
cols[4].get_text(strip=True), # 性齢
cols[5].get_text(strip=True), # 斤量
cols[6].get_text(strip=True), # 騎手
cols[7].get_text(strip=True), # タイム
cols[9].get_text(strip=True), # 人気
cols[10].get_text(strip=True), # オッズ
cols[11].get_text(strip=True), # 後3F
cols[13].get_text(strip=True), # 厩舎
cols[14].get_text(strip=True), # 馬体重
])
df = pd.DataFrame(race_data, columns=["着順", "馬名", "性齢", "斤量", "騎手", "タイム", "人気", "オッズ", "後3F", "厩舎", "馬体重"])
return df
ちなみにレース結果テーブルに書いてある特徴量は以下の意味を持ちます。

この関数を実行して得られた結果です。
# 実行関数
with open(r"result.bin", "rb") as f:
html_text = f.read().decode("EUC-JP", errors="ignore")
df = parse_race_html(html_text)
print(df)
着順 馬名 性齢 斤量 騎手 タイム 人気 オッズ 後3F 厩舎 馬体重
0 1 ホウオウルーレット 牡4 58.0 岩田康 1:51.3 5 7.2 35.9 美浦栗田 494(-4)
1 2 キュールエフウジン 牡4 58.0 藤岡佑 1:51.4 8 22.2 36.3 栗東中尾 494(-6)
2 3 セイクリッドゲイズ セ5 58.0 岩田望 1:51.5 4 7.0 36.8 栗東佐々木 494(0)
3 4 プリモスペランツァ 牡4 58.0 鮫島駿 1:51.6 11 33.9 37.1 栗東中竹 494(0)
4 5 マルブツプライド 牡4 58.0 川須 1:51.6 10 30.1 36.8 栗東加用 532(+8)
5 6 カズプレスト 牡4 58.0 亀田 1:51.7 6 10.4 37.6 栗東高柳大 522(-6)
6 7 ホウオウフウジン 牡4 58.0 幸 1:52.0 9 25.3 38.0 栗東矢作 524(-6)
7 8 クロニクル 牡4 58.0 吉田隼 1:52.1 7 13.1 37.8 栗東田中克 522(+6)
8 9 ルイナールカズマ 牡4 58.0 藤岡康 1:52.1 12 124.1 36.5 栗東奥村豊 496(-6)
9 10 ラインオブソウル 牡4 58.0 松若 1:52.3 3 6.9 37.5 栗東音無 520(+4)
10 11 タガノエスコート 牡4 58.0 和田竜 1:52.7 1 3.1 38.2 栗東小林 500(-6)
11 12 ロコポルティ 牡5 58.0 Mデム 1:52.7 2 5.6 38.0 栗東西園正 526(-2)
レース結果がまとまったテーブルになっていますね。
性齢や馬体重は2つの情報が1列の中にまとまってしまっているので、モデルに入力するときには分けたエンコーディングをしなければなりません。
今はこれらの特徴量はまだ使わないとして、そっと横に置いておきます...
そして同じサイトの払い戻しテーブルもDataFrame化していきます。
こちらはモデルの特徴量として使うのではなく、回収率のシミュレーション用として用意しておきます。
# what: HTMLを解析して払い戻しテーブルをDataFrame化する関数
# for: AIモデルの入力形式に合わせる
# in: 取得したhtml(.bin)
# out: 払い戻しテーブル(DataFrame)
from bs4 import BeautifulSoup
import pandas as pd
def parse_return_html(html_text):
soup = BeautifulSoup(html_text, "html.parser")
pay_tables = soup.find_all("table", class_="Payout_Detail_Table")
pay_data = []
for tbl in pay_tables:
for row in tbl.find_all("tr"):
bet_type = row.find("th").get_text(strip=True) if row.find("th") else None
result = " / ".join(span.get_text(strip=True) for span in row.select("td.Result span") if span.get_text(strip=True))
payout = " / ".join(span.get_text(strip=True) for span in row.select("td.Payout span") if span.get_text(strip=True))
popularity = " / ".join(span.get_text(strip=True) for span in row.select("td.Ninki span") if span.get_text(strip=True))
pay_data.append([bet_type, result, payout, popularity])
pay_df = pd.DataFrame(pay_data, columns=["券種", "馬番", "払戻金", "人気"])
return pay_df
とりあえず単勝以外の複数の馬番が書いてある場合はスラッシュで分けていますが、実際にシミュレーションする場合はこちらもしっかりとデコーディングの方法を考えなければなりません。
券種 馬番 払戻金 人気
0 単勝 6 720円 5人気
1 複勝 6 / 1 / 12 230円500円240円 4人気 / 8人気 / 3人気
2 枠連 1 / 5 5,550円 19人気
3 馬連 1 / 6 7,000円 27人気
4 ワイド 1 / 6 / 6 / 12 / 1 / 12 1,820円880円1,640円 26人気 / 9人気 / 22人気
5 馬単 6 / 1 12,020円 51人気
6 3連複 1 / 6 / 12 13,070円 55人気
7 3連単 6 / 1 / 12 74,630円 295人気
それでは最後にリスト化したレースIDを用いて任意期間のHTMLの取得、そしてテーブルの結合を行っていきます。
まずはHTMLの取得です。
前回の記事で作成したrace_idをリスト化したcsvを用いて、レース結果のURLを作成→アクセス→HTMLをbinで保存という流れをfor文で回していきます。
# CSVの読み込み
df = pd.read_csv(csv_path)
ua = UserAgent()
user_agent = ua.random # ランダムなUser-Agentを取得
# 上位3レースだけ処理(必要なら df.head(3) → df に変更して全件処理)
for i, row in df.head(3).iterrows():
race_id = str(row["race_id"])
url = f"https://race.netkeiba.com/race/result.html?race_id={race_id}"
try:
res = requests.get(url, headers={"User-Agent": user_agent}, timeout=10)
res.raise_for_status() # エラーがあれば例外を発生
# ファイル保存パス
save_path = os.path.join(save_dir, f"{race_id}.bin")
# HTMLをバイナリで保存
with open(save_path, "wb") as f:
f.write(res.content)
# アクセス間隔を少し空ける(サーバー負荷対策)
time.sleep(random.uniform(0.8, 2.0))
except Exception as e:
print(f"Error fetching {race_id}: {e}")
こうして保存したbinaryファイルを元にレース結果のテーブルを作っていきます。
BeautifulSoupでHTMLを解析するparse_race_html関数を用いてレース結果テーブルを作成
→pandasのconcatで結合し1つのテーブルにする
→pickleファイルに保存
という流れのプログラムを作りました。
df = pd.read_csv(csv_path)
result_table_path = r"C:race_result_table.pkl"
all_dfs = []
for i, row in df.head(3).iterrows():
race_id = str(row["race_id"])
try:
# --- 展開 ---
with open(rf"{race_id}.bin", "rb") as f:
html_text = f.read().decode("EUC-JP", errors="ignore")
# --- HTML解析 ---
df_race = parse_race_html(html_text)
df_race.insert(0, "race_id", race_id)
all_dfs.append(df_race)
except Exception as e:
print(f"Error fetching {race_id}: {e}")
result_df = pd.concat(all_dfs, ignore_index=True)
print(result_df)
result_table = result_df.to_pickle(result_table_path)
しかし見ての通り、BeautifulSoupがTableを見つけられていない、といった結果になってしまいます。
Error fetching 202309040910: レース結果テーブルが見つかりません。
Error fetching 202306040903: レース結果テーブルが見つかりません。
Error fetching 202306040912: レース結果テーブルが見つかりません。
保存したbinaryファイルを開き先ほど拾えていた"RaceTable01"を検索すると、なんと今度は見つからなくなってしまいました!
これはHTMLを取得するときにUser-Agentをランダムにしていたことが原因です。
netkeiba.comはアクセス元(User-Agent)によってサイトの構成が変わります。
(スマホ版やNew PC版があるそうです)
アクセスブロックを防ぐためにUser-Agentをランダムにしていたのですが、このままでは全てのパターンに応じたプログラムを書くことになり少し大変です。
したがってPC版が取得できるUser-Agentに限定し、この中でランダムにしていきます。
# CSVファイルのパス
csv_path = r"C:\Users\yasak\Desktop\mykeibaAI_ver1p0\race_id_list_2310_2402.csv"
# 保存フォルダのパス
save_dir = r"C:\Users\yasak\Desktop\mykeibaAI_ver1p0\race_result_html"
# CSVの読み込み
df = pd.read_csv(csv_path)
user_agents = [ # netkeiba.comはアクセス元によってページ構成が変わる→PCブラウザに統一
# Windows Chrome 系
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/121.0.2277.83 Safari/537.36",
# macOS Chrome 系
"Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
]
# ua = UserAgent()
# user_agent = ua.random # ランダムなUser-Agentを取得
# 上位3レースだけ処理(必要なら df.head(3) → df に変更して全件処理)
for i, row in df.head(3).iterrows():
race_id = str(row["race_id"])
url = f"https://race.netkeiba.com/race/result.html?race_id={race_id}"
try:
res = requests.get(url, headers={"User-Agent": random.choice(user_agents)}, timeout=10)
res.raise_for_status() # エラーがあれば例外を発生
# ファイル保存パス
save_path = os.path.join(save_dir, f"{race_id}.bin")
# HTMLをバイナリで保存
with open(save_path, "wb") as f:
f.write(res.content)
# アクセス間隔を少し空ける(サーバー負荷対策)
time.sleep(random.uniform(0.8, 2.0))
except Exception as e:
print(f"Error fetching {race_id}: {e}")
それでは3レース分のレース結果を結合したテーブルを確認してみましょう!
期待通りに結合できています!
それでは最後に、最終的なHTMLを取得する最終的なコードを残して終わりにします!
最終的なコード
# CSVファイルのパス
csv_path = r"C:\Users\yasak\Desktop\mykeibaAI_ver1p0\data\race_id_list_2310_2402.csv"
# 保存フォルダのパス
save_dir = r"C:\Users\yasak\Desktop\mykeibaAI_ver1p0\data\race_result_html"
# CSVの読み込み
df = pd.read_csv(csv_path)
user_agents = [ # netkeiba.comはアクセス元によってページ構成が変わる→PCブラウザに統一
# Windows Chrome 系
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/121.0.2277.83 Safari/537.36",
# macOS Chrome 系
"Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
]
for race_id in tqdm(df["race_id"], total=len(df)):
race_id = str(race_id)
url = f"https://race.netkeiba.com/race/result.html?race_id={race_id}"
# ファイル保存パス
save_path = os.path.join(save_dir, f"{race_id}.bin")
if os.path.exists(save_path):
continue # 既にそのrace_idが取得済みならスキップ
else:
try:
res = requests.get(url, headers={"User-Agent": random.choice(user_agents)}, timeout=10)
res.raise_for_status() # エラーがあれば例外を発生
# HTMLをバイナリで保存
with open(save_path, "wb") as f:
f.write(res.content)
# アクセス間隔を少し空ける(サーバー負荷対策)
time.sleep(random.uniform(0.8, 2.0))
except Exception as e:
print(f"Error fetching {race_id}: {e}")
これでcsvに保存してある全race_id分のスクレイピングが可能となります。
PCがスリープすると取得も終わってしまうところが課題…
将来的にはVPNサーバーなど使って接続に対しても堅牢なシステムを作っていきたいですね
さらにこれらの取得したHTMLから新規のrace_idのみを展開してレース結果を取得するコードがこちらです。
# what: 各レースのbinファイルからレース結果を抽出し、1つのテーブルに結合しpickleで保存する関数
# for: 特徴量の抽出用
# in: 取得したrace_id_list(.csv)とhtml(.bin)
# out: 結合されたresult_table(.pickle)
csv_path = r"C:\Users\yasak\Desktop\mykeibaAI_ver1p0\data\race_id_list_2310_2402.csv"
result_table_path = r"C:\Users\yasak\Desktop\mykeibaAI_ver1p0\data\race_result_table.pkl"
bin_dir = r"C:\Users\yasak\Desktop\mykeibaAI_ver1p0\data\race_result_html"
df = pd.read_csv(csv_path)
# 既存pickleのrace_idを確認
existing_df = pd.read_pickle(result_table_path)
existing_ids = set(existing_df["race_id"].astype(str))
# 新しく解析するrace_idだけを抽出
target_ids = [str(rid) for rid in df["race_id"] if str(rid) not in existing_ids]
new_dfs = []
for race_id in tqdm(target_ids, total=len(target_ids)):
bin_path = os.path.join(bin_dir, f"{race_id}.bin")
if not os.path.exists(bin_path):
print(f"Missing bin file: {race_id}")
continue
try:
# --- 展開 ---
with open(bin_path, "rb") as f:
html_text = f.read().decode("EUC-JP", errors="ignore")
# --- HTML解析 ---
df_race = parse_race_html(html_text)
df_race.insert(0, "race_id", race_id)
new_dfs.append(df_race)
except Exception as e:
print(f"Error fetching {race_id}: {e}")
new_result_df = pd.concat(new_dfs, ignore_index=True)
result_df = pd.concat([existing_df, new_result_df], ignore_index=True)
print(f"✅ 新規{len(new_result_df)}件を追加しました(合計 {len(result_df)} 件)")
print(result_df[:5])
result_table = result_df.to_pickle(result_table_path)
レース結果はpickleとしてまとめてあるため、いつでも展開して中身を使うことができます。
次回はこの全期間のデータを使って、いよいよAIモデルを構築していきます!
