🐴

GASでJRAホームページの情報を基に競馬予想をする

2021/06/14に公開1

GASで競馬予想をしてみたので、まとめたいと思います。

※以下の記事を参考にさせてもらいました。
https://arukayies.com/gas/horse-racing-prediction

予想する方法

JRAホームページの「レース成績データ」というページで過去の重賞の結果が閲覧できるので、ここの情報を基にします。

https://www.jra.go.jp/datafile/seiseki/

上記から以下のデータを取得してきます。

  • 競馬場
  • コース
  • 距離
  • 天気
  • 馬場状態
  • 着順
  • 馬名
  • 騎手名

取得したデータから、以下のように判断してみます。

  • この馬は東京で走った時は、12頭中1位だったからスコア120
  • この騎手は芝コースで走った時は、12人中3位だったからスコア12

上記のように、馬・騎手のスコアを算出して順位付けします。

過去の重賞結果をスクレイピングして取得

過去の重賞結果をGASでスクレイピングして、取得します。

今回使用する項目は上記で記載した通りですが、将来的なことを考えて、取得できる項目は基本的に取得するようにしています。

getJyushoResultが全体を取得する関数、getRaceResultがレースごとの情報を取得する関数です。

let ignore = [
  "スコア",
  "レース成績一覧",
  "テンプレート",
  "プルダウンデータ"
];

function getJyushoResult() {
  let ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getActiveSheet();
  let year = sheet.getRange("B1").getValue();

  let response = UrlFetchApp.fetch(`https://www.jra.go.jp/datafile/seiseki/replay/${year}/jyusyo.html`);
  let content = response.getContentText("Shift_JIS");

  let output = [];
  // 重賞一覧を取得
  let tbody = content.match(/<tbody.*>[\s\S]*?<\/tbody>/g)[0];
  let matchTrs = tbody.match(/<tr.*>[\s\S]*?<\/tr>/g);
  for (let tr of matchTrs) {
    // 月
    let month = tr.match(/<td class="date">([\s\S]*?)<\/td>/)[1];
    month = month.match(/(\d{1,2})\d{1,2}日<span.+<\/span>/)[1];

    // レース名
    let raceName = tr.match(/<td class="race">([\s\S]*?)<\/td>/)[1];
    raceName = raceName.replace(/<a href=".*">/, "");
    raceName = raceName.replace("</a>", "");
    raceName = raceName.match(/<span class="grade_icon.*">.+<\/span>(.+)/)[1];

    // 競馬場
    let place = tr.match(/<td class="place">(.+)<\/td>/)[1];

    // コース、距離
    let courseDistance = tr.match(/<td class="course">([\s\S]*?)<\/td>/)[1];
    courseDistance = courseDistance.match(/<span class="type">(.+)<\/span>(.+)<span class="unit">.+/);
    let course = courseDistance[1];
    let distance = courseDistance[2];
    distance = distance.replace(",", "");

    // レース結果のURL
    let resultUrl = tr.match(/<td class="result">([\s\S]*?)<\/td>/)[1];
    if (resultUrl == "") {
      break;
    }
    resultUrl = resultUrl.match(/<a href="(.+)" class/)[1];
    resultUrl = `https://www.jra.go.jp${resultUrl}`;
    let tmp = getRaceResult(resultUrl, month, raceName, place, course, distance);
    output = output.concat(tmp);
  }
  
  let sheetYear = ss.getSheetByName(year);

  if (sheetYear == null) {
    // 対象年のシートがなかったら作成
    let sheetNew = ss.getSheetByName("テンプレート").copyTo(ss);
    sheetNew.setName(year);

    // シートの位置を移動
    ss.setActiveSheet(sheetNew);
    ss.moveActiveSheet(2);

    sheetYear = sheetNew;
  }
  // 取得したデータを出力
  sheetYear.getRange(2, 1, output.length, output[0].length).setValues(output);

  // レース成績一覧の参照式を更新
  let sheets = ss.getSheets();
  let queryRanges = "";
  for (let sheet of sheets) {
    let sheetName = sheet.getName();
    if (ignore.indexOf(sheetName) != -1) {
      continue;
    }
    if (queryRanges != "") {
      queryRanges += ";";
    }
    queryRanges += `'${sheetName}'!A2:S`;
  }
  sheet.getRange("A4").setValue(`=QUERY({${queryRanges}},"where Col1 is not null")`);

  ss.setActiveSheet(sheet);
}

