競馬予測AIの作成⑳(メインプログラムの改修)

Python

はじめに

競馬予測システムにおけるメインプログラムを大幅に改修しました。

メインプログラムは、Pythonを学び始めたころに作成したコードで、可読性と保守性が低いコードでした。無駄に複数のファイルに分割されたコードでもあったので、いったん1つのファイルにコードをまとめてみました。

今後、作成したプログラムにGUIを付ける予定です。その際は、クラス単位でファイルを分割する予定です。

参考書籍

bookfan 1号店 楽天市場店
¥3,190 (2024/09/07 23:02時点 | 楽天市場調べ)

プログラムの動作概要

JRAの公式サイトからレース情報をスクレイピングし、レースデータを収集します。
収集したデータを基に、機械学習モデルを使用してレース結果の予測を行います。
予測結果はCSVファイルに保存され、Twitterに投稿されます。

プログラムのフローチャート

プログラムの主な機能

プログラムの主な機能は下記となります。

No 機能 概要
1 スクレイピング JRAの公式サイトからレース結果とレース情報を取得します
2 データ処理 収集したデータを整形し、機械学習モデルに入力するためのデータセットを作成します
3 レース予測 機械学習モデルを使用して、レース結果の予測を行います
4

予測結果の保存

予測結果をCSVファイルとして保存します
5 X(Twitter)投稿 予測結果をX(Twitter)に投稿します

プログラムで生成されるデータ

本プログラムを実行することで生成されるデータは下記となります。

No 生成されるデータ 概要
1 ログファイル 実行中のログが`./log/data/`ディレクトリに保存されます。スクレイピング、予測処理、エラー情報などが記録されます
2 レース情報のCSVファイル スクレイピングしたレース情報が`./predicted/today_raceresults`ディレクトリに保存されます。ファイル名は`races_info_<location>.csv`形式です
3 予測結果のCSVファイル レース予測の結果が`./predicted/predict_result_<location>_<date>.csv`形式で保存されます。予測した順位、馬名、オッズ、人気などの情報が含まれます
4 X(Twitter)投稿メッセージ 予測結果をまとめたTwitter投稿用メッセージが`./twitter/message_path`ディレクトリに保存されます。ファイル名は`twitter_message_<location>.txt`形式です

プログラムのユーザインターフェイス

このプログラムはコマンドラインインターフェイス(CLI)で実行されます。ユーザーは設定ファイル(config.ini)を編集し、プログラムを実行するだけで、スクレイピングから予測、Twitter投稿までの一連の処理が自動的に行われます。

プログラムの構成

プログラムは以下のクラスと関数で構成されています。

クラス名 概要
LoggerSetup ログ設定を行います
TwitterClient Twitter APIクライアントを操作します
BrowserSetup Chromeブラウザをセットアップします
NavigationHelper JRAサイトを参照して操作を行います
RequestHelper HTTPリクエストを処理します
RaceResultsScraper JRAレース結果をスクレイピングします
JraRaceDataFetcher JRAレースデータを取得します
RacePrediction レース予測を実行します
RacePredictionProcessing レース予測処理を実行します
RacePredictionExecutor レース予測の実行を管理します

各クラスのメソッドの概要

クラス:LoggerSetup

メソッド名 概要
setup_logger(log_config_path) ログ設定ファイルを使用してロガーを設定します

クラス:TwitterClient

メソッド名 概要
__init__(config) Twitter APIクライアントを初期化します
create_tweet(message) 指定されたメッセージでツイートを作成します

クラス:BrowserSetup

メソッド名 概要
init_browser() Chromeブラウザを初期化します

クラス:NavigationHelper

メソッド名 概要
navigate_to_jra_page(browser, logger, access_type, max_errors=3) 指定されたタイプのページに誘導します

クラス:RequestHelper

メソッド名 概要
get_soup(url, parser=’html5lib’) 指定されたURLにHTTPリクエストを送信し、レスポンスをBeautifulSoupオブジェクトとして返します

クラス:RaceResultsScraper

メソッド名 概要
__init__(location, config, logger) RaceResultsScraperクラスのインスタンスを初期化します
access_raceresults() JRAサイトのレース結果ページにアクセスします
get_locations(soup) レース開催地のリストを取得します
get_location_url(soup) レース開催地のURLリストを取得します
get_round_url(soup) レースのラウンドのURLリストを取得します
preparing_raceresult_data(url) 指定されたURLのレース結果ページを取得し、BeautifulSoupオブジェクトとして返します
get_race_result(soup) レース結果を抽出し、DataFrame形式で返します
main() RaceResultsScraperのインスタンスの全体の処理を実行します

クラス:JraRaceDataFetcher

メソッド名 概要
__init__(config, logger) JraRaceDataFetcherクラスのインスタンスを初期化します
setup_directory() ディレクトリのセットアップを行います
navigate_to_jra_page(access_type) 指定されたタイプのページに誘導します
extract_location_info(soup) レース開催地の情報を抽出します
fetch_location_urls(soup) レース開催地のURLリストを取得します
preparing_raceinfo_data(url) 指定されたURLのレース情報ページを取得し、不要なタグを削除します
extract_round_urls(soup) ラウンドのURLリストを取得します
get_race_times(soup, url) レースの時間情報を抽出します
save_cell_time(cell_time_info, loc_name) レースの時間情報をCSVファイルとして保存します
get_URL_of_each_racetrack_round() 各レーストラックのラウンドURL、開催地リスト、開催地URLリストを取得します
extract_and_format_of_race_info(round_url_list) ラウンドURLリストからレース情報を抽出します
save_location_name(location_list) 開催地名をファイルに保存します
main() JraRaceDataFetcherのインスタンスの全体の処理を実行します

クラス:RacePrediction

メソッド名 概要
__init__(location, csv_file, config, logger) RacePredictionクラスのインスタンスを初期化します
load_race_info() レース情報をCSVファイルから読み込みます
encode_race_info(df_race_info_add_predict) レース情報をエンコードします
predict_time_from_race_info(dmatrix_race_info, df_race_info_add_predict) 予測時間を算出します
labelencode_race_location_round(df) レース名、開催地、ラウンドをラベルエンコードします
predict_ranking(df_race_info) 順位を予測します
set_path_for_twitter_message(location) Twitterメッセージの保存パスを設定します
assign_prediction_ranks(df) 予測時間に基づいて順位を割り当てます
save_twitter_message(twitter_message_path, df_top3) 上位3位の予測結果を保存します
predict_top3_ranks(race_encoded_unique_list, df, twitter_message_path) 上位3位の予測ランクを生成します
remove_unnecessary_columns(df_predict_eval) 不要な列を削除します
sort_columns(df_predict_eval) 列を並び替えます
process_race_predictions(df_race_info) レース情報を処理して予測を行います
append_predicted_top3_ranks_to_csv(df_predict_eval) 予測結果の上位3位をCSVファイルに追加します
run() レース予測を実行します

クラス:RacePredictionProcessing

メソッド名 概要
__init__(location, logger) RacePredictionProcessingクラスのインスタンスを初期化します
load_config() 設定ファイルを読み込みます
load_today_cell_time_info() 本日のレース発走時刻の情報を読み込みます
preparing_raceinfo_data(url) レース情報を整形します
extract_list_from_df(df, row_index, pattern, extract_method=None, default_value=”, post_process=None) DataFrameの特定の行からリストを抽出します
extract_text(soup, tag, attrs, extract_array_index=None, next_elem=None, split_text=None, split_array_index=None, replace=None, regex=None) 特定のテキストを抽出します
get_race_info(soup) レース情報を取得します
get_weather_condition_info() 天気と馬場の情報を取得します
set_weather_condition(df) 天気と馬場の情報をDataFrameに設定します
reindex_df(df) DataFrameの列を並び替えます
save_race_info(races_info) レース情報を保存します
run_prediction_processing(race_info) レース予測処理を実行します
tweet_predicted_rankings(message_file) 予測した順位をツイートします
tweet(message) ツイートを投稿します
process_race_row(row) 各レース行に対する処理を実行します
run_prediction() レース予測処理を開始します

クラス:RacePredictionExecutor

メソッド名 概要
__init__(config_file=’./config.ini’) RacePredictionExecutorクラスのインスタンスを初期化します
load_race_locations() レース開催地のリストを読み込みます
prepare_textfile_to_save_message(today_yyyymmdd) メッセージを保存するためのテキストファイルを準備します
save_win_lose_ratio(location, csv_file) 勝敗比率を計算します
twittermessage_after_final_race(location, count, round_list_size) 最終レース後にTwitterメッセージを作成します
tweet_after_final_race() 最終レース後にTwitterにメッセージを投稿します
main() RacePredictionExecutorのインスタンスの全体の処理を実行します

プログラムの実行環境

このプログラムはPython 3.7以降で動作します。Pythonの実行環境を起動し、以下のライブラリをインストールしてください。

> pip install configparser
> pip install datetime
> pip install glob
> pip install itertools
> pip install joblib
> pip install logging
> pip install os
> pip install pandas
> pip install pickle
> pip install selenium
> pip install re
> pip install requests
> pip install tweepy
> pip install urllib
> pip install xgboost
> pip install BeautifulSoup
> pip install tqdm
> pip install webdriver_manager

プログラムの実行方法

  1. config.iniファイルの設定
    プログラムの設定を行うためのconfig.iniファイルを編集します。APIキーやパス、ディレクトリの設定などを行います。
  2. 「プログラムの全コード」を「main.py」として保存してください。
  3. プログラムの実行
    Pythonの実行環境で以下のように実行します。引数などはありません。
    > python main.py

