競馬予想AIの作成③(競馬データの取得③)

Python

はじめに

前回前々回のプログラムを結合して、指定期間のレース結果を取得してCSVに保存するプログラムを作成しました。前々回のプログラムの一部を変更しています。
変更したのは、距離/天候/競争種別/馬場状態/開催日/開催場所を追加しています。

AIで予測するデータを「タイム」とすることにしました。
「タイム」を1列目に移動してCSVファイルに保存します。

競馬データの取得先

以下のページから競馬データを取得します。

netkeiba.com – 国内最大級の競馬情報サイト

プログラムの概要

前回作成したプログラムと前々回作成したプログラムを結合したプログラムです。
変更が有った箇所は「変更有り」と記載しています。

該当行 概要
1~12 ライブラリの読み込み。変更有り
17~50 メイン処理。各関数を呼び出して各レース結果をCSVに保存する。変更有り
53~92 レース詳細検索で検索条件を指定して検索する。変更無し
95~127 レース詳細検索の結果から各レース結果のURLを抽出する。変更無し
130~157 レース詳細検索の結果で「次」がある場合、「次」をクリックする。変更無し
160~239 レース結果の詳細を取得する。前々回のプログラムで変更有り

事前準備

2022年の1~5月の芝・ダートのレース結果を取得する場合、6700レースの情報を取得することになります。1秒間に4レースの情報を取得できたとしても、プログラムの実行にはおよそ27分かかります。進捗状況を確認できるように、tqdmを用いて進捗バーを表示します。tqdmはpipでインストールします。

pip install tqdm

tqdmを使用することで、下記のように進捗バーを表示することができるようになります。

作成したプログラム

掲載したプログラムは、前回と前々回から「変更有り」の箇所となります。

ライブラリ読み込み

requests、re、tqdmのライブラリを追加しました。

# ライブラリの読み込み
import pandas as pd
import urllib
import requests
import re

from webdriver_manager.chrome import ChromeDriverManager
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import Select
from bs4 import BeautifulSoup

# プログレスバーを表示するためのライブラリを読み込む
from tqdm import tqdm

メイン処理

取得したレース結果をCSVに保存する処理と、コメントを変更しました。

# メイン
def main():
    # webdriver_managerで最適なchromeのバージョンをインストールして設定する
    browser = webdriver.Chrome(ChromeDriverManager().install())

    # 競馬データベースを開く
    browser.get('https://db.netkeiba.com/?pid=race_search_detail')

    browser.implicitly_wait(10) # 指定した要素が見つかるまでの待ち時間を10秒と設定する
    search_rase(browser)        # 検索条件を設定して検索する
    link_list = []              # リンクページのURLリスト

    no_click_next = 0           # 0:次のページ無し、1:次のページ有り
    count_click_next = 0        # 0:検索結果の1ページ目、1以上:検索結果の2ページ目以上
    while no_click_next == 0:
        # レース結果のリンクページのURLを取得する
        link_list = make_raseURL(browser, link_list)

        # 「次」をクリックし、「次」の有無とクリックした回数を返す
        no_click_next, count_click_next = click_next(browser, count_click_next)

    df_RaceResult = pd.DataFrame()

    # レース結果のリンクページにアクセスして、レース結果を取得する
    # 日本レース以外のレース結果は取得しない
    # 日本以外のレースのリンクページには、/race/2022H1a00108/のようにアルファベットが含まれている
    # isdecimalで文字列が数字のみかを判定している
    print('レース結果の詳細を取得します')
    for url in tqdm(link_list):
        if url.split('/')[-2].isdecimal():
            df_RaceResult = pd.concat([df_RaceResult, output_RaceResult(url)], axis=0)

    # CSVにレース結果を保存する
    df_RaceResult.to_csv('./レース結果.csv', encoding='cp932', header=False, index=False, errors="ignore")

レース結果の詳細を取得する

レース距離、天候、馬場の状態、レース名、開催日、開催場所を前々回から追加して取得します。
「タイム」を1列目に移動したデータフレームを作成します。

