競馬予想AI netkeibaのスクレイピングでオッズ、人気も取得

R

競馬予想AIで買い目を弾き出しておきながら、当日の買い目、金額はオッズ、人気を見ながら手作業で決めていく…。それじゃぁ、片手落ちですよね。そこでスクレイピング。でも、rvestパッケージではnetkeibaのオッズや人気は、スクレイピングできないんです。ここでは、RSeleniumパッケージを使ってオッズや人気をスクレイピングする方法を紹介しています。

netkeibaからオッズ、人気データをスクレイピングしたい

過去データをもとに勝ち馬を予想するような競馬予想AIを作っても、それだけでは競馬には勝てない。最終的に買う馬券やその金額はレース当日のオッズや人気データも考慮に入れながら決めたいですよね。各レース、何をいくら買うのかを手作業で決めるのは、それはそれで楽しくもありますが、せっかく競馬予想AIを作っているのだからできれば自動で行いたい。というわけで、オッズ、人気データもスクレイピングしたいと思います。

実は、以前、こちらのページでnetkeibaの出馬表をスクレイピングを行なっています。

競馬予想AIの作り方 〜 Rで出馬表をWebスクレイピング
前回の記事でせっかくスピード指数をスクレイピングしてデータベースを作ったのだから、次のレースの検討に活かしたい‼️そうすれば、きっと…(笑)。というわけで、今週末開催されるレースの出走テーブルをnetkeibaさんからスクレイピングさせてもらうことにしました。

 

スクレイピング対象のnetkeibaのページにはオッズや人気のデータは記載されていたのですがxml2::read_html()関数(xml2パッケージはrvestを読み込むと自動で読み込まれます)では、オッズと人気のデータはスクレイピングできませんでした。

netkeibaのページは、「オッズ」と「人気」の列は直接htmlファイルに書き込まれているわけではなく、JavaScriptで動的にデータが生成されていることが原因です(Pythonのrequestsライブラリなどでhtmlを入手しても、やはり同様にオッズや人気データはスクレイピングできません)。オッズと人気データをスクレイピングするためには、seleniumなどブラウザベースのモジュールを使う必要があります。

 

netkeibaのスクレイピングにRSeleniumを使う

Seleniumとは

Selenium(セレニウム)はWebアプリケーションのブラウザ操作を自動化するソフトです。JavaScriptを用いて動的に生成されるWebページの場合、htmlファイルには欲しい情報(今回の「オッズ」や「人気」など)は記載されていない場合があります。read_html()関数では欲しい情報を入手できないような場合でも、ブラウザ操作用のツールであるSeleniumを用いれば、動的に生成されたデータでも入手することが可能になります。

 

スクレイピングを始める前に

スクレイピングは、WebページのHTML/CSS構造を利用して目的のデータを抽出します。Seleniumを使う場合でも、データの抽出の大部分はrvestを使う場合と同じで、

  • rvest(xml2)パッケージの使い方
  • HTML/CSSの構造

の知識が必要です。もし曖昧なら、まずこちらから。これだけ知っていればスクレイピングは始められます。

Rでスクレイピングするならrvest 表もリンクもテキストも
Rでスクレイピングをするならrvestパッケージを使うのがベスト。表データ、リンク先URL、テキストなどのデータを簡単に入手できます。netkeibaのレース結果を題材にrvestパッケージの使い方をまとめています。
スクレイピングに必要な最低限のHTML/CSSの知識
スクレイピングでデータを収集するためには、HTMLで書かれたWebページの文書構造を理解し、どこに目的のデータが記載されているかをコンピュータに教えてやる必要があります。このページではスクレイピングに最低限必要なHTML/CSSの知識をまとめています。

 

ソースコード

R用のSeleniumパッケージであるRSeleniumを用いてnetkeibaの出馬表をスクレイピングできるようにしたソースコードを以下に示します。

使用するライブラリ

library(rvest)
library(stringr)
library(RSelenium)
library(tidyverse)

開催日のrace_idを取得する関数

引数:  レース開催日 race_date (yyyymmdd形式の文字列)
戻り値: レース開催日に行われる全競馬場の全レースのrace_id
# レースIDを調べる

get_race_ids <- function(race_date){

    # netkeiba開催日のページ
    url <- paste0("https://race.netkeiba.com/top/race_list_sub.html?kaisai_date=", race_date)
    race_ids <- read_html(url, encoding = "UTF-8")%>%
        html_nodes("a") %>%
        html_attr("href") %>%
        str_subset(".*race_id=") %>%
        str_replace(".*race_id=", "") %>%
        str_replace("&rf=.*", "") %>%
        unique()

    cat(" Sleep")
    Sys.sleep(1)
    cat(" Done")

    race_ids

}

race_idのページのhtmlデータを取得する関数

引数:
race_id:レースID
rsChr : RSeleniumでChromeを起動した時の戻り値(リモートドライバー)