プログラムの全コード

# JRA(日本中央競馬会)のレース情報をスクレイピングし、レース予測を行い、その結果をTwitterに投稿します。
# 実行方法:
# 1. config.iniを適切に設定する
# 2. 動作に必要なライブラリをインストールする
# 3. 下記のコマンドで実行する
# > python main.py
#
# 出力される内容:
# 1. ログファイル:
#    - 実行中に発生したログが./log/data/ディレクトリに保存されます。
#    - ログファイルには、スクレイピング、予測処理、エラー情報などが記録されます。
#
# 2. レース情報のCSVファイル:
#    - スクレイピングしたレース情報が./predicted/today_raceresultsディレクトリに保存されます。
#    - ファイル名は races_info_.csv 形式で、各開催場所ごとに保存されます。
#
# 3. 予測結果のCSVファイル:
#    - レース予測の結果が./predicted/predict_result__.csv形式で保存されます。
#    - ファイルには、予測した順位、馬名、オッズ、人気、その他のレース情報が含まれます。
#
# 4. Twitter投稿メッセージ:
#    - 予測結果をまとめたTwitter投稿用のメッセージが./twitter/message_pathディレクトリに保存されます。
#    - ファイル名はtwitter_message_.txt形式で、各開催場所ごとに保存されます。
#    - メッセージには、当日の予測結果のまとめ、複勝の的中率などが含まれます。

# ライブラリの読み込み
import configparser
import datetime
import glob
import itertools
import joblib
import logging.config
import time
import os
import pandas as pd
import pickle
import selenium
import re
import requests
import tweepy
import urllib
import xgboost as xgb

from bs4 import BeautifulSoup
from datetime import datetime as dt
from multiprocessing import Pool
from selenium import webdriver
from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.common.by import By
from sklearn.preprocessing import LabelEncoder
from tqdm import tqdm
from webdriver_manager.chrome import ChromeDriverManager


class LoggerSetup:
    """
    ログ設定を行うためのクラス。

    メソッド
    -------
    setup_logger(log_config_path: str) -> logging.Logger:
        指定されたログ設定ファイルからロガーを設定して返す。
    """
    @staticmethod
    def setup_logger(log_config_path):
        """
        ログ設定ファイルを使用してロガーを設定する。

        :param str log_config_path: ログ設定ファイルのパス
        :return: 設定されたロガーインスタンス
        :rtype: logging.Logger
        """
        os.makedirs('./log/data/', exist_ok=True)
        logging.config.fileConfig(log_config_path)
        return logging.getLogger(__name__)


class TwitterClient:
    """
    Twitter APIクライアントを操作するためのクラス。

    メソッド
    -------
    __init__(config: configparser.ConfigParser):
        Twitter APIクライアントを初期化する。

    create_tweet(message: str):
        指定されたメッセージでツイートを作成する。
    """
    def __init__(self, config):
        """
        Twitter APIクライアントを初期化する。

        :param configparser.ConfigParser config: Twitter APIの認証情報を含む設定オブジェクト
        """
        self.client = tweepy.Client(
            bearer_token=config['twitter']['BEARER_TOKEN'],
            consumer_key=config['twitter']['API_KEY'],
            consumer_secret=config['twitter']['API_SECRET'],
            access_token=config['twitter']['ACCESS_TOKEN'],
            access_token_secret=config['twitter']['ACCESS_TOKEN_SECRET'],
        )

    def create_tweet(self, message):
        """
        指定されたメッセージでツイートを作成する。

        :param str message: ツイートするメッセージ
        """
        self.client.create_tweet(text=message)


class BrowserSetup:
    """
    ブラウザのセットアップを行うためのクラス。

    メソッド
    -------
    init_browser() -> selenium.webdriver.Chrome:
        Chromeブラウザを初期化して返す。
    """
    @staticmethod
    def init_browser():
        """
        Chromeブラウザを初期化する。

        :return: 初期化されたChromeブラウザ
        :rtype: selenium.webdriver.Chrome
        """
        service = ChromeService(executable_path=ChromeDriverManager().install())
        browser = webdriver.Chrome(service=service)
        browser.implicitly_wait(20)
        return browser


class NavigationHelper:
    """
    JRAサイトへのナビゲーションを支援するためのクラス。

    メソッド
    -------
    navigate_to_jra_page(browser: selenium.webdriver.Chrome, logger: logging.Logger, access_type: str, max_errors: int = 3) -> BeautifulSoup:
        指定されたタイプのページにナビゲートし、ページの内容を返す。
    """
    @staticmethod
    def navigate_to_jra_page(browser, logger, access_type, max_errors=3):
        """
        指定されたタイプのページにナビゲートし、ページの内容を返す。

        :param selenium.webdriver.Chrome browser: 使用するブラウザインスタンス
        :param logging.Logger logger: ロギング用ロガーインスタンス
        :param str access_type: アクセスタイプ ('entries' または 'results')
        :param int max_errors: 最大エラー回数(デフォルトは3)
        :return: ページの内容を解析したBeautifulSoupオブジェクト
        :rtype: BeautifulSoup
        """
        error_count = 0
        while error_count < max_errors:
            try:
                logger.info('JRAサイトから情報の取得を開始します')
                browser.get('https://www.jra.go.jp/')
                navigation_steps = {
                    'entries': ('//*[@id="quick_menu"]/div/ul/li[2]/a',
                                '//*[@id="main"]/div[2]/div/div/div[1]/a',
                                '//*[@id="race_list"]/tbody/tr[1]/th/a'),
                    'results': ('//*[@id="quick_menu"]/div/ul/li[2]/a',
                                '//*[@id="main"]/div[2]/div/div/div[1]/a',
                                '//*[@id="race_list"]/tbody/tr[1]/td[8]/a')
                }
                for step in navigation_steps[access_type]:
                    browser.find_element(By.XPATH, step).click()
                res = requests.get(browser.current_url)
                return BeautifulSoup(res.content, 'lxml')
            except selenium.common.exceptions.NoSuchElementException as err:
                error_count += 1
                logger.info(f'エラーが発生しました: {err}')
                if error_count >= max_errors:
                    logger.info('最大エラー回数に達したため、処理を終了します。')
                    exit(1)
                else:
                    logger.info(f'リトライします ({error_count}/{max_errors})')
            except Exception as err:
                logger.info(f'予期しないエラーが発生しました: {err}')
                exit(1)
            finally:
                browser.quit()


class RequestHelper:
    """
    HTTPリクエストを処理するためのクラス。

    メソッド
    -------
    get_soup(url: str, parser: str = 'html5lib') -> BeautifulSoup:
        指定されたURLにHTTPリクエストを送信し、レスポンスをBeautifulSoupオブジェクトとして返す。
    """
    @staticmethod
    def get_soup(url, parser='html5lib'):
        """
        指定されたURLにHTTPリクエストを送信し、レスポンスをBeautifulSoupオブジェクトとして返す。

        :param str url: 取得するWebページのURL
        :param str parser: 使用するパーサー(デフォルトは'html5lib')
        :return: レスポンスを解析したBeautifulSoupオブジェクト
        :rtype: BeautifulSoup
        """
        res = requests.get(url)
        res.encoding = res.apparent_encoding    # resに含まれるテキストから文字コードを自動設定
        return BeautifulSoup(res.text, parser)