# レース結果の詳細を取得する
def output_RaceResult(url):
    res = requests.get(url) # 指定したURLからデータを取得する
    soup = BeautifulSoup(res.content, 'html.parser') # content形式で取得したデータをhtml形式で分割する

    # レース名を取得する
    race_name = soup.find_all('h1')
    race_name = race_name[1].text

    # 開催日、開催場所を取得する
    race_base_info = soup.find('p', attrs={'class': 'smalltxt'})
    rase_base_info_text = race_base_info.text.replace(u'\xa0', u' ')
    words = rase_base_info_text.split(' ')
    race_date = words[0]
    race_place = words[1]

    # レース情報を取得する
    race_info = soup.find('diary_snap_cut')
    race_info_text = race_info.text.replace(u'\xa0', u' ')
    race_info_text = race_info.text.replace(u'\xa5', u' ')
    words = race_info_text.split('/')

    race_info_distance = int(re.sub(r'\D', '', words[0]))   # レース距離だけを取り出してint型で保存する
    race_info_weather = words[1].split(':')                 # 天候
    race_info_condition = words[2].split(':')               # 馬場の状態

    # tableデータを抽出する
    tables = soup.find('table', attrs={'class': 'race_table_01'})
    tables = tables.find_all('tr')

    # 取得したデータからindex名を抽出する
    indexs = tables[0].text.split('\n')

    # レース情報/結果を取得する
    tmp = []
    df = pd.DataFrame()
    df_tmp1 = pd.DataFrame()

    for table in tables[1:]:
        tmp = table.text.split('\n')
        df_tmp1 = pd.Series(tmp)
        df = pd.concat([df, df_tmp1], axis=1)

    # 学習に必要な情報のみを抽出する
    # 着順:要素No.1、馬名:要素No.5、性齢:要素No.7、斤量:要素No.8、騎手:要素No.10、
    # タイム:要素No.12、上り:要素No.21、単勝:要素No.23、人気:要素No.24、馬体重:要素No.25の情報を抽出する
    df_tmp2 = pd.DataFrame()
    df_tmp3 = pd.DataFrame()
    for i in (1, 5, 7, 8, 10, 12, 21, 23, 24, 25):
        df_tmp2 = df.iloc[i]
        df_tmp3 = pd.concat([df_tmp3, df_tmp2], axis=1)

    # カラム名を設定する
    tmp = ['着順', '馬名', '性齢', '斤量', '騎手', 'タイム', '上り', '単勝', '人気', '馬体重']
    df_columns = pd.Series(tmp)
    df_tmp3.columns = df_columns  # index名を設定する

    df_tmp4 = pd.DataFrame()

    try:
        # 距離、天候、馬場、状態の列を追加する
        df_tmp3['距離'] = race_info_distance
        df_tmp3['天候'] = race_info_weather[1]
        df_tmp3['馬場'] = race_info_condition[0]
        df_tmp3['状態'] = race_info_condition[1]
        df_tmp3['開催日'] = race_date
        df_tmp3['レース名'] = race_name
        df_tmp3['開催場所'] = race_place

        # 説明変数として使用しない列を削除する
        df_tmp3.drop(['着順', '上り'], axis=1, inplace=True)

        # 目的変数にするタイムを1列に変更する
        df_tmp4 = df_tmp3.reindex(columns=['タイム', '馬名', '性齢', '斤量', '騎手', '単勝',
                                           '人気', '馬体重', '距離', '天候', '馬場', '状態',
                                           '開催日', 'レース名', '開催場所'])
    except:
        print('レース情報を取得できませんでした')

    return df_tmp4

全コードのまとめ

下記に各コードをまとめました。

クリックで全コードを表示
# ライブラリの読み込み
import time

import pandas as pd
import urllib
import requests
import re

from webdriver_manager.chrome import ChromeDriverManager
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import Select
from bs4 import BeautifulSoup

# プログレスバーを表示するためのライブラリを読み込む
from tqdm import tqdm