function getRaceResult(resultUrl, month, raceName, place, course, distance) {
  let response = UrlFetchApp.fetch(resultUrl);
  let content = response.getContentText("Shift_JIS");

  // 天気を取得
  let matchWeather = content.match(/<li class="weather">[\s\S]*?<span class="inner">[\s\S]*?<span class="cap">天候<\/span>[\s\S]*?<span class="txt">([\s\S]*?)<\/span>/);
  let weather = matchWeather[1];

  // 馬場状態を取得
  let matchCourseStatus = content.match(/<li class="(turf|durt)">[\s\S]*?<span class="inner">[\s\S]*?<span class="cap">(|ダート)<\/span>[\s\S]*?<span class="txt">([\s\S]*?)<\/span>/);
  let courseStatus = matchCourseStatus[3];

  // 着順一覧を取得
  let tbody = content.match(/<tbody.*>[\s\S]*?<\/tbody>/g)[0];
  let matchTrs = tbody.match(/<tr>[\s\S]*?<\/tr>/g);

  let output = [];
  for (let tr of matchTrs) {
    // 着順
    let rank = tr.match(/<td class="place">(.+)<\/td>/)[1];

    // 枠
    let waku = tr.match(/<td class="waku">([\s\S]*?)<\/td>/)[1];
    waku = waku.match(/<img src="\/JRADB\/img\/waku\/(.?).png"/)[1];

    // 馬番
    let num = tr.match(/<td class="num">(.+)<\/td>/)[1];

    // 馬名
    let horse = tr.match(/<td class="horse">([\s\S]*?)<\/td>/)[1];
    horse = horse.replace(/<span class="horse_icon">.*<\/span>/, "");
    horse = horse.replace(/<div class="icon blinker">.*<\/div>/, "");
    horse = horse.replace("<div class=\"horse\">", "");
    horse = horse.replace("</div>", "");
    horse = horse.replace(/\s/g, "");

    // 負担重賞
    let weight = tr.match(/<td class="weight">(.+)<\/td>/)[1];

    // 騎手名
    let jockey = tr.match(/<td class="jockey">(.+)<\/td>/)[1];

    // タイム
    let time = "";

    // コーナー通過順位
    let matchCorner = tr.match(/<td class="corner">([\s\S]*?)<\/td>/)[1];
    let matchLis = matchCorner.match(/<li.+>.+<\/li>/g);
    let corners = [];
    // 1000直はコーナー通過順位がない
    if (matchLis != null) {
      for (let li of matchLis) {
        let order = li.match(/<li.+>(.+)<\/li>/)[1];
        order = order.replace("&nbsp;", "");
        if (order == "") {
          continue;
        }
        corners.push(order);
      }
    }
    let corner = corners.join();

    // 上がり
    let matchLast3fTime = tr.match(/<td class="f_time">(.+)<\/td>/);
    let last3fTime = "";
    if (matchLast3fTime != null) {
      last3fTime = matchLast3fTime[1];
    }

    // 馬体重
    let horseWeight = tr.match(/<td class="h_weight">([\s\S]*?)<\/td>/)[1];
    horseWeight = horseWeight.replace(/\s/g, "");
    let previousRatio = "";
    if (horseWeight != "") {
      let matchPreviousRatio = horseWeight.match(/<span>\((.+)\)<\/span>/);
      if (matchPreviousRatio != null) {
        previousRatio = matchPreviousRatio[1];
      }
      horseWeight = horseWeight.replace(/<span>.*<\/span>/, "");
    }

    // 単勝人気
    let matchPop = tr.match(/<td class="pop">(.+)<\/td>/);
    let pop = "";
    if (matchPop != null) {
      pop = matchPop[1];
    }

    output.push([
      month,
      raceName,
      place,
      course,
      distance,
      weather,
      courseStatus,
      rank,
      waku,
      num,
      horse,
      weight,
      jockey,
      time,
      corner,
      last3fTime,
      horseWeight,
      previousRatio,
      pop,
    ]);
  }
  return output;
}