class RaceResultsScraper:
    """
    JRAレース結果をスクレイピングするためのクラス。

    メソッド
    -------
    __init__(self, location: str, config: configparser.ConfigParser, logger: logging.Logger):
        RaceResultsScraperクラスのインスタンスを初期化する。

    access_raceresults(self) -> BeautifulSoup:
        JRAサイトのレース結果ページにアクセスし、そのページの内容を返す。

    get_locations(self, soup: BeautifulSoup) -> list:
        BeautifulSoupオブジェクトからレース開催地のリストを取得する。

    get_location_url(self, soup: BeautifulSoup) -> list:
        BeautifulSoupオブジェクトからレース開催地のURLリストを取得する。

    get_round_url(self, soup: BeautifulSoup) -> list:
        BeautifulSoupオブジェクトからレースのラウンドのURLリストを取得する。

    preparing_raceresult_data(self, url: str) -> BeautifulSoup:
        指定されたURLのレース結果ページを取得し、BeautifulSoupオブジェクトとして返す。

    get_race_result(self, soup: BeautifulSoup) -> pd.DataFrame:
        BeautifulSoupオブジェクトからレース結果を抽出し、DataFrame形式で返す。

    main(self):
        全体の処理を実行し、レース結果を取得し、保存する。
    """
    def __init__(self, location, config, logger):
        """
        RaceResultsScraperクラスのインスタンスを初期化する。

        :param str location: レース開催地のローマ字表記
        :param configparser.ConfigParser config: 設定情報を含むConfigParserオブジェクト
        :param logging.Logger logger: ロギング用ロガーインスタンス
        """
        self.location = location
        self.config = config
        self.logger = logger
        self.loc_dict = dict(self.config['loc_romaji_jp'])
        self.race_results_path = f'{self.config['settings']['predicted_path']}/today_raceresults'
        self.predicted_results_path = f'{self.config['settings']['predicted_path']}/predict_result'
        self.browser = BrowserSetup.init_browser()
        os.makedirs(self.race_results_path, exist_ok=True)

    def get_locations(self, soup):
        """
        BeautifulSoupオブジェクトからレース開催地のリストを取得する。
        サイト内上部にあるリンク、例[1回中山5日][1回中京5日][1回小倉1日]から[中山][中京][小倉]を取得する。

        :param BeautifulSoup soup: レース結果ページのBeautifulSoupオブジェクト
        :return: レース開催地のリスト
        :rtype: list
        """
        tag_div = soup.find_all('div', attrs={'class': 'link_list multi div3 center mid narrow'})
        tag_div_text = tag_div[0].text
        tag_div_text = re.sub(r'\d', '', tag_div_text)  # 数字を削除
        location_list = re.sub(r'[回日]', '', tag_div_text).split('\n')   # 回,日の文字を削除して改行で分割
        location_list = list(filter(None, location_list))   # 空の要素を削除
        return location_list

    def get_location_url(self, soup):
        """
        BeautifulSoupオブジェクトからレース開催地のURLリストを取得する。
        [5回東京5日][5回阪神5日]などのURLを取得する。

        :param BeautifulSoup soup: レース結果ページのBeautifulSoupオブジェクト
        :return: レース開催地のURLリスト
        :rtype: list
        """
        locations_info = soup.find_all('div', attrs={'class': 'link_list multi div3 center mid narrow'})

        # 取得したdivタグから追加でaタグを取得し、aタグからhrefを抽出する
        # 'https://www.jra.go.jp'と抽出したhrefを結合してlocation_urlに保存する
        location_url = [urllib.parse.urljoin('https://www.jra.go.jp', location.get('href'))
                        for locations in locations_info
                        for location in locations.find_all('a')]
        return location_url

    def get_round_url(self, soup):
        """
        BeautifulSoupオブジェクトからレースのラウンドのURLリストを取得する。
        ラウンドへのリンクはページの上部と下部の2箇所に配置されている。
        上部のリンクを用いてラウンドのURLを取得する。

        :param BeautifulSoup soup: レース結果ページのBeautifulSoupオブジェクト
        :return: ラウンドのURLリスト
        :rtype: list
        """
        rounds_info = soup.find('ul', attrs={'class': 'nav race-num mt15'})
        round_url = [urllib.parse.urljoin('https://www.jra.go.jp', round.get('href'))
                     for round in rounds_info.find_all('a')]
        return round_url

    def preparing_raceresult_data(self, url):
        """
        指定されたURLのレース結果ページを取得し、整形したBeautifulSoupオブジェクトとして返す。
        ブリンカーを着用している馬は、他の馬より1つデータが多いため、その後の処理でエラーが出る。
        ブリンカー着用のタグを削除してデータを整える。

        :param str url: レース結果ページのURL
        :return: レース結果ページの内容を解析したBeautifulSoupオブジェクト
        :rtype: BeautifulSoup
        """
        soup = RequestHelper.get_soup(url)

        # ブリンカー着用のクラスを削除する
        for i in soup.find_all('div', attrs={'class': 'icon blinker'}):
            i.decompose()

        # ブリンカー着用のタグを削除しても、不要な改行が残るので、改行を空文字で置換する
        for i in soup.find_all('td', attrs={'class': 'horse'}):
            horse = i.text.replace('\n', '')
            i.replace_with(horse)
        return soup

    def get_race_result(self, soup):
        """
        BeautifulSoupオブジェクトからレース結果を抽出し、DataFrame形式で返す。

        :param BeautifulSoup soup: レース結果ページのBeautifulSoupオブジェクト
        :return: レース結果のDataFrame
        :rtype: pd.DataFrame
        """
        race_info = pd.DataFrame()
        # 除外と取消の出走馬を除いて、tableを1行ごとにデータフレームに入れる
        tbody = soup.find('tbody').find_all('tr')
        df = pd.concat([pd.Series(tb.text.split('\n')) for tb in tbody[:3]], axis=1)

        # 馬名(カタカナ)を抽出
        horse_list = [re.findall('[\u30A1-\u30FF]+', i)[0] for i in df.iloc[4]]
        race_info['horse'] = horse_list
        return race_info

    def main(self):
        """
        全体の処理を実行し、レース結果を取得し、保存する。
        """
        start_time = time.time()
        self.logger.info('start_time:{}'.format(start_time))

        soup = NavigationHelper.navigate_to_jra_page(self.browser, self.logger, 'results')
        # レース開催場所を取得
        location_list = self.get_locations(soup)
        self.logger.info('location_list:{}'.format(' '.join(map(str, location_list))))

        # 開催場所のURLを取得
        location_url = self.get_location_url(soup)
        self.logger.info('location_url:{}'.format(' '.join(map(str, location_url))))

        # 引数で設定された開催場所(nakayama,tokyoなど)のloc_dictのkey(中山,東京など)を取得
        location_romaji = self.loc_dict[self.location]

        # 現在の日付を年月日のフォーマットで取得
        now = datetime.datetime.now()
        today = now.strftime('%Y年%#m月%#d日')
        today_yyyymmdd = now.strftime('%Y%m%d')

        # 開催場所のレース結果を取得
        round_url = []
        for url in location_url:
            soup = RequestHelper.get_soup(url)

            # レース情報に引数で設定された開催場所が含まれていたらラウンドのURLを取得する
            location_info = soup.find('div', attrs={'class': 'cell date'})
            self.logger.info('location_info:{}'.format(location_info))

            # 指定した開催場所が取得したレース結果に含まれているか、
            # プログラムを実行した日付のレース結果が含まれているか、をbool型で保存する
            bool_location = location_romaji in location_info.text
            bool_day = today in location_info.text

            if bool_location and bool_day:
                round_url.extend(self.get_round_url(soup))

        self.logger.info('round_url:{}'.format(round_url))

        # レース結果の抽出と整形を行い、データフレームに追記保存
        races_info = pd.DataFrame()
        logging.info('レース情報の取得を開始します')

        for url in tqdm(round_url):
            soup = self.preparing_raceresult_data(url)
            try:
                races_info = pd.concat([races_info, self.get_race_result(soup)])
            except BaseException as err:
                self.logger.exception('Raise Exception:{}'.format(err))
                self.logger.info('レース結果の取得に失敗しました。')

        if races_info.empty:
            self.logger.info('取得可能なレース結果がありません。')
            return

        # レース結果をCSVファイルに保存
        races_info = races_info.reset_index(drop=True)
        real_result_csv_file = f'{self.race_results_path}/real_result_{self.location}_{today_yyyymmdd}.csv'
        races_info.to_csv(real_result_csv_file, encoding='cp932', index=False, errors='ignore')

        # 予測結果を読み込んでraces_infoと結合し、CSVファイルに保存
        predict_result_file = f'{self.predicted_results_path}_{self.location}_{today_yyyymmdd}.csv'
        df = pd.read_csv(predict_result_file, header=None, encoding='cp932')
        df = pd.concat([df, races_info], axis=1)
        predict_real_result_csv_file = f'{self.race_results_path}/predict_real_result_{self.location}_{today_yyyymmdd}.csv'
        df.to_csv(predict_real_result_csv_file, encoding='cp932', index=False, errors='ignore')

        run_time = time.time() - start_time
        self.logger.info('{0} {1} 実行時間:{2:.2f}秒'.format(os.path.basename(__file__), self.location, run_time))