# メイン
def main():
    # webdriver_managerで最適なchromeのバージョンをインストールして設定する
    browser = webdriver.Chrome(ChromeDriverManager().install())

    # 競馬データベースを開く
    browser.get('https://db.netkeiba.com/?pid=race_search_detail')

    browser.implicitly_wait(10) # 指定した要素が見つかるまでの待ち時間を10秒と設定する
    search_rase(browser)        # 検索条件を設定して検索する
    link_list = []              # リンクページのURLリスト

    no_click_next = 0           # 0:次のページ無し、1:次のページ有り
    count_click_next = 0        # 0:検索結果の1ページ目、1以上:検索結果の2ページ目以上
    while no_click_next == 0:
        # レース結果のリンクページのURLを取得する
        link_list = make_raseURL(browser, link_list)

        # 「次」をクリックし、「次」の有無とクリックした回数を返す
        no_click_next, count_click_next = click_next(browser, count_click_next)

    df_RaceResult = pd.DataFrame()

    # レース結果のリンクページにアクセスして、レース結果を取得する
    # 日本レース以外のレース結果は取得しない
    # 日本以外のレースのリンクページには、/race/2022H1a00108/のようにアルファベットが含まれている
    # isdecimalで文字列が数字のみかを判定している
    print('レース結果の詳細を取得します')
    for url in tqdm(link_list):
        if url.split('/')[-2].isdecimal():
            df_RaceResult = pd.concat([df_RaceResult, output_RaceResult(url)], axis=0)

    # CSVにレース結果を保存する
    df_RaceResult.to_csv('./レース結果.csv', encoding='cp932', header=False, index=False, errors="ignore")

# 検索条件を設定して検索する
def search_rase(browser):
    print('検索条件を設定します')
    # 競争種別で「芝」と「ダート」にチェックを入れる
    elem_check_track_1 = browser.find_element(By.ID, value='check_track_1')
    elem_check_track_2 = browser.find_element(By.ID, value='check_track_2')

    elem_check_track_1.click()
    elem_check_track_2.click()

    # 期間を2010年から2022年に設定する
    elem_start_year = browser.find_element(By.NAME, value='start_year')
    elem_start_year_select = Select(elem_start_year)
    elem_start_year_select.select_by_value('2022')

    # 月を指定する場合は、select_by_valueで月数を指定する
    elem_start_month = browser.find_element(By.NAME, value='start_mon')
    elem_start_month_select = Select(elem_start_month)
    elem_start_month_select.select_by_value('1')

    elem_end_year = browser.find_element(By.NAME, value='end_year')
    elem_end_year_select = Select(elem_end_year)
    elem_end_year_select.select_by_value('2022')

    # 月を指定する場合は、select_by_valueで月数を指定する
    elem_end_month = browser.find_element(By.NAME, value='end_mon')
    elem_end_month_select = Select(elem_end_month)
    elem_end_month_select.select_by_value('2')

    # 画面を下にスクロールする
    # browser.execute_script('window.scrollTo(0, 400);')

    # 表示件数を100件にする
    elem_list = browser.find_element(By.NAME, value='list')
    elem_list_select = Select(elem_list)
    elem_list_select.select_by_value('100')

    # 検索をクリック(submit)する
    elem_search = browser.find_element(By.CLASS_NAME, value='search_detail_submit')
    elem_search.submit()

    print('検索を行います')


# 取得したHTMLからレース結果のURLを抽出し、URLリストを作成する
def make_raseURL(browser, link_list):
    print('取得したHTMLからレース結果のURLを抽出します')
    html = browser.page_source.encode('utf-8')      # UTF-8でHTMLを取得する
    soup = BeautifulSoup(html, 'html.parser')       # 検索結果をbeautifulSoupで読み込む
    table_data = soup.find(class_='nk_tb_common')   # 検索結果のテーブルを取得する

    for element in table_data.find_all('a'):
        url = element.get('href')   # リンクページを取得する

        # リンクページがjavascriptの場合は、次のリンクページの処理に移る
        if 'javascript' in url:
            continue

        # リンクページの絶対URLを作成する
        link_url = urllib.parse.urljoin('https://db.netkeiba.com', url)

        # レース結果のみを抽出する
        # レース結果のURLは'https://db.netkeiba.com/rase/yyyyXXmmddXX'となる
        # 馬情報のURLは'https://db.netkeiba.com/horse/yyyymmddXXXX'
        # 騎手情報のURLは'https://db.netkeiba.com/jockey/result/recent/'
        # レース結果以外のリンクページを除外するための単語リストを用意する
        word_list = ['horse', 'jockey', 'result', 'sum', 'list', 'movie']

        tmp_list = link_url.split('/') # リンクページのURLを'/'で分割する
        and_list = set(word_list) & set(tmp_list) # word_listとtmp_listを比較し、一致している単語を抽出する

        # 一致している単語が0のリンクページのURLのリストを作成する
        if len(and_list) == 0:
            link_list.append(link_url)

    print('URLを抽出しました')
    return link_list