過去の重賞結果を出力するスプレッドシート

スプレッドシートは以下のようになっています。

B1セルに取得したい年を入力して、「取得」ボタンをクリックすると、上記コードのgetJyushoResultを呼び出します。

重賞結果のページでは、2019年からページのフォーマットが変わったので、上記コードでは2019年以降のページに対応しています。

上記シートでは、過去全体の結果を一覧にしていて、以下のように各年のシートを参照します。

=QUERY({'2021'!A2:S;'2020'!A2:S;'2019'!A2:S},"where Col1 is not null")

各年では以下のように「テンプレート」というシートを作っておいて、取得する時にシートを作成して、過去全体の結果の一覧から参照するという感じです。

スコアを算出する

着順を指標にして、以下のように計算してみます。
馬と騎手で、それぞれ係数を変えています。

  • 東京開催の場合
    ある馬は12頭中4位だったら、10×(12頭中÷4位)= 30
  • 芝コースの場合
    ある騎手は12人中12位だったら、3×(12人中÷12位)= 3
  • 曇りの場合
    ある馬は12頭中1位だったら、10×(12頭中÷1位)= 120

コードは以下の通りです。

function calcScore() {
  let sheet = SpreadsheetApp.getActiveSheet();
  let searchData = new SearchData();
  searchData.place = sheet.getRange('B1').getValue();
  searchData.course = sheet.getRange('B2').getValue();
  searchData.distance = sheet.getRange('B3').getValue();
  searchData.weather = sheet.getRange('D1').getValue();
  searchData.condition = sheet.getRange('D2').getValue();

  // 対象馬リスト
  let targetHorseJockeys = sheet.getRange("A7:B").getValues();
  let horseList = {};
  let jockeyList = {};
  for (let targetHorseJockey of targetHorseJockeys) {
    let targetHorse = targetHorseJockey[0];
    if (targetHorse == "") {
      break;
    }
    let targetJockey = targetHorseJockey[1];

    horseList[targetHorse] = new Score();
    jockeyList[targetJockey] = new Score();
  }
  searchData.horseList = horseList;
  searchData.jockeyList = jockeyList;

  let sheetResultList = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("レース成績一覧");
  let resultDatas = sheetResultList.getRange("A4:S").getValues();
  for (let index in resultDatas) {
    // データ一覧は4行目から
    let rowNum = Number(index) + 4;

    let data = resultDatas[index];
    if (data[0] == "") {
      break;
    }

    // 馬名
    let horseName = data[10];
    // 騎手
    let jockey = data[12];

    if (searchData.horseList[horseName]) {
      // 馬名が一致している場合のスコア
      let score = calcEachScore(sheetResultList, rowNum, searchData, data, false);
      searchData.horseList[horseName].scorePlace += score.scorePlace;
      searchData.horseList[horseName].scoreCourse += score.scoreCourse;
      searchData.horseList[horseName].scoreDistance += score.scoreDistance;
      searchData.horseList[horseName].scoreWeather += score.scoreWeather;
      searchData.horseList[horseName].scoreCondition += score.scoreCondition;
    }
    if (searchData.jockeyList[jockey]) {
      // 騎手が一致している場合のスコア
      let score = calcEachScore(sheetResultList, rowNum, searchData, data, true);
      searchData.jockeyList[jockey].scorePlace += score.scorePlace;
      searchData.jockeyList[jockey].scoreCourse += score.scoreCourse;
      searchData.jockeyList[jockey].scoreDistance += score.scoreDistance;
      searchData.jockeyList[jockey].scoreWeather += score.scoreWeather;
      searchData.jockeyList[jockey].scoreCondition += score.scoreCondition;
    }
  }

  let rowScore = 7;
  const colHorseScore = 7;
  const colJockeyScore = 14;
  for (let key in searchData.horseList) {
    let horseScore = searchData.horseList[key];
    sheet.getRange(rowScore, colHorseScore).setValue(horseScore.scorePlace);
    sheet.getRange(rowScore, colHorseScore+1).setValue(horseScore.scoreCourse);
    sheet.getRange(rowScore, colHorseScore+2).setValue(horseScore.scoreDistance);
    sheet.getRange(rowScore, colHorseScore+3).setValue(horseScore.scoreWeather);
    sheet.getRange(rowScore, colHorseScore+4).setValue(horseScore.scoreCondition);

    let jockey = sheet.getRange(rowScore, 2).getValue();
    let jockeyScore = searchData.jockeyList[jockey];
    sheet.getRange(rowScore, colJockeyScore).setValue(jockeyScore.scorePlace);
    sheet.getRange(rowScore, colJockeyScore+1).setValue(jockeyScore.scoreCourse);
    sheet.getRange(rowScore, colJockeyScore+2).setValue(jockeyScore.scoreDistance);
    sheet.getRange(rowScore, colJockeyScore+3).setValue(jockeyScore.scoreWeather);
    sheet.getRange(rowScore, colJockeyScore+4).setValue(jockeyScore.scoreCondition);

    rowScore++;
  }
}