戻り値:race_idのページのhtmlデータ

rsChrにChromeのリモートドライバーが設定されているときは、seleniumを用いたスクレイピングが行われ、rsChrがNAのとき(デフォルト)はrvestを用いたスクレイピングが行われます。seleniumを用いたスクレイピングは速くコマンドを出しすぎるとエラーが生じてしまうため、各ステップ2秒間の待ち時間を設定しています(2秒×3回 = 計6秒の待ち時間)。rvestを用いたスクレイピングは1秒です。状況に応じて使い分けてください。

# race_idのページを読み込む

get_scheduled_race_html <- function(race_id, rsChr = NA){

    cat(race_id)
    cat("n")

    url <- paste0("https://race.netkeiba.com/race/shutuba.html?race_id=", race_id, "&rf=race_list")

    if(isS4(rsChr)){
        rsChr$navigate(url)
        Sys.sleep(2)

        ret <- rsChr$findElements(using = "css selector", "div.RaceTableArea")
        Sys.sleep(2)

        if(length(ret) !=0){
            pgsource <- rsChr$getPageSource()
            Sys.sleep(2)

            html <- read_html(pgsource[[1]])

        } else {
            html <- NA
        }

    }else{
        html <- read_html(url, encoding = "EUC-JP")
        Sys.sleep(1)
    }

    html

}

htmlデータから各馬共通の情報を抽出する関数

引数:
html:get_scheduled_race_html()関数で取得したhtmlデータ
race_id:スクレイピング対象のrace_id

戻り値:レース条件を記載したdata.frame

# レースの条件等、各馬にとって共通のデータを読み込む

get_scheduled_race_cond <- function(html, race_id){

    #レース
    race_No <- html %>%
        html_element(".RaceNum") %>%
        html_text() %>%
        str_remove("R") %>%
        as.integer()

    #レース名
    race_name <- html %>%
        html_element(".RaceName") %>%
        html_text() %>%
        str_trim()

    race_data01 <- html %>%
        html_element(".RaceData01") %>%
        html_text() %>%
        str_remove_all("n")

    race_data02 <- html %>%
        html_element(".RaceData02") %>%
        html_text() %>%
        str_split("n") %>%
        unlist()

    # 開催
    kaisai <- paste0(race_data02[2:4], collapse = "")

    #競馬場
    place <- race_data02[3]

    #クラス
    class <- paste0(race_data02[5:6], collapse = "")

    #馬場
    turf <- race_data01 %>%
        str_split("/") %>%
        .[[1]] %>%
        .[2] %>%
        str_trim() %>%
        str_sub(1,1)

    # 距離
    distance <- race_data01 %>%
        str_split("/") %>%
        .[[1]] %>%
        .[2] %>%
        str_extract("\d+") %>%
        as.integer()

    #左右
    rotation <- race_data01 %>%
        str_split("/") %>%
        .[[1]] %>%
        .[2] %>%
        str_replace("^.*\((.)\)$", "\1")


    df.cond <- tibble("race_id" = race_id,
        "開催" = kaisai,
        "競馬場" = place,
        "レース" = race_No,
        "レース名" = race_name,
        "クラス" = class,
        "馬場" = turf,
        "距離" = distance,
        "左右" = rotation
        )

    df.cond

}

htmlデータから各馬固有の情報を抽出する関数

引数:
html:get_scheduled_race_html()関数で取得したhtmlデータ
race_id:スクレイピング対象のrace_id

戻り値:枠、馬番、馬名、オッズ、人気順、etcを記載したdata.frame

# 各馬のデータを読み込む

get_scheduled_race_horse <- function(html, race_id){

    # 出馬表の読み込み
    df <- html %>%
        html_elements("div.RaceTableArea") %>%
        html_table() %>%
        .[[1]]

    # 列名の修正
    names(df) <- names(df)  %>%
        str_replace_all("n", "") %>%
        str_replace("(^.*オッズ.*$)|(^.*更新.*$)", "オッズ")

    # 1行目は不要なので削除
    df <- df[-1, ]

    # 必要な列のみ抽出
    df <- df %>%
        select(`枠`, `馬番`, `馬名`, `性齢`, `斤量`, `騎手`, `厩舎`, `オッズ`, `人気`)

    # 型変換
    # htmlの中身によって、
    データが入っている場合と入っていない場合があるので場合分け
    #
    # 枠、馬番 : データの入手日時、時間によって、入っていたり入っていなかったりする
    # オッズ、人気 : データの入手方法(seleniumを使うかどうか)で
    # 入っていたり入っていなかったりする

    if(str_detect(df$枠[1], "\d")){
        df[c("枠", "馬番")] <- lapply(df[c("枠", "馬番")], as.integer) %>%
            as.data.frame()
    }

    df["斤量"] <- lapply(df["斤量"], as.double) %>%
        as.data.frame()

    if(str_detect(df$オッズ[1], "\d")){
        df["オッズ"] <- lapply(df["オッズ"], as.double) %>%
            as.data.frame()
    }

    if(str_detect(df$人気[1], "\d")){
        df["人気"] <- lapply(df["人気"], as.integer) %>%
            as.data.frame()
    }

    # race_idをdata.frameに追加
    df <- df %>%
        mutate(race_id = race_id, .before = everything())

    df
}