# 画面下にスクロールして「次」をクリックする
def click_next(browser, count_click_next):
    # 画面を下にスクロールする
    time.sleep(5)
    browser.execute_script('window.scrollTo(0, 2500);')

    # 次をクリックする
    # 検索1ページ目のxpathは2ページ以降とは異なるため、count_click_nextで
    # 検索1ページ目なのかを判定している
    if count_click_next == 0:
        # xpath = '//*[@id="contents_liquid"]/div[2]/a' # 20230308 サイト変更に伴いxpathの指定を変更
        xpath = '//*[@id="contents_liquid"]/div[2]/ul[1]/li[14]/a/span/span'
        elem_search = browser.find_element(By.XPATH, value=xpath)
        elem_search.click()
        no_click_next = 0
    else:
        # 検索最後のページで次をクリックしようとすると例外処理が発生する
        # exceptで例外処理を取得し、no_click_nextに1を代入する
        try:
            # xpath = '//*[@id="contents_liquid"]/div[2]/a[2]' # 20230308 サイト変更に伴いxpathの指定を変更
            xpath = '//*[@id="contents_liquid"]/div[2]/ul[1]/li[14]/a/span/span'
            elem_search = browser.find_element(By.XPATH, value=xpath)
            elem_search.click()
            no_click_next = 0
        except:
            print('次のページ無し')
            no_click_next = 1

    count_click_next += 1 # ページ数を判別するためのフラグに1を加算する

    return no_click_next, count_click_next

# レース結果の詳細を取得する
def output_RaceResult(url):
    res = requests.get(url) # 指定したURLからデータを取得する
    soup = BeautifulSoup(res.content, 'html.parser') # content形式で取得したデータをhtml形式で分割する

    # レース名を取得する
    race_name = soup.find_all('h1')
    race_name = race_name[1].text

    # 開催日、開催場所を取得する
    race_base_info = soup.find('p', attrs={'class': 'smalltxt'})
    rase_base_info_text = race_base_info.text.replace(u'\xa0', u' ')
    words = rase_base_info_text.split(' ')
    race_date = words[0]
    race_place = words[1]

    # レース情報を取得する
    race_info = soup.find('diary_snap_cut')
    race_info_text = race_info.text.replace(u'\xa0', u' ')
    race_info_text = race_info.text.replace(u'\xa5', u' ')
    words = race_info_text.split('/')

    race_info_distance = int(re.sub(r'\D', '', words[0]))   # レース距離だけを取り出してint型で保存する
    race_info_weather = words[1].split(':')                 # 天候
    race_info_condition = words[2].split(':')               # 馬場の状態

    # tableデータを抽出する
    tables = soup.find('table', attrs={'class': 'race_table_01'})
    tables = tables.find_all('tr')

    # 取得したデータからindex名を抽出する
    indexs = tables[0].text.split('\n')

    # レース情報/結果を取得する
    tmp = []
    df = pd.DataFrame()
    df_tmp1 = pd.DataFrame()

    for table in tables[1:]:
        tmp = table.text.split('\n')
        df_tmp1 = pd.Series(tmp)
        df = pd.concat([df, df_tmp1], axis=1)

    # 学習に必要な情報のみを抽出する
    # 着順:要素No.1、馬名:要素No.5、性齢:要素No.7、斤量:要素No.8、騎手:要素No.10、
    # タイム:要素No.12、上り:要素No.21、単勝:要素No.23、人気:要素No.24、馬体重:要素No.25の情報を抽出する
    df_tmp2 = pd.DataFrame()
    df_tmp3 = pd.DataFrame()
    for i in (1, 5, 7, 8, 10, 12, 21, 23, 24, 25):
        df_tmp2 = df.iloc[i]
        df_tmp3 = pd.concat([df_tmp3, df_tmp2], axis=1)

    # カラム名を設定する
    tmp = ['着順', '馬名', '性齢', '斤量', '騎手', 'タイム', '上り', '単勝', '人気', '馬体重']
    df_columns = pd.Series(tmp)
    df_tmp3.columns = df_columns  # index名を設定する

    df_tmp4 = pd.DataFrame()

    try:
        # 距離、天候、馬場、状態の列を追加する
        df_tmp3['距離'] = race_info_distance
        df_tmp3['天候'] = race_info_weather[1]
        df_tmp3['馬場'] = race_info_condition[0]
        df_tmp3['状態'] = race_info_condition[1]
        df_tmp3['開催日'] = race_date
        df_tmp3['レース名'] = race_name
        df_tmp3['開催場所'] = race_place

        # 説明変数として使用しない列を削除する
        df_tmp3.drop(['着順', '上り'], axis=1, inplace=True)

        # 目的変数にするタイムを1列に変更する
        df_tmp4 = df_tmp3.reindex(columns=['タイム', '馬名', '性齢', '斤量', '騎手', '単勝',
                                           '人気', '馬体重', '距離', '天候', '馬場', '状態',
                                           '開催日', 'レース名', '開催場所'])
    except:
        print('レース情報を取得できませんでした')

    return df_tmp4