class JraRaceDataFetcher:
    """
    JRAレースデータを取得するためのクラス。

    メソッド
    -------
    __init__(self, config: configparser.ConfigParser, logger: logging.Logger):
        JraRaceDataFetcherクラスのインスタンスを初期化する。

    setup_directory(self):
        ディレクトリのセットアップを行い、既存のCSVファイルを削除する。

    navigate_to_jra_page(self, access_type: str) -> BeautifulSoup:
        指定されたタイプのページにナビゲートし、BeautifulSoupオブジェクトを返す。

    extract_location_info(self, soup: BeautifulSoup) -> list:
        BeautifulSoupオブジェクトからレース開催地の情報を抽出する。

    fetch_location_urls(self, soup: BeautifulSoup) -> list:
        BeautifulSoupオブジェクトからレース開催地のURLリストを取得する。

    preparing_raceinfo_data(self, url: str) -> BeautifulSoup:
        指定されたURLのレース情報ページを取得し、不要なタグを削除したBeautifulSoupオブジェクトを返す。

    extract_round_urls(self, soup: BeautifulSoup) -> list:
        BeautifulSoupオブジェクトからラウンドのURLリストを取得する。

    get_race_times(self, soup: BeautifulSoup, url: str) -> pd.DataFrame:
        BeautifulSoupオブジェクトからレースの時間情報を抽出し、DataFrameとして返す。

    save_cell_time(self, cell_time_info: pd.DataFrame, loc_name: str):
        レースの時間情報をCSVファイルとして保存する。

    get_URL_of_each_racetrack_round(self) -> tuple:
        各レーストラックのラウンドURL、開催地リスト、開催地URLリストを取得する。

    extract_and_format_of_race_info(self, round_url_list: list) -> pd.DataFrame:
        ラウンドURLリストからレース情報を抽出し、DataFrameとして返す。

    save_location_name(self, location_list: list):
        開催地名をファイルに保存する。

    main(self):
        全体の処理を実行し、ディレクトリのセットアップ、ラウンドURLの取得、レース情報の抽出、時間情報の保存を行う。
    """
    def __init__(self, config, logger):
        """
        JraRaceDataFetcherクラスのインスタンスを初期化する。

        :param configparser.ConfigParser config: 設定情報を含むConfigParserオブジェクト
        :param logging.Logger logger: ロギング用ロガーインスタンス
        """
        self.loc_dict = dict(config['loc_romaji_jp'])
        self.cell_time_path = config['settings']['cell_time_path']
        self.logger = logger
        self.error_count = 0
        self.max_errors = 3
        self.browser = BrowserSetup.init_browser()

    def setup_directory(self):
        """
        ディレクトリのセットアップを行い、既存のCSVファイルを削除する。
        """
        os.makedirs(self.cell_time_path, exist_ok=True)
        for i in glob.glob(self.cell_time_path + '*.csv'):
            os.remove(i)

    def navigate_to_jra_page(self, access_type):
        """
        JRAの出馬表またはレース結果へのナビゲーションし、BeautifulSoupオブジェクトを返す。
        1. WebDriverをセットアップして、JRAのトップページを開く。
        2. 指定されたページタイプ(出馬表またはレース結果)に応じて、該当ページにナビゲートする。
        3. ナビゲーション後のページを取得し、BeautifulSoupオブジェクトを生成して返す。

        :param str access_type: アクセスするページの種類。'entries'(出馬表)または 'results'(レース結果)のいずれか。
        :return: ナビゲート後のページのBeautifulSoupオブジェクト
        :rtype: bs4.BeautifulSoup
        """
        return NavigationHelper.navigate_to_jra_page(self.browser, self.logger, access_type, self.max_errors)

    def extract_location_info(self, soup):
        """
        BeautifulSoupオブジェクトからレース開催地の情報を抽出する。
        サイト内上部にあるリンク、例[1回中山5日][1回中京5日][1回小倉1日]から開催情報を取得する。

        :param BeautifulSoup soup: レース情報ページのBeautifulSoupオブジェクト
        :return: レース開催地のリスト
        :rtype: list
        """
        locations = soup.find('div', class_='link_list multi div3 center mid narrow').text
        return re.sub(r'[0-9回日]', '', locations).split()

    def fetch_location_urls(self, soup):
        """
        BeautifulSoupオブジェクトからレース開催地のURLリストを取得する。
        [5回東京5日][5回阪神5日]などのURLを取得する。

        :param BeautifulSoup soup: レース情報ページのBeautifulSoupオブジェクト
        :return: レース開催地のURLリスト
        :rtype: list
        """
        base_url = 'https://www.jra.go.jp'
        return [urllib.parse.urljoin(base_url, link['href']) for link in
                soup.select('.link_list.multi.div3.center.mid.narrow a')]

    def preparing_raceinfo_data(self, url):
        """
        指定されたURLのレース情報ページを取得し、不要なタグを削除したBeautifulSoupオブジェクトを返す。
        ブリンカーを着用している馬は、他の馬より1つデータが多いため、その後の処理でエラーが出る。
        ブリンカー着用のタグを削除してデータを整える。

        :param str url: レース情報ページのURL
        :return: レース情報ページの内容を解析したBeautifulSoupオブジェクト
        :rtype: BeautifulSoup
        """
        soup = RequestHelper.get_soup(url)

        # ブリンカー着用のクラスを削除
        for tag in soup.find_all('span', class_='horse_icon blinker'):
            tag.decompose()

        # ブリンカー着用のタグを削除しても、前後に空白が存在する場合があるので、空白を削除
        for tag in soup.find_all('td', class_='num'):
            tag.string = tag.get_text(strip=True)
        return soup

    def extract_round_urls(self, soup):
        """
        BeautifulSoupオブジェクトからラウンドのURLリストを取得する。
        ラウンドへのリンクはページの上部と下部の2箇所に配置されている。
        上部のリンクを用いてラウンドのURLを取得する。

        :param BeautifulSoup soup: レース情報ページのBeautifulSoupオブジェクト
        :return: ラウンドのURLリスト
        :rtype: list
        """
        base_url = 'https://www.jra.go.jp'
        rounds_info = soup.find('ul', attrs={'class': 'nav race-num mt15'})
        return [urllib.parse.urljoin(base_url, a['href']) for a in rounds_info.find_all('a')]

    def get_race_times(self, soup, url):
        """
        BeautifulSoupオブジェクトからレースの時間情報を抽出し、DataFrameとして返す。
        1. 指定されたBeautifulSoupオブジェクトから開催場所と日付の情報を抽出する。
        2. 発走時刻を抽出し、整形する。
        3. 開催場所、日付、発走時刻、レース情報のURLを含むデータフレームを作成する。

        :param BeautifulSoup soup: レース情報ページのBeautifulSoupオブジェクト
        :param str url: レース情報ページのURL
        :return: レースの時間情報を含むDataFrame
        :rtype: pd.DataFrame
        """
        race_base_info = soup.find('div', class_='cell date').text.split()
        race_location, race_date = race_base_info[1].replace('日', '日目'), race_base_info[0].split('(')[0]
        cell_time = (soup.find('div', class_='cell time').text.replace('発走時刻:', '').
                     replace('\n', '').replace('分', '').replace(' ', '').
                     replace('時', ':'))
        cell_time_info = pd.DataFrame([[race_location, race_date, cell_time, url]],
                                      columns=['location', 'race_date', 'cell_time', 'race_info_url'])
        return cell_time_info

    def save_cell_time(self, cell_time_info, loc_name):
        """
        レースの時間情報をCSVファイルとして保存する。
        1. 指定された開催場所の発走時刻情報をフィルタリングする。
        2. フィルタリングされた発走時刻情報をCSVファイルとして保存する。

        :param pd.DataFrame cell_time_info: レースの時間情報を含むDataFrame
        :param str loc_name: レース開催地の名称
        """
        csv_file_path = os.path.join(self.cell_time_path, f'cell_time_info_{loc_name}.csv')
        filtered_info = cell_time_info[cell_time_info['location'].str.contains(loc_name)][
            ['race_date', 'cell_time', 'race_info_url']]
        filtered_info.to_csv(csv_file_path, encoding='cp932', index=False, errors='ignore')

    def get_URL_of_each_racetrack_round(self):
        """
        各レーストラックのラウンドURL、開催地リスト、開催地URLリストを取得する。
        1. 出走表ページにナビゲートし、開催場所の情報とそのURLを取得する。
        2. 各開催場所ごとのラウンドのURLを取得する。

        :return: ラウンドURLリスト、開催地リスト、開催地URLリストのタプル
        :rtype: tuple(list, list, list)
        """
        soup = self.navigate_to_jra_page('entries')
        location_list = self.extract_location_info(soup)
        location_url_list = self.fetch_location_urls(soup)
        round_url_list = []

        for url in location_url_list:
            soup = RequestHelper.get_soup(url)
            round_url_list.extend(self.extract_round_urls(soup))
        return round_url_list, location_list, location_url_list

    def extract_and_format_of_race_info(self, round_url_list):
        """
        ラウンドURLリストからレース情報を抽出し、DataFrameとして返す。
        1. 指定されたラウンドURLリストを順に処理し、各URLからレース情報を抽出する。
        2. 抽出した情報を整形し、発走時刻情報をデータフレームに追加する。

        :param list round_url_list: ラウンドのURLリスト
        :return: レース情報を含むDataFrame
        :rtype: pd.DataFrame
        """
        cell_time_info = pd.DataFrame()
        self.logger.info('発走時刻の取得を開始します')
        for url in tqdm(round_url_list):
            soup = self.preparing_raceinfo_data(url)
            try:
                cell_time_info = pd.concat([cell_time_info, self.get_race_times(soup, url)])
            except BaseException as err:
                self.logger.info(f'予測に必要なデータを取得できません:{err}')
        return cell_time_info

    def save_location_name(self, location_list):
        """
        開催地名をファイルに保存する。
        1. 開催場所のリストからローマ字表記のリストを作成する。
        2. ローマ字表記のリストをpickleファイルとして保存する。

        :param list location_list: レース開催地のリスト
        """
        # 開催場所(中山、東京など)のloc_dictのvalue(nakayama,tokyoなど)を取得する
        loc_list = []
        for location in location_list:
            key = [k for k, v in self.loc_dict.items() if v == location][0]
            loc_list.append(key)

        with open(self.cell_time_path + 'location_list.pkl', 'wb') as f:
            pickle.dump(loc_list, f)

    def main(self):
        """
        全体の処理を実行し、ディレクトリのセットアップ、ラウンドURLの取得、レース情報の抽出、時間情報の保存を行う。
        """
        self.setup_directory()
        round_url_list, location_list, location_url_list = self.get_URL_of_each_racetrack_round()
        cell_time_info = self.extract_and_format_of_race_info(round_url_list)

        for location in location_list:
            self.save_cell_time(cell_time_info, location)

        self.save_location_name(location_list)


def run_race_prediction(location, logger):
    """
    指定されたロケーションのレース予測処理を実行する。
    この関数は、指定されたロケーションに対してRacePredictionProcessingクラスのインスタンスを作成し、予測処理を実行する。

    :param str location:
    :param logging.Logger logger: ロギング用ロガーインスタンス
    """
    race_prediction = RacePredictionProcessing(location, logger)
    race_prediction.run_prediction()