スクレイピング本体

引数:
race_ids:スクレイピングしたいレースのrace_id
rsChr : RSeleniumでChromeを起動した時の戻り値(リモートドライバー)

戻り値:race条件、枠、馬番、馬名、オッズ、人気順、etcを記載したdata.frame

# スクレイピング本体
get_scheduled_race_table <- function(race_ids, rsChr = NA){
    df.cond <- NULL
    df.horse <- NULL

    for(race_id in race_ids){
        # htmlデータの読み込み
        html <- get_scheduled_race_html(race_id, rsChr)

        # race条件の読み込み
        df.cond.tmp <- get_scheduled_race_cond(html, race_id)

        # 出馬表を取得する
        df.horse.tmp <- get_scheduled_race_horse(html, race_id)

        # 各data.frameの合体
        if(is.null(df.cond)){
            df.cond <- df.cond.tmp
            df.horse <- df.horse.tmp
        }else{
            df.cond <- rbind(df.cond, df.cond.tmp)
            df.horse <- rbind(df.horse, df.horse.tmp)
        }
    }

    # race条件と各馬データの合体
    df.race.table <- left_join(df.cond, df.horse)

    df.race.table
}

RSeleniumの起動

引数:なし
戻り値:RSeleniumでChromeを起動した時の戻り値(リモートドライバー)

# RSelenium の起動

start_selenium <- function(){

    # selenium.jarの起動
    system("java -jar selenium.jar &")

    # Google Chrome 起動
    rsChr <- RSelenium::remoteDriver(port = 4444L, browserName = "chrome")

    # Webブラウザを開く
    rsChr$open()

    rsChr
}

RSeleniumの終了

引数:
rsChr : RSeleniumでChromeを起動した時の戻り値(リモートドライバー)

戻り値:なし

# RSelenium 終了処理
stop_selenium <- function(rsChr){

    # ブラウザを閉じる
    rsChr$close()

    # 何かバグとかで終了して、Rのみ再起動してしまって、
    # selenium を複数立ち上げてしまうことがある
    # それを防止するため
    # selenium.jar を全て終了する

    # まず、selenium.jarのpidを確認
    system("ps -A | grep 'java'")
    cat("n")

    pid <- system("ps -A | grep 'java'", intern = TRUE)

    # selenium.jarのpid
    pid_selenium <- pid %>%
        str_subset("selenium") %>%
        str_extract("^\d*")

    #
    # 6721 ?? 0:01.37 /usr/bin/java -jar selenium.jar
    # 6724 ?? 0:00.01 sh -c ps -A | grep 'java'
    # 6726 ?? 0:00.00 grep java
    #
    # pidをkillする(上の場合6721)
    # system("kill 6721") # selenium.jarをkillする

    # selenium.jar を全て終了する
    for(p in pid_selenium){
        paste0("kill ", p)
        system(paste0("kill ", p))
    }

    #killできていることの確認
    cat("n")
    system("ps -A | grep 'java'")

}

使用例

以下の例では、まず、2022年3月27日に開催される全レースに関してオッズなしの出馬表をスクレイピングした後、お目当てのレースのrace_idを抽出して(今回は各競馬場の10〜12R)、オッズありの出馬表データをスクレイピングしています。

#---------------------------------------
# race_dateで指定された開催日のrace_idの抽出
#---------------------------------------

# 開催日
race_date <- "20220327"

# race_idの読み込み
race_ids <- get_race_ids(race_date)

# race_idsに対応する出馬表(オッズなし)
df.race.table <- get_scheduled_race_table(race_ids, NA)

# お目当てのレースのrace_idを抽出
race_ids2 <- df.race.table %>%
    filter(レース %in% (10:12)) %>%
    select(race_id) %>%
    unique()  %>%
    unlist()

#---------------------------------------
# オッズありの出馬表の抽出
#---------------------------------------

# seleniumの起動
rsChr <- start_selenium()

# オッズありの出馬表のスクレイピング
df.race.table <- get_scheduled_race_table(race_ids2, rsChr)

# seleniumの停止
stop_selenium(rsChr)

# seleniumの停止確認
system("ps -A | grep 'java'")

まとめ

RSeleniumパッケージを使うことで、JavaScriptで動的にデータが作られているサイトもスクレイピングすることができます。これでnetkeibaの出馬表からオッズ、人気データも取得することができるようになります。