function calcEachScore(sheetResultList, rowNum, searchData, resultData, isJockey) {
  // 出走頭数
  let numberOfRunners = getNumberOfRunners(sheetResultList, rowNum);
  // 着順
  let rank = resultData[7];

  let score = new Score();
  // 除外、競走中止などは空になっているので対象外
  if (rank == "") {
    return score;
  }

  let coefficient = 10;
  if (isJockey) {
    coefficient = 3;
  }

  // 競馬場が一致している場合のスコア
  if (resultData[2] == searchData.place) {
    score.scorePlace = calc(numberOfRunners, rank, coefficient);
  }
  // コースが一致している場合のスコア
  if (resultData[3] == searchData.course) {
    score.scoreCourse = calc(numberOfRunners, rank, coefficient);
  }
  // 距離が一致している場合のスコア
  if (resultData[4] == searchData.distance) {
    score.scoreDistance = calc(numberOfRunners, rank, coefficient);
  }
  // 天候が一致している場合のスコア
  if (resultData[5] == searchData.weather) {
    score.scoreWeather = calc(numberOfRunners, rank, coefficient);
  }
  // 馬場状態が一致している場合のスコア
  if (resultData[6] == searchData.condition) {
    score.scoreCondition = calc(numberOfRunners, rank, coefficient);
  }
  return score;
}

function getNumberOfRunners(sheetResultList, rowNum) {
  // 出走頭数
  // 着順で次の行から次の1が出現したら、その上の行で出走頭数が分かる
  // 該当馬の次の行から最大18頭までの間で探す
  let arrayNumberOfRunners = Array.prototype.concat.apply([], sheetResultList.getRange(`H${rowNum+1}:H${rowNum+18}`).getValues());
  let indexNumberOfRunners = arrayNumberOfRunners.indexOf(1);

  // 現在行に見つかった1着のインデックスを足すと、該当行になる
  let rowNumNumberOfRunners = rowNum + indexNumberOfRunners;
  let numberOfRunners = sheetResultList.getRange(rowNumNumberOfRunners, 8).getValue();
  return numberOfRunners;
}

function calc(numberOfRunners, rank, coefficient) {
  let score = Math.round(coefficient * (numberOfRunners / rank));
  return score;
}