if __name__ == "__main__":
    main()

プログラムの実行結果

プログラム実行のカレントフォルダに「レース結果.csv」が作成されます。
また、標準出力では下記のメッセージが表示されます。
中止になったレース情報を参照した場合、「レース情報を取得できませんでした」と表示されます。

====== WebDriver manager ======
Current google-chrome version is 102.0.5005
Get LATEST chromedriver version for 102.0.5005 google-chrome
Driver [C:\Users\xxxx\.wdm\drivers\chromedriver\win32\102.0.5005.61\chromedriver.exe] found in cache
C:\Users\ xxxx\Google ドライブ\Web_Scraping\xxxx.py:20: DeprecationWarning: executable_path has been deprecated, please pass in a Service object
  browser = webdriver.Chrome(ChromeDriverManager().install())
検索条件を設定します
取得したHTMLからレース結果のURLを抽出します
URLを抽出しました
~中略~

取得したHTMLからレース結果のURLを抽出します
URLを抽出しました
取得したHTMLからレース結果のURLを抽出します
URLを抽出しました
取得したHTMLからレース結果のURLを抽出します
URLを抽出しました
取得したHTMLからレース結果のURLを抽出します
URLを抽出しました
次のページ無し
レース結果の詳細を取得します
 98%|█████████▊| 6559/6700 [36:36<00:50,  2.80it/s]レース情報を取得できませんでした
レース情報を取得できませんでした
 98%|█████████▊| 6561/6700 [36:37<00:52,  2.62it/s]レース情報を取得できませんでした
レース情報を取得できませんでした
 98%|█████████▊| 6563/6700 [36:37<00:51,  2.65it/s]レース情報を取得できませんでした
レース情報を取得できませんでした
 98%|█████████▊| 6565/6700 [36:38<00:49,  2.74it/s]レース情報を取得できませんでした
レース情報を取得できませんでした
 98%|█████████▊| 6567/6700 [36:39<00:42,  3.11it/s]レース情報を取得できませんでした
レース情報を取得できませんでした
100%|██████████| 6700/6700 [37:25<00:00,  2.98it/s]

プロセスは終了コード 0 で終了しました

プログラム作成時にハマったところ

レースが中止になった場合のデータを取得するとプログラムが異常終了

中止になったレースの情報も情報としてサイトに掲載されています。そのレースをプログラムでデータ取得した際、空のデータとして取得することになります。空のデータをpandasで処理しようとすると、exceptionが発生しました。

基本的なことですが、今後は、異常処理が発生することを想定してプログラムを作成するようにします。

CSVファイルにデータを保存しようとしたときにエンコードでエラー

取得したデータをCSVファイルに保存するタイミングで下記のエラーが出ました。

UnicodeEncodeError: 'cp932' codec can't encode character '\xa0' in position 47: illegal multibyte sequence

対処としては、replaceを使って問題となっている文字列を変換しました。

race_info_text = race_info.text.replace(u'\xa0', u' ')
race_info_text = race_info.text.replace(u'\xa5', u' ')

また、CSVファイルに保存する際のオプションで「errors=”ignore”」を追加しました。

# CSVにレース結果を保存する
df_RaceResult.to_csv('./レース結果.csv', encoding='cp932', header=False, index=False, errors="ignore")