class RacePredictionExecutor:
    """
    レース予測の実行を管理するクラス。

    メソッド
    -------
    __init__(self, config_file='./config.ini'):
        RacePredictionExecutorクラスのインスタンスを初期化する。

    load_race_locations(self):
        レース開催地のリストを読み込む。

    prepare_textfile_to_save_message(self, today_yyyymmdd):
        メッセージを保存するためのテキストファイルを準備する。

    save_win_lose_ratio(self, location, csv_file):
        勝敗比率を計算し、CSVファイルに保存する。

    twittermessage_after_final_race(self, location, count, round_list_size):
        最終レース後にTwitterメッセージを作成する。

    tweet_after_final_race(self):
        最終レース後にTwitterにメッセージを投稿する。

    main(self):
        全体の処理を実行し、レース予測を管理する。
    """
    def __init__(self, config_file='./config.ini'):
        """
        RacePredictionExecutorクラスのインスタンスを初期化する。

        :param str config_file: 設定ファイルのパス(デフォルトは'./config.ini')
        """
        self.config = configparser.ConfigParser(interpolation=None)
        self.config.read(config_file, 'UTF-8')
        self.log_config_path = self.config['settings']['log_conf']
        self.race_locations_path = self.config['settings']['race_locations_path']
        self.predicted_path = self.config['settings']['predicted_path']
        self.wait_time_seconds = int(self.config['settings']['wait_time_seconds'])
        self.logger = LoggerSetup.setup_logger(self.log_config_path)
        self.race_locations = self.load_race_locations()
        self.message_path = self.config['twitter']['message_path']
        self.message_file = os.path.join(self.message_path, 'twitter_message_win_lose.txt')
        self.twitter_client = TwitterClient(self.config)

    def load_race_locations(self):
        """
        レース開催地のリストを読み込む。

        :return: レース開催地のリスト
        :rtype: list
        """
        with open(self.race_locations_path, 'rb') as f:
            race_locations = pickle.load(f)
        self.logger.info('race_locations:{}'.format(' '.join(map(str, race_locations))))
        return race_locations

    def prepare_textfile_to_save_message(self, today_yyyymmdd):
        """
        メッセージを保存するためのテキストファイルを準備する。

        :param str today_yyyymmdd: 今日の日付(フォーマット:YYYYMMDD)
        """
        if os.path.isfile(self.message_file):
            os.remove(self.message_file)
        with open(self.message_file, mode='a') as f:
            f.write(f'■{today_yyyymmdd} 予測結果のまとめ\n')

    def save_win_lose_ratio(self, location, csv_file):
        """
        複勝の勝敗比率を計算し、CSVファイルに保存する。

        :param str location: レース開催地
        :param str csv_file: CSVファイルのパス
        :return: 勝利数とラウンド数のタプル
        :rtype: tuple
        """
        df = pd.read_csv(csv_file, encoding='cp932')
        df = df.rename(columns={'0': 'predict_rank', '1': 'predict_time', '2': 'predict_horse',
                                '3': 'odds', '4': 'popular', '5': 'distance', '6': 'date',
                                '7': 'race_name', '8': 'location', '9': 'round', 'horse': 'real_horse'})
        round_list = df['round'].unique()
        num_wins = 0
        df_rate = pd.DataFrame()
        date, race_name, type_horsetickets, loc, win_lose = [], [], [], [], []

        # ラウンドごとの予測と実際の馬名を比較する
        # setでリストを比較し、一致した数を取得し、一致した数が1以上だった場合、複勝は的中、とする
        for round in round_list:
            df_round = df.query('round == @round')
            predict_horse_list = list(df_round['predict_horse'])
            real_horse_list = list(df_round['real_horse'])
            match = set(predict_horse_list) & set(real_horse_list)

            win_lose.append('アタリ' if len(match) >= 1 else 'ハズレ')
            num_wins += len(match) >= 1
            date.append(df_round.iloc[0, 6])
            race_name.append(df_round.iloc[0, 7])
            type_horsetickets.append('複勝')
            loc.append(location)

        df_rate['date'] = date
        df_rate['location'] = loc
        df_rate['race_name'] = race_name
        df_rate['type_horsetickets'] = type_horsetickets
        df_rate['win_lose'] = win_lose
        df_rate.to_csv('test.csv', encoding='cp932', mode='a', index=False, header=False, errors='ignore')

        return num_wins, round_list.size

    def twittermessage_after_final_race(self, location, count, round_list_size):
        """
        最終レース後にTwitterメッセージを作成する。

        :param str location: レース開催地
        :param int count: 勝利数
        :param int round_list_size: ラウンド数
        """
        rate = count / round_list_size
        with open(self.message_file, mode='a') as f:
            f.write(f'{location} 複勝の的中率:{rate:.0%}\n')

    def tweet_after_final_race(self):
        """
        最終レース後にTwitterにメッセージを投稿する。
        """
        if os.path.isfile(self.message_file):
            with open(self.message_file) as f:
                message = f.readlines()
            message.append('#競馬 #予測 #AI\n競馬AIの作成過程\n')
            message.append('https://relaxing-living-life.com/')
            message = ''.join(map(str, message))
            self.twitter_client.create_tweet(message)

    def main(self):
        """
        全体の処理を実行し、レース予測を管理する。
        """
        jra_fetcher = JraRaceDataFetcher(self.config, self.logger)
        jra_fetcher.main()

        # マルチプロセスの同時実行数を設定する。最大の同時開催数となるので値は、3から変更は不要。
        pool_size = 3

        # レース開催場所ごとにインスタンスを作成し、非同期タスクで予測を実行する。
        # 非同期タスクが全て完了するまで待機する。
        with Pool(pool_size) as pool:
            results = [pool.apply_async(run_race_prediction, args=(location, self.logger,)) for location in self.race_locations]
            for result in results:
                result.wait()

        # 最終レース後にすべてのレース結果を取得して予測結果と比較し、複勝の的中率を計算してX(twitter)にレース結果のまとめとして投稿する
        # 最後の予測からwait_time_seconds後に処理を実行する
        for i in range(self.wait_time_seconds, 0, -1):
            print(f'\r\033[K#レース結果まとめの実行まで残り {i} 秒 ', end='')
            time.sleep(1)

        # 最終レース後にレース結果のまとめを投稿する
        now = datetime.datetime.now()
        today_yyyymmdd = now.strftime('%Y%m%d')
        self.prepare_textfile_to_save_message(today_yyyymmdd)

        for location in self.race_locations:
            scraper = RaceResultsScraper(location, self.config, self.logger)
            scraper.main()
            csv_path = os.path.join(self.predicted_path, 'today_raceresults',
                                    f'predict_real_result_{location}_{today_yyyymmdd}.csv')
            num_wins, round_list_size = self.save_win_lose_ratio(location, csv_path)
            self.twittermessage_after_final_race(location, num_wins, round_list_size)

        self.tweet_after_final_race()


