南関東競馬のデータスクレイピング

# 1.概要
地方競馬(南関東競馬)の予想を機械学習でやってみよう!
と思ったのが始まりです。

機械学習に必要なデータはJRAだと有料で入手できるが、地方競馬だとなかなか見つからないので、Pythonでスクレイピングをする事にしました。

そういう訳で競馬情報サイトからデータ収集します。

# 2.データ取得用URLについて
競馬情報サイトnetkeibaからスクレイピングします。
スクレイピングはwebページに大量のアクセスをかけるものです。
サーバーに負荷をかけすぎないよう気を付けてください。

https://nar.netkeiba.com/race/result.html?race_id=202444081211&rf=race_list
※これは2024年8月12日第11レースの出馬表URL
https://nar.netkeiba.com/race/shutuba_past.html?race_id=202444081211&rf=shutuba_submenu
※こちらが同レースの馬柱URL(出走各馬の過去5走分のデータを表形式で集めたもの)

この二つの情報を一定年数分抽出します。

# 3.スクレイピングの手順
1)上記URLのrace_id=以降の数字部分が年、会場、月、日、レース番号を示しているため、それを5重ループで定義する

※会場は以下のコードになっているため、配列としてループを回す
門別:30、盛岡:35、水沢:36、浦和:42、船橋:43、大井:44、川崎:45、金沢:46、笠松:47、名古屋:48、園田:50、姫路:51、高知:54、佐賀:55

2)まず出馬表URLにBeautifulSoupを使ってアクセス。
もし出馬表URLが空なら馬柱URLも空なので、その時点でループを抜ける

3)まずそのレースの施行条件を抽出
(レース名、発走時刻、芝orダ、距離、回数、会場、日数、賞金、レースの格)

4)次にレースの結果をデータフレームで抽出
(着順、枠、馬番、馬名、年齢、性別、斤量、騎手、タイム、着差、人気、単勝オッズ、ラスト3ハロン(600m)タイム、厩舎、場体重、場体重増減)

5)最後に過去5走分のデータをデータフレームで抽出(馬番、印、馬名オッズ、騎手斤量、前走、2走前、3走前、4走前、5走前)
※馬名オッズには4)で抽出した情報と同じ情報が入っているが、レース間隔や父母名も入っているため、取得する
※n走前には一つのセルに複数の情報が入る形になるが、学習時に分割するため、問題なし

6)レース結果と過去5走分のデータを馬番で結合

7)レースの施行条件を出走頭数分二次元のデータフレーム化

8)結合したものと施行条件を結合

9)次のループ(レース)へ

# 4.コード
注意点
※可読性は悪いです
※2024年8月13日時点では使えていますが、今後使えなくなるかも