次回、試すこと

AIモデルを作成する前に、まずは取得したデータの全体像を把握します。
取得したデータから馬ごと、騎手ごと、開催場所ごと、様々な要素ごとにデータを整理してみます。

コメント

  1. tty より:

    有益な記事ありがとうございます。
    コードを活用させて頂いております。ありがとうございます。
    こちらのコードを実行すると、下記のエラーが出たのでclass名が変わったのかなと思ったのでデベロッパーツールで確認したところ『nk_tb_common race_table_01』というクラスに変わっていました。
    その後、94行目をtable_data = soup.find(class_=’nk_tb_common race_table_01′) と変更して実行してみました。
    しかし、下記のエラーが再度でました。コードの一部を変えてみたりしたのですが、結局下記のエラーが主に出てきました。
    宜しければお助けいただけないでしょうか。
    返信お待ちしております。

    ———————————————————————–
    browser = webdriver.Chrome(ChromeDriverManager().install())
    検索条件を設定します
    検索を行います
    取得したHTMLからレース結果のURLを抽出します
    Traceback (most recent call last):
    File “/Users//Desktop/memo.txt/keiba.py”, line 155, in
    main()
    File “/Users//Desktop/memo.txt/keiba.py”, line 30, in main
    link_list = make_raseURL(browser, link_list)
    File “/Users//Desktop/memo.txt/keiba.py”, line 96, in make_raseURL
    for element in table_data.find_all(‘a’):
    AttributeError: ‘NoneType’ object has no attribute ‘find_all’
    ———————————————————————–

    • sakurater より:

      エラーメッセージを見るに、netkeibaから情報を取得できていないように見受けられます。
      掲載しているプログラムの動作確認をしてみました。
      正常に動作していることを確認しましたので、プログラムの実行環境に影響しているかもしれません。

      プログラムを実行するとchromeが自動インストールされます。
      そのあとにchromeが自動起動して取得処理をseleniumで行います。
      netkeibaの情報取得に時間が掛かっているかもしれません。
      main関数内の下記の箇所を10から30に変更すると、うまくいくかもしれません。

      browser.implicitly_wait(10) → browser.implicitly_wait(30)

      • tty より:

        迅速な返信ありがとうございます。
        教えてくださったコードを書き換えてみたところ、おっしゃる通りnetkeibaから情報を取得できていなかったらしくて正常に実行することが出来ました。
        ありがとうございました!

  2. mr より:

    何度もすみません。統合プログラムをコピペしたら、下記のようなエラーが発生しました。コピペしたのでスペルミスはないとおもうのですが、対処法を教えてください。

    NameError: name ‘output_RaceResult’ is not defined

    • sakurater より:

      “NameError: name ‘output_RaceResult’ is not defined” エラーは、output_RaceResult 関数が定義されていない場合に発生します。
      output_RaceResult 関数の貼り付けで手違いがあった可能性があります。
      対処案として、「全コードのまとめ」を掲載しました。
      掲載した全コードで動作するかをお試しください。

  3. mr より:

    ありがとうございます。実行はできました‼
    追加で2点聞きたいのですが、
    一つ目は実行時にchromeが自動で立ち上がるのですがこれは仕様ですか?
    二点目は
    # 期間を2010年から2022年に設定する
    elem_start_year = browser.find_element(By.NAME, value=’start_year’)
    elem_start_year_select = Select(elem_start_year)
    elem_start_year_select.select_by_value(‘2022’)

    の’2022’を’2010’に変更すればスクレイピングのスタート年を変更できるんですよね?

    • sakurater より:

      回答が遅くなり申し訳ありません。
      コメントを見逃していました。。。

      ■1つ目の質問について
      仕様となります。
      chromeのオプションで「–headless」を付けることで、chromeを非表示することは可能です。

      # WebDriverのオプションを設定
      options = webdriver.ChromeOptions()
      options.add_argument('--headless')
      # webdriver_managerで最適なchromeのバージョンをインストールして設定する
      browser = webdriver.Chrome(ChromeDriverManager().install(), options=options)

      ■2つ目の質問について
      はい、その認識の通りです。
      「elem_start_year_select.select_by_value(‘2022’)」でスクレイピングのスタート年を設定しています。

タイトルとURLをコピーしました