class RacePrediction:
    """
    レース予測を実行するクラス。

    メソッド
    -------
    __init__(self, location, csv_file, config, logger):
        RacePredictionクラスのインスタンスを初期化する。

    load_race_info(self):
        レース情報をCSVファイルから読み込む。

    encode_race_info(self, df_race_info_add_predict):
        レース情報をエンコードし、DMATRIX形式に変換する。

    predict_time_from_race_info(self, dmatrix_race_info, df_race_info_add_predict):
        エンコードされたレース情報から予測時間を算出する。

    labelencode_race_location_round(self, df):
        レース名、開催地、ラウンドをラベルエンコードする。

    predict_ranking(self, df_race_info):
        レース情報を基に順位を予測する。

    set_path_for_twitter_message(self, location):
        Twitterメッセージの保存パスを設定する。

    assign_prediction_ranks(self, df):
        予測時間に基づいて順位を割り当てる。

    save_twitter_message(self, twitter_message_path, df_top3):
        上位3位の予測結果をTwitterメッセージとして保存する。

    predict_top3_ranks(self, race_encoded_unique_list, df, twitter_message_path):
        上位3位の予測ランクを生成し、保存する。

    remove_unnecessary_columns(self, df_predict_eval):
        不要な列を削除する。

    sort_columns(self, df_predict_eval):
        列を特定の順序に並べ替える。

    process_race_predictions(self, df_race_info):
        レース情報を処理して予測を行う。

    append_predicted_top3_ranks_to_csv(self, df_predict_eval):
        予測結果の上位3位をCSVファイルに追加する。

    run(self):
        レース予測を実行する。
    """
    def __init__(self, location, csv_file, config, logger):
        """
        RacePredictionクラスのインスタンスを初期化する。

        :param str location: レース開催地
        :param str csv_file: レース情報を含むCSVファイルのパス
        :param configparser.ConfigParser config: 設定情報を含むConfigParserオブジェクト
        :param logging.Logger logger: ロギング用ロガーインスタンス
        """
        self.location = location
        self.csv_file = csv_file
        self.config = config
        self.logger = logger
        self.model_dir = self.config['settings']['model_path']
        self.model_path = os.path.join(self.model_dir, 'gbm.pkl')
        self.enc_model_path = os.path.join(self.model_dir, 'oe_x.pkl')
        self.ranking_model = joblib.load(self.model_path)
        self.encoder_model = joblib.load(self.enc_model_path)
        self.label_encoder = LabelEncoder()
        self.predicted_path = self.config['settings']['predicted_path']
        os.makedirs(self.predicted_path, exist_ok=True)
        self.twitter_message_dir = self.config['twitter']['message_path']
        os.makedirs(self.twitter_message_dir, exist_ok=True)

    def load_race_info(self):
        """
        レース情報をCSVファイルから読み込む。

        :return: レース情報を含むDataFrame
        :rtype: pd.DataFrame
        """
        df_race_info = pd.read_csv(self.csv_file, encoding='cp932')
        df_race_info['date'] = pd.to_datetime(df_race_info['date'], format='%Y年%m月%d日')
        return df_race_info

    def encode_race_info(self, df_race_info_add_predict):
        """
        レース情報をエンコードし、DMATRIX形式に変換する。

        :param pd.DataFrame df_race_info_add_predict: 予測用のレース情報を含むDataFrame
        :return: エンコードされたレース情報のDMATRIX
        :rtype: xgb.DMatrix
        """
        encoded_race_info = self.encoder_model.fit_transform(df_race_info_add_predict)
        encoded_race_info = pd.DataFrame(encoded_race_info)
        encoded_race_info.columns = list(df_race_info_add_predict.columns.values)
        dmatrix_race_info = xgb.DMatrix(encoded_race_info)
        return dmatrix_race_info

    def predict_time_from_race_info(self, dmatrix_race_info, df_race_info_add_predict):
        """
        エンコードされたレース情報から予測時間を算出する。

        :param xgb.DMatrix dmatrix_race_info: エンコードされたレース情報のDMATRIX
        :param pd.DataFrame df_race_info_add_predict: 予測用のレース情報を含むDataFrame
        :return: 予測時間を含むDataFrame
        :rtype: pd.DataFrame
        """
        predict_time = self.ranking_model.predict(dmatrix_race_info)
        df_race_info_add_predict['predict_time'] = predict_time
        return df_race_info_add_predict

    def labelencode_race_location_round(self, df):
        """
        レース名、開催地、ラウンドをラベルエンコードする。

        :param pd.DataFrame df: レース情報を含むDataFrame
        :return: ラベルエンコードされたレース情報を含むDataFrame
        :rtype: pd.DataFrame
        """
        df['race_name_encoded'] = self.label_encoder.fit_transform(df['race_name'].values)
        df['location_encoded'] = self.label_encoder.fit_transform(df['location'].values)
        df['round_encoded'] = self.label_encoder.fit_transform(df['round'].values)
        return df

    def predict_ranking(self, df_race_info):
        """
        レース情報を基に順位を予測する。

        :param pd.DataFrame df_race_info: レース情報を含むDataFrame
        :return: 予測された順位を含むDataFrame
        :rtype: pd.DataFrame
        """
        dmatrix_race_info = self.encode_race_info(df_race_info)
        df_race_info = self.predict_time_from_race_info(dmatrix_race_info, df_race_info)
        return self.labelencode_race_location_round(df_race_info)

    def set_path_for_twitter_message(self, location):
        """
        Twitterメッセージの保存パスを設定する。

        :param str location: レース開催地
        :return: Twitterメッセージの保存パス
        :rtype: str
        """
        twitter_message_path = os.path.join(self.twitter_message_dir, f'twitter_message_{location}.txt')
        if os.path.isfile(twitter_message_path):
            os.remove(twitter_message_path)
        return twitter_message_path

    def assign_prediction_ranks(self, df):
        """
        予測時間に基づいて順位を割り当てる。

        :param pd.DataFrame df: 予測時間を含むレース情報
        :return: 順位が割り当てられたレース情報
        :rtype: pd.DataFrame
        """
        df_sort_predict = df.sort_values('predict_time')
        df_sort_predict['predict_rank'] = range(1, len(df_sort_predict.index) + 1)
        return df_sort_predict

    def save_twitter_message(self, twitter_message_path, df_top3):
        """
        上位3位の予測結果をTwitterメッセージとして保存する。

        :param str twitter_message_path: Twitterメッセージの保存パス
        :param pd.DataFrame df_top3: 上位3位の予測結果を含むDataFrame
        """
        with open(twitter_message_path, mode='a') as f:
            f.write('■' + str(df_top3.iloc[0, 15]) + ' ' + str(df_top3.iloc[0, 14]) + ' ' + str(df_top3.iloc[0, 16]) + '\n')
            for rank in range(3):
                f.write(f'{rank + 1}. {df_top3.iloc[rank, 0]}\n')

    def predict_top3_ranks(self, race_encoded_unique_list, df, twitter_message_path):
        """
        上位3位の予測ランクを生成し、保存する。

        :param list race_encoded_unique_list: レース名のエンコードリスト
        :param pd.DataFrame df: レース情報を含むDataFrame
        :param str twitter_message_path: Twitterメッセージの保存パス
        :return: 上位3位の予測ランクを含むDataFrame
        :rtype: pd.DataFrame
        """
        df_predict_eval = pd.DataFrame()
        location_encoded_list = df['location_encoded'].unique()
        round_encoded_list = df['round_encoded'].unique()

        for i, j, k in itertools.product(race_encoded_unique_list, location_encoded_list, round_encoded_list):
            df_race = df[df['race_name_encoded'] == i]
            df_race_location = df_race[df_race['location_encoded'] == j]
            df_race_location_round = df_race_location[df_race_location['round_encoded'] == k]

            if df_race_location_round.empty:
                continue

            df_race_location_round_assign_rank = self.assign_prediction_ranks(df_race_location_round)
            df_top3 = df_race_location_round_assign_rank[df_race_location_round_assign_rank['predict_rank'] < 4]
            df_predict_eval = pd.concat([df_predict_eval, df_top3])

            if twitter_message_path != 'no_message_needed':
                self.save_twitter_message(twitter_message_path, df_top3)

        return df_predict_eval

    def remove_unnecessary_columns(self, df_predict_eval):
        """
        不要な列を削除する。

        :param pd.DataFrame df_predict_eval: レース情報を含むDataFrame
        :return: 不要な列が削除されたDataFrame
        :rtype: pd.DataFrame
        """
        unnecessary_columns = ['race_name_encoded', 'location_encoded', 'round_encoded', 'age', 'rider_weight',
                               'rider', 'horse_weight', 'weather', 'ground', 'condition']
        return df_predict_eval.drop(unnecessary_columns, axis=1)

    def sort_columns(self, df_predict_eval):
        """
        列を特定の順序に並べ替える。

        :param pd.DataFrame df_predict_eval: レース情報を含むDataFrame
        :return: 並べ替えられたDataFrame
        :rtype: pd.DataFrame
        """
        sorted_columns = ['predict_rank', 'predict_time', 'horse', 'odds', 'popular',
                          'distance', 'date', 'race_name', 'location', 'round']
        return df_predict_eval.reindex(columns=sorted_columns)

    def process_race_predictions(self, df_race_info):
        """
        レース情報を処理して予測を行う。

        :param pd.DataFrame df_race_info: レース情報を含むDataFrame
        :return: 予測結果を含むDataFrame
        :rtype: pd.DataFrame
        """
        df_race_info = self.predict_ranking(df_race_info).dropna()
        race_encoded_unique_list = df_race_info['race_name_encoded'].unique()
        self.logger.info(f'race_encoded_unique_list: {" ".join(map(str, race_encoded_unique_list))}')

        twitter_message_path = self.set_path_for_twitter_message(self.location)
        df_predict_eval = self.predict_top3_ranks(race_encoded_unique_list, df_race_info, twitter_message_path)
        df_predict_eval = self.remove_unnecessary_columns(df_predict_eval)
        return self.sort_columns(df_predict_eval)

    def append_predicted_top3_ranks_to_csv(self, df_predict_eval):
        """
        予測結果の上位3位をCSVファイルに追加する。

        :param pd.DataFrame df_predict_eval: 予測結果を含むDataFrame
        """
        timestamp_today_predict_result = datetime.datetime.now().strftime('%Y%m%d')
        today_raceresult_file = os.path.join(self.predicted_path, f'predict_result_{self.location}_{timestamp_today_predict_result}.csv')
        df_predict_eval[:3].to_csv(today_raceresult_file, mode='a', encoding='cp932', index=False, header=False, errors='ignore')

    def run(self):
        """
        レース予測を実行する。
        """
        start_time = time.time()
        self.logger.info(f'start_time: {start_time}')

        df_race_info = self.load_race_info()
        df_race_info = self.process_race_predictions(df_race_info)
        self.append_predicted_top3_ranks_to_csv(df_race_info)

        run_time = time.time() - start_time
        self.logger.info(f'{os.path.basename(__file__)} {self.location} 実行時間: {run_time:.2f}秒')