```python
import requests
from bs4 import BeautifulSoup
import pandas as pd
import numpy as np
import time
import re
import csv
import sys

#URLの共通部分
url_header='https://nar.netkeiba.com/race/result.html?race_id='
url_shutuba_header='https://nar.netkeiba.com/race/shutuba_past.html?race_id='
url_shutuba_fooder='&rf=shutuba_submenu'

placenumber=[30,35,36,42,43,44,45,46,47,48,50,51,54,55]

#ヘッダー
header_list=['this_date','this_place','this_weather','this_race_title','this_race_sum','this_race_shiba','this_dart_dart','this_race_distance','this_race_ryou_truck','this_race_yayaomo_truck','this_race_omo_truck','this_race_huryou_truck',
             'this_result','this_waku','this_ban','this_male','this_female','this_sen','this_kinryo','this_jockey','this_time','this_ninki','this_odds','this_last_three_halons_time','this_stable','this_weight','this_weight_change',
             'once_date','once_place','once_weather','once_race_title','once_race_sum','once_race_shiba','once_dart_dart','once_race_distance','once_race_ryou_truck','once_race_yayaomo_truck','once_race_omo_truck','once_race_huryou_truck',
             'once_result','once_waku','once_ban','once_kinryo','once_jockey','once_time','once_ninki','once_odds','once_last_three_halons_time','once_weight','once_weight_change',
             'twice_date','twice_place','twice_weather','twice_race_title','twice_race_sum','twice_race_shiba','twice_dart_dart','twice_race_distance','twice_race_ryou_truck','twice_race_yayaomo_truck','twice_race_omo_truck','twice_race_huryou_truck',
             'twice_result','twice_waku','twice_ban','twice_kinryo','twice_jockey','twice_time','twice_ninki','twice_odds','twice_last_three_halons_time','twice_weight','twice_weight_change',
             'third_date','third_place','third_weather','third_race_title','third_race_sum','third_race_shiba','third_dart_dart','third_race_distance','third_race_ryou_truck','third_race_yayaomo_truck','third_race_omo_truck','third_race_huryou_truck',
             'third_result','third_waku','third_ban','third_kinryo','third_jockey','third_time','third_ninki','third_odds','third_last_three_halons_time','third_weight','third_weight_change',
             'fourth_date','fourth_place','fourth_weather','fourth_race_title','fourth_race_sum','fourth_race_shiba','fourth_dart_dart','fourth_race_distance','fourth_race_ryou_truck','fourth_race_yayaomo_truck','fourth_race_omo_truck','fourth_race_huryou_truck',
             'fourth_result','fourth_waku','fourth_ban','fourth_kinryo','fourth_jockey','fourth_time','fourth_ninki','fourth_odds','fourth_last_three_halons_time','fourth_weight','fourth_weight_change',
             'fifth_date','fifth_place','fifth_weather','fifth_race_title','fifth_race_sum','fifth_race_shiba','fifth_dart_dart','fifth_race_distance','fifth_race_ryou_truck','fifth_race_yayaomo_truck','fifth_race_omo_truck','fifth_race_huryou_truck',
             'fifth_result','fifth_waku','fifth_ban','fifth_kinryo','fifth_jockey','fifth_time','fifth_ninki','fifth_odds','fifth_last_three_halons_time','fifth_weight','fifth_weight_change',]

#webアクセスのプログラム
def web_access(url):
      r=requests.get(url)
      time.sleep(1.0)
      soup=BeautifulSoup(r.content,"html.parser")
      return soup

# ... existing code ...

# 指定したURLからテーブルデータを抽出する関数
def extract_race_result_table(url):
    soup = web_access(url)
    table = soup.find('table', {'summary': '全着順', 'class': 'RaceTable01 RaceCommon_Table ResultRefund Table_Show_All ResultMain', 'id': 'All_Result_Table'})
    
    headers = [header.text.replace('\n', '').strip() for header in table.find_all('th')]
    
    rows = []
    for row in table.find_all('tr'):
        cells = row.find_all('td')
        if len(cells) > 0:
            rows.append([cell.get_text(separator=' ').replace('\n',' ').strip() for cell in cells])
    
    df = pd.DataFrame(rows, columns=headers)
    return df

# ... existing code ...
def normalize_spaces(text):
    # すべての種類のスペースを正規表現でキャッチ
    normalized_text = re.sub(r'[ 　]+', ' ', text)
    return normalized_text
# ... existing code ...

# 各馬の過去5走データを取得する関数
def extract_past5_data(soup):
    past5_table = soup.find('table', class_='Shutuba_Table Shutuba_Past5_Table')

    if past5_table is None:
        print('過去5走データがありません')
        return None
    
    text_line=[]

    data02_elements=past5_table.find_all('div',class_='Data02')
    for element in data02_elements:
        text = element.get_text().replace('　','').replace(' ','').replace('\n','')
        text_line.append(text)
    
    for i,element in enumerate(data02_elements):
        if i<len(text_line):
            element.string = text_line[i]


    headers = [header.text.replace('\n','') for header in past5_table.find_all('th')]
    
    rows = []
    for row in past5_table.find_all('tr'):
        cells = row.find_all('td')
        if len(cells) > 0:
            
            row_data = [re.sub(r'[ 　]+', ' ', cell.get_text(separator=' ').replace('\n',' ').strip()) for cell in cells]
            rows.append(row_data)
    
    if not rows:
        print('No rows found in past_5_table')
        return None
    
    df = pd.DataFrame(rows, columns=headers)
    return df

def extract_race_class(race_title):
    race_class = '0'
    if 'OP' in race_title:
        race_class = 'OP'
    elif '重賞' in race_title:
        race_class = '重賞'
    elif 'Jpn1' in race_title:
        race_class = 'JPN1'
    elif 'Jpn2' in race_title:
        race_class = 'JPN2'
    elif 'Jpn3' in race_title:
        race_class = 'JPN3'
    elif 'G1' in race_title:
        race_class = 'G1'
    elif 'G2' in race_title:
        race_class = 'G2'
    elif 'G3' in race_title:
        race_class = 'G3'
    elif ('デビュー' in race_title) or ('新馬' in race_title):
        race_class = '新馬'
    elif '未勝利' in race_title:
        race_class = '未勝利'
    else:
        match = re.search(r'(C|B|A)(\d+)', race_title)
        if match:
            race_class = match.group(1) + convert_number_to_kanji(match.group(2))
        elif '1勝クラス' in race_title:
            race_class = '一勝クラス'
        elif '二勝クラス' in race_title:
            race_class = '二勝クラス'
        elif '三勝クラス' in race_title:
            race_class = '三勝クラス'
    return race_class

def race_class_plus(past5_list, race_classes):
    for race_class in race_classes:
        if re.search(race_class, past5_list[3]):
            # C~A+数字+漢数字の場合、漢数字のみを削除
            match = re.search(r'(C|B|A)(\d+)([一二三四五六七八九十]*)', past5_list[3])
            if match:
                past5_list[3] = match.group(1) + match.group(2)  # 漢数字を削除
                past5_list.insert(4, match.group(1) + match.group(2))
            else:
                # 例: まるまる賞(A1) の場合、A1を挿入
                match = re.search(r'\((C|B|A\d+)\)', past5_list[3])
                if match:
                    past5_list.insert(4, match.group(1))
                else:
                    past5_list.insert(4, race_class)
            break
    return past5_list

# ... existing code ...

# レース情報を抽出する関数
def extract_race_info(soup):
    race_info = {}
    
    # レース名
    race_name = soup.find('div', class_='RaceName').get_text(strip=True)
    race_info['レース名'] = race_name
    
    # レースの格、芝ダートと距離、天気、馬場
    race_data_1 = soup.find('div', class_='RaceData01').get_text().replace('/', ' ').split()
    race_info['発走時刻'] = race_data_1[0] if len(race_data_1) > 0 else '0'
    race_info['芝orダと距離']=race_data_1[1] if len(race_data_1)>1 else '0'
    race_data_2 = soup.find('div', class_='RaceData02').get_text().replace('/', ' ').split()
    race_info['回数'] = race_data_2[0] if len(race_data_2) > 0 else '0'
    race_info['場所'] = race_data_2[1] if len(race_data_2) > 1 else '0'
    race_info['日目'] = race_data_2[2] if len(race_data_2) > 2 else '0'
    race_info['頭数'] = race_data_2[6] if len(race_data_2) > 6 else '0'

    # レースの格を抽出
    race_class = '未格付'
    if 'OP' in race_name:
        race_class = 'OP'
    elif '重賞' in race_name:
        race_class = '重賞'
    elif 'Jpn1' in race_name:
        race_class = 'JPN1'
    elif 'Jpn2' in race_name:
        race_class = 'JPN2'
    elif 'Jpn3' in race_name:
        race_class = 'JPN3'
    elif 'G1' in race_name:
        race_class = 'G1'
    elif 'G2' in race_name:
        race_class = 'G2'
    elif 'G3' in race_name:
        race_class = 'G3'
    elif 'デビュー' in race_name or '新馬' in race_name:
        race_class = '新馬'
    elif '未勝利' in race_name:
        race_class = '未勝利'
    else:
        match = re.search(r'(C|B|A)(\d+)', race_name)
        if match:
            race_class = match.group(1) + convert_number_to_kanji(match.group(2))
        elif '1勝クラス' in race_name:
            race_class = '一勝クラス'
        elif '2勝クラス' in race_name:
            race_class = '二勝クラス'
        elif '3勝クラス' in race_name:
            race_class = '三勝クラス'
    
    race_info['レースの格'] = race_class
    

    
    return race_info


# 数字を漢数字に変換する関数
def convert_number_to_kanji(number):
    kanji_numbers = {
        '0':'零', '1': '一', '2': '二', '3': '三', '4': '四',
        '5': '五', '6': '六', '7': '七', '8': '八', '9': '九'
    }
    return ''.join(kanji_numbers[digit] for digit in number)


# データ抽出関数
def extract_data_from_url(url, table_class):
    soup = web_access(url)
    table = soup.find('table', {'class': table_class})
    
    if table:
        print(f"Table with class '{table_class}' found")
        df = extract_race_result_table(url)
        race_info=extract_race_info(url)
        return df, race_info
    else:
        print(f"Table with class '{table_class}' not found")
        return pd.DataFrame(), None


all_maerged_dfs=[]

for year in range(2018,2019):#2019~2023年中のデータを収集
    #競馬場の番号
    #門別:30、盛岡:35、水沢:36、浦和:42、船橋:43、大井:44、川崎:45、金沢:46、笠松:47、名古屋:48、園田:50、姫路:51、高知:54、佐賀:55
    for place in range(42,46):
     for month in range(1,13):
         for day in range(1,32):
             for race in range(1,13):
                try:
                    url=url_header+str(year)+str(place)+str(month).zfill(2)+str(day).zfill(2)+str(race).zfill(2)
                    print(url)

                    url_shutuba=url_shutuba_header+str(year)+str(place)+str(month).zfill(2)+str(day).zfill(2)+str(race).zfill(2)+url_shutuba_fooder
                    print(url_shutuba)

                    soup=web_access(url)

                    #正解データを含むレースのレース情報
                    #出走頭数、距離、天気、馬場、レース名、日にちを取得
                    race_data_1=soup.find('div',class_='RaceData01').get_text().replace('/',' ').splitlines()
                    race_data_1 = [x for x in race_data_1 if x!='']
                    race_data_2=soup.find('div',class_='RaceData02').get_text().replace('/',' ').splitlines()
                    race_data_2 = [x for x in race_data_2 if x!='']
                    print(race_data_1)
                    print(race_data_2)
                    #修正用
                    if '\xa0' in race_data_1:
                         exclude_words = ['\xa0']
                         for word in race_data_1:
                              if word in exclude_words:
                                   race_data_1.remove(word)
                    
                    if len(race_data_2) >= 3 and race_data_2[2].strip() == '':
                         print("1レース目の情報がないため、翌日のレースへ")
                         break
                    if '-' in race_data_1:
                         print('レースが行えなかったため、次のレースへ')
                         continue

                    #確認用
                    #print(year_month_day,this_place,weather,race_title,race_sum,shiba,dart,distance,ryou,yayaomo,omo,huryou)

                    #最新レースのレース条件は完全に完了

                    #各馬のurlを取得
                    horse_info_cells = soup.find_all('td', class_='Horse_Info')
                    haraimodosi_info=soup.find('tr', class_='Tansho').get_text()
                    if '返還' in haraimodosi_info:
                        print('レース取りやめ、翌日へ')
                        break
                    horse_url = [a['href'] for cell in horse_info_cells for a in cell.find_all('a', href=True)]
                    print(horse_url)
                    race_sum=len(horse_url)

                    if not (isinstance(race_sum, (int, float)) or (isinstance(race_sum, str) and race_sum.isdigit())):
                         continue

                    race_result_df=extract_race_result_table(url)
                    if race_result_df is None:
                        print('No race result data, skipping this race')
                        continue
                    race_info=extract_race_info(soup)
                    print(race_result_df)
                    print(f"Race Info: {race_info}")
                    
                    # ... existing code ...
                    
                    # 各馬の過去5走データを取得
                    soup_2=web_access(url_shutuba)
                    past5_df = extract_past5_data(soup_2)
                    if past5_df is None:
                        print('No past 5 data found,skipping this race')
                        continue
                    past5_df.replace('',0,inplace=True)
                    past5_df.replace(0,inplace=True)
                    print(past5_df.to_string(index=False))

                    print(past5_df)

                    #カラム名「前走」から「5走」までの要素の処理
                    race_classes = ['OP','重賞','Jpn1','Jpn2','Jpn3','G1','G2','G3','新馬','デビュー','未勝利','1勝クラス','2勝クラス','3勝クラス',r'A\d+',r'B\d+',r'C\d+',r'[ABC]\d+[一二三四五六七八九十]']
                    for col in ['前走','2走','3走','4走','5走']:
                        for line in range(past5_df.shape[0]):
                            past5_str = str(past5_df.loc[past5_df.index[line],col])
                            #除外系を0埋め
                            past5_str=normalize_spaces(past5_str)
                            past5_line = past5_str.split(' ')
                            if not len(past5_line)==1:
                                if (past5_line[2]=='除') or (past5_line[2]=='中'):
                                    if len(past5_line)==14:
                                        past5_line.insert(-1,'0')
                                        past5_line.insert(-4,'0')
                                        past5_line.insert(6,'0')
                                    elif len(past5_line)==13:
                                        past5_line.insert(-1,'0')
                                        past5_line.insert(-1,'0')
                                        past5_line.insert(-4,'0')
                                        past5_line.insert(6,'0')
                            #条件分岐:
                            #1:レースの格情報が入っていない…この時はタイトルにも入っていないし、スクレイピングで抽出できてもいない
                            if not any(re.search(race_class, past5_str) for race_class in race_classes):
                                past5_str=normalize_spaces(past5_str)
                                past5_line = past5_str.split(' ')
                                past5_line.insert(4,'未格付')
                                past5_new_str=' '.join(past5_line)
                                past5_df.loc[past5_df.index[line],col] = past5_new_str
                            #2:レースの格情報がレース名にのみ乗っている
                            #3:既にレース情報に格が入っている
                            else:
                                if not (past5_str==0 or past5_str=='0'):
                                    past5_str=normalize_spaces(past5_str)
                                    past5_line = past5_str.split(' ')
                                    if not any(re.search(race_class, past5_line[4]) for race_class in race_classes):
                                        #真ならばレース名にのみ格情報が入っている
                                        # #偽ならレースの格情報が入っている
                                        past5_line=race_class_plus(past5_line,race_classes)
                                        past5_new_str = ' '.join(past5_line)
                                        past5_df.loc[past5_df.index[line],col] = past5_new_str
                    # レース情報をデータフレーム化
                    race_info_df=pd.DataFrame([race_info]*len(race_result_df),columns=race_info.keys())

                    #レース情報とレース結果を結合
                    race_result_df=pd.concat([race_info_df,race_result_df],axis=1)

                    maerged_df=pd.merge(race_result_df,past5_df,left_on='馬番',right_on='馬番',how='inner')
                    print(maerged_df)

                    all_maerged_dfs.append(maerged_df)
                except:
                    continue    #print(all_maerged_dfs)
final_df=pd.concat(all_maerged_dfs,ignore_index=True)

print(final_df)



final_df.to_csv('2018.csv',index=False,encoding='utf-8-sig')

print('CSVファイルに出力しました')
```


# 5.機械学習に関して
別記事で投稿します