class SearchData {
  // 競馬場
  get place() {
    return this._place;
  }
  set place(place) {
    this._place = place;
  }
  // コース
  get course() {
    return this._course;
  }
  set course(course) {
    this._course = course;
  }
  // 距離
  get distance() {
    return this._distance;
  }
  set distance(distance) {
    this._distance = distance;
  }
  // 天候
  get weather() {
    return this._weather;
  }
  set weather(weather) {
    this._weather = weather;
  }
  // 馬場状態
  get condition() {
    return this._condition;
  }
  set condition(condition) {
    this._condition = condition;
  }
  // 馬名リスト
  get horseList() {
    return this._horseList;
  }
  set horseList(horseList) {
    this._horseList = horseList;
  }
  // 騎手リスト
  get jockeyList() {
    return this._jockeyList;
  }
  set jockeyList(jockeyList) {
    this._jockeyList = jockeyList;
  }
}

class Score {
  constructor() {
    this._scorePlace = 0;
    this._scoreCourse = 0;
    this._scoreDistance = 0;
    this._scoreWeather = 0;
    this._scoreCondition = 0;
  }
  // 競馬場スコア
  get scorePlace() {
    return this._scorePlace;
  }
  set scorePlace(scorePlace) {
    this._scorePlace = scorePlace;
  }
  // コーススコア
  get scoreCourse() {
    return this._scoreCourse;
  }
  set scoreCourse(scoreCourse) {
    this._scoreCourse = scoreCourse;
  }
  // 距離スコア
  get scoreDistance() {
    return this._scoreDistance;
  }
  set scoreDistance(scoreDistance) {
    this._scoreDistance = scoreDistance;
  }
  // 天候スコア
  get scoreWeather() {
    return this._scoreWeather;
  }
  set scoreWeather(scoreWeather) {
    this._scoreWeather = scoreWeather;
  }
  // 馬場状態スコア
  get scoreCondition() {
    return this._scoreCondition;
  }
  set scoreCondition(scoreCondition) {
    this._scoreCondition = scoreCondition;
  }
}

スコアを算出するスプレッドシート

以下のような感じで条件を指定して、「スコア算出」ボタンをクリックすると、上記コードのcalcScoreを呼び出します。
この時、7行目のA列、B列に算出したい馬名と騎手を入力しておきます。

  • 競馬場
  • コース
  • 距離
  • 天候
  • 馬場状態

2021年の安田記念を予想してみた

2021年の安田記念を予想してみました。

予想順位は以下の通りです。
1位:グランアレグリア(馬スコア1位、騎手スコア1位)
2位:インディチャンプ(馬スコア2位、騎手スコア2位)
3位:ダノンキングリー(馬スコア5位、騎手スコア3位)
4位:ラウダシオン(馬スコア3位、騎手スコア4位)
5位:サリオス(馬スコア4位、騎手スコア6位)

実際の結果は以下の通りでした。
1位:ダノンキングリー
2位:グランアレグリア
3位:シュネルマイスター:予想スコア12位(馬スコア14位、騎手スコア12位)
4位:インディチャンプ
5位:トーラスジェミニ:予想スコア9位(馬スコア13位、騎手スコア5位)

まとめ

ダノンキングリーを拾えたのは良いですが、だいぶ改善が必要な結果で、騎手スコアの比重が大きいですね。

これは、過去の重賞結果を基にしているので、単純にリーディング上位騎手がたくさん乗っているとスコアが高くなってしまいます。

馬スコアも同様で、該当コース、距離のレースをどれだけ走っているかによってしまいますね。
安田記念のように、当該コースで行われる重賞が多い場合には良いですが、あまり行われない距離とか、3歳馬が絡むと、難しいですね。

競馬のことをあまり知らない人が予想してみる、とかだったら結構良いかもですね。

今後改修できるところは改修していきたいと思います。

Discussion

やぎちゃんまんやぎちゃんまん

GAS初心者なので、コードなるものを見てもどうすれば良いか分からないです…
調べても理解出来ず、出来ればどこに何を入力すれば良いかの例も欲しいです。