class RacePredictionProcessing:
    """
    レース予測処理を実行するクラス。

    メソッド
    -------
    __init__(self, location, logger):
        RacePredictionProcessingクラスのインスタンスを初期化する。

    load_config(self):
        設定ファイルを読み込む。

    load_today_cell_time_info(self):
        本日のレース発走時刻の情報を読み込む。

    preparing_raceinfo_data(self, url):
        指定されたURLのレース情報を整形する。

    extract_list_from_df(self, df, row_index, pattern, extract_method=None, default_value='', post_process=None):
        DataFrameの特定の行からリストを抽出する。

    extract_text(self, soup, tag, attrs, extract_array_index=None, next_elem=None, split_text=None,
                 split_array_index=None, replace=None, regex=None):
        BeautifulSoupオブジェクトから特定のテキストを抽出する。

    get_race_info(self, soup):
        BeautifulSoupオブジェクトからレース情報を取得する。

    get_weather_condition_info(self):
        天気と馬場の情報を取得する。

    set_weather_condition(self, df):
        天気と馬場の情報をDataFrameに設定する。

    reindex_df(self, df):
        DataFrameの列を並び替える。

    save_race_info(self, races_info):
        レース情報を保存する。

    run_prediction_processing(self, race_info):
        レース情報を取得し、予測処理を実行する。

    tweet_predicted_rankings(self, message_file):
        予測ランクをTwitterに投稿する。

    tweet(self, message):
        メッセージをTwitterに投稿する。

    process_race_row(self, row):
        各レース行に対する処理を実行する。

    run_prediction(self):
        レース予測を実行する。
    """
    def __init__(self, location, logger):
        """
        RacePredictionProcessingクラスのインスタンスを初期化する。

        :param str location: レース開催地
        :param logging.Logger logger: ロギング用ロガーインスタンス
        """
        self.config_ini = r'./config.ini'
        self.location = location
        # self.time_buffer = 1600
        self.config = self.load_config()
        self.time_buffer = int(self.config['settings']['time_buffer'])
        self.twitter_message_dir = self.config['twitter']['message_path']
        self.location_jp = dict(self.config['loc_romaji_jp'])[location]
        self.log_config_path = self.config['settings']['log_conf']
        self.logger = LoggerSetup.setup_logger(self.log_config_path)
        self.location_cell_time_info_csv = dict(self.config['location_cell_time_info_csv'])
        self.location_races_info_csv = dict(self.config['location_races_info_csv'])
        self.race_info_path = self.config['settings']['race_info_path']
        self.today_cell_time_info = self.load_today_cell_time_info()
        self.weather = ''
        self.shiba_condition = ''
        self.dart_condition = ''
        self.twitter_client = TwitterClient(self.config)

    def load_config(self):
        """
        設定ファイルを読み込む。

        :return: 設定情報を含むConfigParserオブジェクト
        :rtype: configparser.ConfigParser
        """
        config = configparser.ConfigParser(interpolation=None)
        config.read(self.config_ini, 'UTF-8')
        return config

    def load_today_cell_time_info(self):
        """
        本日のレース発走時刻の情報を読み込む。

        :return: レース発走時刻情報を含むDataFrame
        :rtype: pd.DataFrame
        """
        cell_time_info = pd.read_csv(self.config['location_cell_time_info_csv'][self.location], encoding='CP932')
        cell_time_info['date_time'] = cell_time_info['race_date'].str.cat(cell_time_info['cell_time'], sep=' ')
        today = datetime.datetime.now().strftime('%Y年%#m月%#d日')
        return cell_time_info[cell_time_info['race_date'] == today]

    def preparing_raceinfo_data(self, url):
        """
        レース情報のHTMLデータを取得し、パースする
        指定されたURLからレース情報のHTMLデータを取得し、BeautifulSoupを使用してパースする
        また、HTML内の特定の要素(ブリンカー着用のクラスおよびタグ)を削除し、不要な改行を取り除く
        :param str url: レース情報のURL
        :return: パースされたHTMLデータ
        :rtype: BeautifulSoup
        """
        soup = RequestHelper.get_soup(url)
        for tag in soup.find_all('span', class_='horse_icon blinker'):
            tag.decompose()
        for tag in soup.find_all('td', class_='num'):
            tag.string = tag.get_text(strip=True)
        return soup

    def extract_list_from_df(self, df, row_index, pattern, extract_method=None, default_value='', post_process=None):
        """
        DataFrameの特定の行からリストを抽出する。
        指定されたデータフレームの特定の行から正規表現パターンに一致する要素を抽出し、リストとして返す
        オプションで、抽出方法や抽出後の処理、デフォルト値を指定することができる

        :param pd.DataFrame df: データを含むDataFrame
        :param int row_index: 抽出する行のインデックス
        :param str pattern: 抽出に使用する正規表現パターン
        :param function extract_method: 抽出方法(デフォルトはNone)
        :param str default_value: デフォルト値(デフォルトは'')
        :param function post_process: 抽出後の処理(デフォルトはNone)
        :return: 抽出されたリスト
        :rtype: list
        """
        extracted_list = []
        for item in df.iloc[row_index]:
            item = item.replace(' ', '')
            match = re.search(pattern, item)
            if match:
                result = match.group(1) if extract_method is None else extract_method(match.group(1))
            else:
                result = default_value
            if post_process:
                result = post_process(result)
            extracted_list.append(result)
        return extracted_list

    def extract_text(self, soup, tag, attrs, extract_array_index=None, next_elem=None, split_text=None,
                     split_array_index=None, replace=None, regex=None):
        """
        BeautifulSoupオブジェクトから特定のテキストを抽出する。
        オプションで、次の要素の属性値を抽出したり、テキストを分割して特定の部分を取り出したり、
        置換や正規表現による変換を行うことができる

        :param BeautifulSoup soup: 解析対象のBeautifulSoupオブジェクト
        :param str tag: 抽出対象のタグ名
        :param dict attrs: 抽出対象の属性辞書
        :param int extract_array_index: 抽出対象のインデックス(デフォルトはNone)
        :param str next_elem: 抽出対象の次の要素(デフォルトはNone)
        :param str split_text: 分割テキスト(デフォルトはNone)
        :param int split_array_index: 分割後のインデックス(デフォルトはNone)
        :param tuple replace: 置換テキスト(デフォルトはNone)
        :param tuple regex: 正規表現パターン(デフォルトはNone)
        :return: 抽出されたテキスト
        :rtype: str
        """
        elements = soup.find_all(tag, attrs=attrs)
        if next_elem is not None:
            element = elements[extract_array_index].next_element.attrs[next_elem]
        else:
            element = elements[extract_array_index]
        if split_array_index is not None:
            element = element.text.split(split_text)[split_array_index]
        if replace is not None:
            old, new = replace
            element = element.replace(old, new)
        if regex is not None:
            pattern, repl = regex
            element = re.sub(pattern, repl, element.text)
        if not isinstance(element, str):
            element = element.text
        return element

    def get_race_info(self, soup):
        """
        BeautifulSoupオブジェクトからレース情報を取得する。
        レース情報のHTMLデータから必要な情報を抽出し、データフレームに変換する。
        抽出項目
        - 人気,馬名,オッズ,馬体重,父馬,母馬,年齢,騎手体重,騎手名,ラウンド,レース日,開催場所,レース名,距離,コース(芝・ダート)

        :param BeautifulSoup soup: レース情報ページのBeautifulSoupオブジェクト
        :return: レース情報を含むDataFrame
        :rtype: pd.DataFrame
        """
        self.logger.info('レース情報を取得する')
        # race_info = pd.DataFrame()
        tbody = soup.find('tbody').find_all('tr')

        df = pd.DataFrame()
        for tb in tbody[0:]:
            tmp = tb.text.split('\n')
            contains_exclude = False
            for item in tmp[:19]:
                if '除外' in item or '取消' in item:
                    contains_exclude = True
                    break
            if contains_exclude:
                continue
            df_row = pd.Series(tmp)
            df = pd.concat([df, df_row], axis=1)

        popular_list = self.extract_list_from_df(df, 5, r'\((\d+)番人気\)', post_process=str)
        horse_list = self.extract_list_from_df(df, 5, r'([\u30A1-\u30FF]+)')
        odds_list = self.extract_list_from_df(df, 5, r'([\d.]+)\(', post_process=float)
        horse_weight_list = self.extract_list_from_df(df, 7, r'(\d+)kg', post_process=int, default_value=0)
        father_list = self.extract_list_from_df(df, 9, r':(.+)')
        mother_list = self.extract_list_from_df(df, 10, r':(.+)\(', default_value='不明')
        age_list = self.extract_list_from_df(df, 13, r'(.+)/', post_process=lambda x: x.replace('せん', 'セ'))
        rider_weight_list = self.extract_list_from_df(df, 15, r'(\d+\.?\d*)kg', post_process=str)
        rider_list = self.extract_list_from_df(df, 17, r'([^\s▲△☆◇★]+)', default_value='不明')
        round = self.extract_text(soup=soup, tag='div', attrs={'class': 'race_number'}, extract_array_index=0,
                                  next_elem='alt', replace=('レース', ' R'))
        race_date = self.extract_text(soup=soup, tag='div', attrs={'class': 'cell date'}, extract_array_index=0,
                                      split_text='(', split_array_index=0)
        race_location = self.extract_text(soup=soup, tag='div', attrs={'class': 'cell date'}, extract_array_index=0,
                                          split_text=' ', split_array_index=1, replace=('日', '日目'))
        race_name = self.extract_text(soup=soup, tag='span', attrs={'class': 'race_name'}, extract_array_index=0)
        race_distance = self.extract_text(soup=soup, tag='div', attrs={'class': 'cell course'}, extract_array_index=0,
                                          regex=(r'\D', ''))
        ground = self.extract_text(soup=soup, tag='div', attrs={'class': 'cell course'}, extract_array_index=0)
        ground = 'ダート' if 'ダート' in ground else '芝' if '芝' in ground else ground

        race_info = pd.DataFrame({
            'horse': horse_list,
            'father': father_list,
            'mother': mother_list,
            'age': age_list,
            'rider_weight': rider_weight_list,
            'rider': rider_list,
            'odds': odds_list,
            'popular': popular_list,
            'horse_weight': horse_weight_list,
            'distance': race_distance,
            'ground': ground,
            'date': race_date,
            'race_name': race_name,
            'location': race_location,
            'round': round
        })
        return race_info

    def get_weather_condition_info(self):
        """
        天気と馬場の情報を取得する。
        """
        self.logger.info('天気と馬場の情報を取得する')
        url = 'https://www.jra.go.jp/keiba/baba/'
        soup = RequestHelper.get_soup(url, 'lxml')
        class_cell_txt = soup.find('div', attrs={'class': 'cell txt'})
        self.weather = class_cell_txt.text.replace('天候:', '')
        self.logger.info(f'天気:{self.weather}')

        location_list = []
        location_url = []
        class_nav_tab = soup.find_all('div', attrs={'class': 'nav tab'})
        tag_a = class_nav_tab[0].find_all('a')

        for i in tag_a:
            location_list.append(i.text)
            url = urllib.parse.urljoin(url, i.get('href'))
            location_url.append(url)

        location_list = [s.replace('競馬場', '') for s in location_list]
        index_num = location_list.index(self.location_jp)
        url = location_url[index_num]
        soup = RequestHelper.get_soup(url, 'lxml')
        class_data_list_unit = soup.find_all('div', attrs={'class': 'data_list_unit'})

        for i in class_data_list_unit:
            tag_h4 = i.find_all('h4')
            if len(tag_h4) == 0:
                continue
            if tag_h4[0].text == '芝':
                tag_p = i.find_all('p')
                self.shiba_condition = tag_p[0].text
                self.logger.info(f'芝:{self.shiba_condition}')
            if tag_h4[0].text == 'ダート':
                tag_p = i.find_all('p')
                self.dart_condition = tag_p[0].text
                self.logger.info(f'ダート:{self.dart_condition}')

    def set_weather_condition(self, df):
        """
        天気と馬場の情報をDataFrameに設定する。
        指定されたレース情報のデータフレームに天気と馬場の情報を追加する。
        - `self.weather` の情報を天気列に設定する
        - 芝コースに関する情報を `self.shiba_condition` から取得し、該当するレコードの馬場状態に設定する
        - ダートコースに関する情報を `self.dart_condition` から取得し、該当するレコードの馬場状態に設定する

        :param pd.DataFrame df: レース情報を含むDataFrame
        :return: 天気と馬場の情報が設定されたDataFrame
        :rtype: pd.DataFrame
        """
        self.logger.info('天気と馬場の情報を設定する')
        df = df.reset_index(drop=True)
        df.loc[df['location'].str.contains(self.location_jp), 'weather'] = self.weather
        df.loc[(df['location'].str.contains(self.location_jp)) &
               (df['ground'].str.contains('芝')), 'condition'] = self.shiba_condition
        df.loc[(df['location'].str.contains(self.location_jp)) &
               (df['ground'].str.contains('ダート')), 'condition'] = self.dart_condition
        return df

    def reindex_df(self, df):
        """
        DataFrameの列を並び替える。

        :param pd.DataFrame df: レース情報を含むDataFrame
        :return: 並び替えられたDataFrame
        :rtype: pd.DataFrame
        """
        self.logger.info('列を並び替える')
        df = df.reindex(columns=['horse', 'father', 'mother', 'age', 'rider_weight', 'rider', 'odds', 'popular',
                                 'horse_weight', 'distance', 'weather', 'ground', 'condition', 'date', 'race_name',
                                 'location', 'round'])
        return df

    def save_race_info(self, races_info):
        """
        レース情報を保存する。
        以下の手順でレース情報を保存する。
        1. JRAの公式サイトから天気と馬場の情報を取得し、インスタンス変数に格納する
        2. 取得した天気と馬場の情報をレース情報のデータフレームに設定する
        3. データフレームの列を特定の順序に並び替える
        4. 保存先ディレクトリを作成し、開催場所に一致するレース情報をCSVファイルとして保存する

        :param pd.DataFrame races_info: レース情報のデータフレーム
        """
        self.get_weather_condition_info()
        races_info = self.set_weather_condition(races_info)
        races_info = self.reindex_df(races_info)
        os.makedirs(self.race_info_path, exist_ok=True)
        races_info_loc = races_info.loc[races_info['location'].str.contains(self.location_jp)]
        if len(races_info_loc) > 0:
            csv_file = f'{self.race_info_path}races_info_{self.location_jp}.csv'
            races_info_loc.to_csv(csv_file, encoding='cp932', index=False, errors='ignore')

    def run_prediction_processing(self, race_info):
        """
        レース予測処理を実行する。
        以下の手順でレース予測処理を実行する:
        1. 指定されたレース情報のURLからHTMLデータを取得し、パースする
        2. パースされたHTMLデータからレース情報を抽出する
        3. 抽出したレース情報を保存する
        4. レース予測を行うために `RacePrediction` クラスを初期化し、予測処理を実行する
        5. 予測結果をツイートするためのメッセージを作成し、ツイートする

        :param pd.Series race_info: レース情報のシリーズ
       """
        self.logger.info(f'{self.location_jp} {race_info["date_time"]} レースの情報を取得する')
        self.logger.info(f'取得先URL:{race_info["race_info_url"]}')
        soup = self.preparing_raceinfo_data(race_info['race_info_url'])

        races_info = pd.DataFrame()
        try:
            races_info = self.get_race_info(soup)
        except BaseException as err:
            self.logger.exception(f'Raise Exception:{err}')
            self.logger.info('予測に必要なデータが掲載されていません')

        self.save_race_info(races_info)
        race_prediction = RacePrediction(self.location, self.location_races_info_csv[self.location],
                                         self.config, self.logger)
        race_prediction.run()
        message_file = os.path.join(self.twitter_message_dir, f'twitter_message_{self.location}.txt')
        self.tweet_predicted_rankings(message_file)

    def tweet_predicted_rankings(self, message_file):
        """
        予測した順位をツイートする。
        指定されたファイルからツイートメッセージを読み込み、ツイートする。
        メッセージには予測したレースの上位3位の情報が含まれており、ハッシュタグとリンクを追加してツイートする。
        ファイルが存在しない場合は何も行わない。

        :param str message_file: ツイートメッセージが含まれるファイルのパス
        """
        if os.path.isfile(message_file):
            with open(message_file) as f:
                lines = f.readlines()
            lines.append('#競馬 #予測 #AI\n競馬AIの作成過程\n')
            lines.append('https://relaxing-living-life.com/')
            message = lines[0] + lines[1] + lines[2] + lines[3] + lines[-2] + lines[-1]
            self.logger.info(f'Xに投稿するメッセージ:{message}')
            self.tweet(message)

    def tweet(self, message):
        """
        ツイートを投稿する。
        指定したメッセージをツイートする。
        Tweepyを使用してTwitter APIにアクセスし、ツイートを投稿する。

        ツイートの投稿に成功した場合、その旨をログに記録し、失敗した場合はエラーメッセージをログに記録する
        :param str message: 投稿するツイートメッセージ
        """
        self.logger.info('Xに投稿する')
        try:
            self.twitter_client.create_tweet(message)
            self.logger.info('Tweet posted successfully')
        except tweepy.TweepyException as e:
            self.logger.error(f'Tweepy error occurred: {e}')
        except Exception as e:
            self.logger.error(f'Unexpected error occurred: {e}')

    def process_race_row(self, row):
        """
        各レース行に対する処理を実行する。
        以下の手順で各レース行に対する処理を実行する。
        1. 現在時刻を取得する
        2. レースの出走時刻を取得する
        3. 現在時刻が出走時刻を過ぎている場合、ログに記録し処理を終了する
        4. 出走時刻までの時間差を計算し、設定されたバッファ時間を差し引いた後の秒数を取得する
        5. その秒数だけ待機し、待機後に `run_prediction_processing` メソッドを呼び出して予測処理を実行する
        6. もし待機時間が0以下の場合は、すぐに予測処理を実行する

        :param pd.Series row: レース情報のシリーズ
       """
        self.logger.info('各レース行に対する処理。データの取得、レース情報の保存、順位予測、Xへの投稿、の処理を開始する')
        now = datetime.datetime.now()
        cell_date_time = dt.strptime(row['date_time'], '%Y年%m月%d日 %H:%M')
        if now > cell_date_time:
            self.logger.info(f'出走済み:{row['date_time']}')
            return

        diff_time = cell_date_time - now
        seconds_to_run_prediction = diff_time.seconds - self.time_buffer
        if seconds_to_run_prediction > 0:
            for wait_time in range(seconds_to_run_prediction, 0, -1):
                print(f"\r\033[K#次の実行まで残り {wait_time} 秒 {self.location} ", end="")
                time.sleep(1)
            self.run_prediction_processing(row)
        else:
            self.run_prediction_processing(row)

    def run_prediction(self):
        """
        レース予測処理を実行する。
        予測処理の起点になるメソッド
        当日の出走レースに対して予測処理を実行する。
        以下の手順で処理を行う:
        1. 処理開始時刻をログに記録する
        2. 当日の各レース情報を順次処理する
        3. 各レース情報に対して `process_race_row` メソッドを呼び出し、予測処理を実行する
        4. 処理終了時刻をログに記録し、実行時間を計測してログに記録する
       """
        start_time = time.time()
        self.logger.info(f'start_time:{start_time}')
        for _, row in self.today_cell_time_info.iterrows():
            self.process_race_row(row)
        run_time = time.time() - start_time
        self.logger.info(f'{os.path.basename(__file__)}, {self.location}, 実行時間:{run_time:.2f}秒')


if __name__ == '__main__':
    RacePredictionExecutor().main()

コメント

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