馬券の的中成績を管理するWebアプリを作成した

競馬の馬券が当たった時はうれしいのですが、時間がたつとどのレースで当てたのかがわからなくなってしまいます。
そこで、当たったレースと払戻金を管理できるWebアプリを作成しました。

ついでに、レースの勝ち馬名や着順等も一緒に眺めたいので、netkeiba.com様のWebページをスクレイピングしました。（節度あるアクセスにしましょう）

記録場所は、手っ取り早くブラウザのLocalStorageにしました。JSONファイルにエクスポート/インポートする機能も付けておきましたが、永続的に記録したい場合は、そこまで複雑な処理ではないので、カスタマイズしてみてください。

ソースコードもろもろはGitHubに上げておきました。

poruruba/Keiba_Kanri

https://github.com/poruruba/Keiba_Kanri

#サーバ側の仕組み

Node.jsサーバとJavascriptによるWebページに分けられます。
実際のスクレイピングは、Node.jsサーバ側で実施しています。

Node.jsサーバ側では、2つのスクレイピングをしています。

①指定年のG1レースのリストのページ
②指定レースの詳細ページ

①指定年のG1レースのリストのページ
G1だけなのは、私が、G1のみ競馬に参加しているためです。
以下のURLでフィルタリングしたものです。
　https://db.netkeiba.com/?pid=race_search_detail

期間を年指定にしていること、競馬場を全チェックしていること（国内レースのみとするため）、クラスにG1のみチェックを入れていること、表示件数を最大の100件にしているところが特徴です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/e953ca62-0a0c-fd1f-fad9-6541bcecf3be.png)

そうするとこんな感じで、表のあるページが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/12d84916-22ca-8fb0-59fd-2ac59710059d.png)

②指定レースの詳細ページ

以下のURLです。URLにレースIDがあります。
　https://db.netkeiba.com/race/202106050811/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/76b7a54b-4e54-21a7-ca29-bf04d1e69aa4.png)

#クライアント側の仕組み

作成するJavascriptのWebページは3つのページから構成されます。

①G1レースリスト
　指定した年のG１レースがリスト表示されます。Node.jsサーバの「①指定年のG1レースのリストのページ」を呼び出しています。また、的中レース情報を入力しておくと、そのレースに赤く印がつきます。
②レース情報
　①でレースを選択すると、着順等のそのレースの詳細な情報が表示されます。Node.jsサーバの「②指定レースの詳細ページ」を呼び出しています。レース的中結果を入力するのもこのページです。
③的中レースリスト
　的中レースが、払い戻し額とともに、パネル形式で表示されます。的中レースの情報の保持には、ブラウザのLocalStorageを使っています。

#サーバ側の実装

スクレイピングするユーティリティクラスを作成しました。

HTTP呼び出しは、npmモジュール「node-fetch@2.6.6」を利用しました。
スクレイピングには、npmモジュール「cheerio」を利用しました。
また、受信したWebページは、EUC-JPであるため、UTF-8に変換するためのnpmモジュール「iconv」も利用しています。

netkeiba.com様にご迷惑が掛からないように、3秒をウェイトを入れています。

```js:/api/controllers/keiba-info/keiba.js
'use strict';

const base_url = 'https://db.netkeiba.com';

const cheerio = require('cheerio');
const { URL, URLSearchParams } = require('url');
const fetch = require('node-fetch');
const Headers = fetch.Headers;
const Iconv  = require('iconv').Iconv;
const APICALL_WAIT = 3000;

class Keiba{
  constructor(){
    this.iconv = new Iconv("EUC-JP", 'UTF-8');
  }

  async getRaceResult(raceId){
    var html_eucjp = await do_get(base_url + "/race/" + raceId);
    const html = Buffer.from(this.iconv.convert(Buffer.from(html_eucjp))).toString();
//    console.log(html);
    await new Promise(resolve => setTimeout(resolve, APICALL_WAIT));

    return this.parseRaceResult(html);
  }

  async getRaceList(year){
    var params = {
      pid: "race_list",
      start_year: year,
      end_year: year,
      "grade[]": 1,
      sort: "date",
      list: 100,
    };
    var html_eucjp = await do_post_urlencoded_racelist(base_url, params);
    const html = Buffer.from(this.iconv.convert(Buffer.from(html_eucjp))).toString();
//    console.log(html);
    await new Promise(resolve => setTimeout(resolve, APICALL_WAIT));

    return this.parseRaceList(html);
  }

  parseRaceList(html){
    const $ = cheerio.load(html);

    var raceList = [];
    $('tr', '.nk_tb_common').each((i, elem) => {
      if( i == 0 ) return;
      // 開催日、開催、天気、R、レース名、映像、距離、頭数、馬場、タイム、ペース、勝ち馬、騎手、調教師、2着馬、3着馬
      // 開催日(0)、開催(1)、天気(2)、R(3)、レース名(4)、映像(5)、距離(6)、頭数(7)、馬場(8)、タイム(9)、ペース(10)、勝ち馬(11)、騎手(12)、調教師(13)、2着馬(14)、3着馬(15)
      var raceInfo = {};
      var tds = $(elem).find('td');
      raceInfo.id = $(tds[4]).find('a').attr('href').split('/')[2];
      raceInfo.date = $(tds[0]).find('a').text().trim();
      raceInfo.event = $(tds[1]).find('a').text().trim();
      raceInfo.weather = $(tds[2]).text().trim();
      raceInfo.race = parseInt($(tds[3]).text().trim());
      raceInfo.name = $(tds[4]).find('a').text().trim();
      raceInfo.movie = $(tds[5]).find('a').attr('href') ? (base_url + $(tds[5]).find('a').attr('href').trim()) : null;
      raceInfo.distance = $(tds[6]).text().trim();
      raceInfo.num_of_horse = parseInt($(tds[7]).text().trim());
      raceInfo.condition = $(tds[8]).text().trim();
      raceInfo.time = $(tds[9]).text().trim();
      raceInfo.pace = $(tds[10]).text().trim();
      raceInfo.winner = $(tds[11]).find('a').text().trim();
      raceInfo.jockey = $(tds[12]).find('a').text().trim();
      raceInfo.trainer = $(tds[13]).text().trim();
      raceInfo.second = $(tds[14]).find('a').text().trim();
      raceInfo.third = $(tds[15]).find('a').text().trim();

      raceList.push(raceInfo);
    });
  
    console.log(raceList);
  
    return raceList;
  }

  parseRaceResult(html){
  	console.log(html);
    const $ = cheerio.load(html);
  
    var raceInfo = {};
    raceInfo.title = $('h1').text().replace(/\n/g, "").trim();
    raceInfo.info1 = $('diary_snap_cut > span').text();
    raceInfo.info2 = $('.smalltxt').text();
    raceInfo.date = raceInfo.info2.split(" ")[0];

    var raceResult = [];
    $('tr', '.nk_tb_common').each((i, elem) => {
      if( i == 0 ) return;
      // 着順、枠番、馬番、馬名、性齢、斤量、騎手、タイム、着差、タイム指数、通過、上り、単勝、人気、馬体重、調教タイム、厩舎コメント、備考、調教師、馬主、賞金
      // 着順(0)、枠番(1)、馬番(2)、馬名(3)、性齢(4)、斤量(5)、騎手(6)、タイム(7)、着差(8)、タイム指数(9)、通過(10)、上り(11)、単勝(12)、人気(13)、馬体重(14)、調教タイム(15)、厩舎コメント(16)、備考(17)、調教師(18)、馬主(19)、賞金(20)
      var rankInfo = {};
      var tds = $(elem).find('td');
      rankInfo.order = parseInt($(tds[0]).text().trim());
      rankInfo.wakuban = parseInt($(tds[1]).text().trim());
      rankInfo.umaban = parseInt($(tds[2]).text().trim());
      rankInfo.name = $(tds[3]).text().trim();
      rankInfo.age = $(tds[4]).text().trim();
      rankInfo.kinryou = parseInt($(tds[5]).text().trim());
      rankInfo.jockey = $(tds[6]).text().trim();
      rankInfo.time = $(tds[7]).text().trim();
      rankInfo.difference = $(tds[8]).text().trim();
      rankInfo.pass = $(tds[10]).text().trim();
      rankInfo.last_time = parseFloat($(tds[11]).text().trim());
      rankInfo.tan_ods = parseFloat($(tds[12]).text().trim());
      rankInfo.rating = parseInt($(tds[13]).text().trim());
      rankInfo.weight = $(tds[14]).text().trim();
      rankInfo.trainer = $(tds[18]).text().replace(/\n/g, "").trim();
      rankInfo.owner = $(tds[19]).text().trim();
  
      var award = $(tds[20]).text().trim();
      if( award )
        rankInfo.award = parseKuraiFloat(award) * 10000;
      raceResult.push(rankInfo);
    });
  
    console.log(raceResult);
  
    var odsResult = {};
    $('tr', '.pay_table_01').each((i, elem) => {
      var type = $($(elem).find("th")[0]).attr("class");
      var items = $(elem).find('td');
      switch(type){
        case 'tan':{
          var item = {
            type: type,
            number: parseKuraiInt($(items[0]).text()),
            refund: parseKuraiInt($(items[1]).text())
          }
          odsResult[type] = item;
          break;
        }
        case 'waku':
        case 'uren':
        case 'utan':
        case 'sanfuku':
        case 'santan':
        {
          var item = {
            type: type,
            number: $(items[0]).text(),
            refund: parseKuraiInt($(items[1]).text())
          }
          odsResult[type] = item;
          break;
        }
        case 'fuku':{
          var item = {
            type: type,
            refunds: []
          };
  
          var numbers = $(items[0]).html().replace(/<[^>]*>/g, "<>").split("<>");
          var refunds = $(items[1]).html().replace(/<[^>]*>/g, "<>").split("<>");
          for( var i = 0 ; i < numbers.length ; i++ ){
            item.refunds.push({
              number: parseKuraiInt(numbers[i]),
              refund: parseKuraiInt(refunds[i])
            });
          }
  
          odsResult[type] = item;
          break;
        }
        case 'wide':{
          var item = {
            type: type,
            refunds: []
          };
  
          var numbers = $(items[0]).html().replace(/<[^>]*>/g, "<>").split("<>");
          var refunds = $(items[1]).html().replace(/<[^>]*>/g, "<>").split("<>");
          var no = $(items[2]).html().replace(/<[^>]*>/g, "<>").split("<>");
          for( var i = 0 ; i < numbers.length ; i++ ){
            item.refunds.push({
              number: numbers[i],
              refund: parseKuraiInt(refunds[i]),
              no: parseKuraiInt(no[i])
            });
          }
  
          odsResult[type] = item;
          break;
        }
      }
    });
  
    console.log(JSON.stringify(odsResult));
  
    return {
      raceInfo: raceInfo,
      raceResult: raceResult,
      odsResult: odsResult
    }
  }
}

function do_post_urlencoded_racelist(url, params) {
  const headers = new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' });
  var body = new URLSearchParams(params);
  body.append('jyo[]', "01");
  body.append('jyo[]', "02");
  body.append('jyo[]', "03");
  body.append('jyo[]', "04");
  body.append('jyo[]', "05");
  body.append('jyo[]', "06");
  body.append('jyo[]', "07");
  body.append('jyo[]', "08");
  body.append('jyo[]', "09");
  body.append('jyo[]', "10");

  return fetch(url, {
      method: 'POST',
      body: body,
      headers: headers
    })
    .then((response) => {
      if (!response.ok)
        throw 'status is not 200';
        
      return response.arrayBuffer();
    })
}

function do_get(url, qs) {
  var params = new URLSearchParams(qs);

  var params_str = params.toString();
  var postfix = (params_str == "") ? "" : ((url.indexOf('?') >= 0) ? ('&' + params_str) : ('?' + params_str));
  return fetch(url + postfix, {
      method: 'GET',
    })
    .then((response) => {
      if (!response.ok)
        throw 'status is not 200';
      return response.arrayBuffer();
    });
}

function parseKuraiInt(str){
  return parseInt(str.replace(/,/g, ""));
}

function parseKuraiFloat(str){
  return parseFloat(str.replace(/,/g, ""));
}

module.exports = new Keiba();
```

#クライアント側の実装

Vueのおかげで、ずいぶん実装が楽にできました。

```js:/public/js/main.js
'use strict';

//const vConsole = new VConsole();
//window.datgui = new dat.GUI();

const base_url = "";
const prefix = "keiba";
const year_list = [
    2022, 2021, 2020, 2019, 2018, 2017, 2016, 2015, 2014, 2013, 2012, 2011, 2010, 2009, 2008, 2007, 2006, 2005, 2004, 2003, 2002, 2001, 2000,
];

var vue_options = {
    el: "#top",
    mixins: [mixins_bootstrap],
    data: {
        year_list: year_list,
        racelist_year: year_list[0],
        racelist: [],
        raceresult_raceid: 0,
        raceresult: null,
        edit_mode: false,
        bedinfo: {
            fuku: [],
            wide: [],
        },
        bedlist_year: year_list[0],
        bedlist: [],
    },
    computed: {
    },
    methods: {
        bedlist_export: function(){
            var bedlist = [];
            for( var i = 0 ; i < localStorage.length ; i++ ){
                let key = localStorage.key(i);
                if( key.startsWith(prefix) )
                    bedlist.push(JSON.parse(localStorage.getItem(key)));
            }
            var blob = new Blob([JSON.stringify(bedlist, null, "\t")], { type: "application/json" });
            var url = window.URL.createObjectURL(blob);
      
            var a = document.createElement("a");
            a.href = url;
            a.target = '_blank';
            a.download = "keiba.json";
            a.click();
            window.URL.revokeObjectURL(url);
        },
        bedlist_import: function(files){
            if (files.length <= 0) {
                this.hash_input = '';
                return;
            }
        
            var file = files[0];
            var reader = new FileReader();
            reader.onload = (theFile) => {
                var bedlist = JSON.parse(reader.result);
                for( var i = 0 ; i < bedlist.length ; i++ )
                    localStorage[prefix + bedlist[i].id] = JSON.stringify(bedlist[i]);
                this.bedlist_year_change();
                this.dialog_close('#file_import');
            };
            reader.readAsText(file);
        },
        bedlist_year_change: async function(){
            var bedlist = [];
            for( var i = 0 ; i < localStorage.length ; i++ ){
                let key = localStorage.key(i);
                if( key.startsWith(prefix + this.bedlist_year) )
                    bedlist.push(JSON.parse(localStorage.getItem(key)));
            }
            bedlist.sort((first, second) =>{
                var f = moment(first.date, 'YYYY年MM月DD日').unix();
                var s = moment(second.date, 'YYYY年MM月DD日').unix();
                return s - f;
            });
            this.bedlist = bedlist;
        },
        racelist_year_change: async function(){
            var params = {
                year: this.racelist_year
            };
            try{
                this.progress_open();
                var response = await do_post(base_url + "/keiba-racelist", params );
                console.log(response);
                this.racelist = response.result;
            }finally{
                this.progress_close();
            }
        },
        raceinfo_select: async function(id){
            console.log("raceinfo_select(" + id + ")");
            this.raceresult_raceid = id;
            this.edit_mode = false;
            await this.raceinfo_update();
            this.tab_select("#raceinfo");
        },
        raceinfo_update: async function(){
            var params = {
                race_id: this.raceresult_raceid
            };
            try{
                this.progress_open();
                var response = await do_post(base_url + "/keiba-raceresult", params );
                console.log(response);
                this.raceresult = response.result;
                this.bedlist_reload();
            }finally{
                this.progress_close();
            }
        },
        bedlist_reload: function(){
            var bedinfo = localStorage[prefix + this.raceresult_raceid];
            if( !bedinfo ){
                this.bedinfo = {
                    id: this.raceresult_raceid,
                    title: this.raceresult.raceInfo.title,
                    date: this.raceresult.raceInfo.date,
                    odsResult: this.raceresult.odsResult,
                    fuku: [],
                    wide: [],
                }
            }else{
                this.bedinfo = JSON.parse(bedinfo);
            }
        },
        change_edit_mode: function(enable){
            this.bedlist_reload();
            this.edit_mode = enable;
        },
        commit_bed: function(){
            var nodata = false;
            do{
                if( this.bedinfo.tan > 0 ) break;
                if( this.bedinfo.fuku.filter(item => item > 0).length > 0 ) break;
                if( this.bedinfo.waku > 0 ) break;
                if( this.bedinfo.uren > 0 ) break;
                if( this.bedinfo.wide.filter(item => item > 0).length > 0 ) break;
                if( this.bedinfo.utan > 0 ) break;
                if( this.bedinfo.sanfuku > 0 ) break;
                if( this.bedinfo.santan > 0 ) break;
                nodata = true;
            }while(false);
            if( nodata ){
                localStorage.removeItem(prefix + this.bedinfo.id);
            }else{
                localStorage[prefix + this.bedinfo.id] = JSON.stringify(this.bedinfo);
            }
            this.change_edit_mode(false);
        },
        check_bed: function(race_id){
            var bedinfo = localStorage.getItem(prefix + race_id);
            if( !bedinfo )
                return false;
            bedinfo = JSON.parse(bedinfo);
            var nodata = false;
            do{
                if( bedinfo.tan > 0 ) break;
                if( bedinfo.fuku.filter(item => item > 0).length > 0 ) break;
                if( bedinfo.waku > 0 ) break;
                if( bedinfo.uren > 0 ) break;
                if( bedinfo.wide.filter(item => item > 0).length > 0 ) break;
                if( bedinfo.utan > 0 ) break;
                if( bedinfo.sanfuku > 0 ) break;
                if( bedinfo.santan > 0 ) break;
                nodata = true;
            }while(false);
            return !nodata;
        }
    },
    created: function(){
    },
    mounted: function(){
        proc_load();

        this.racelist_year = new Date().getFullYear();
        this.bedlist_year = this.racelist_year;
        this.racelist_year_change();
        this.bedlist_year_change();
    }
};
vue_add_data(vue_options, { progress_title: '' }); // for progress-dialog
vue_add_global_components(components_bootstrap);
vue_add_global_components(components_utils);

/* add additional components */
  
window.vue = new Vue( vue_options );
```

#Webページの画面

3ページありますが、タブで切り替えられるようにしています。
ここらへんは、bootstrap 3.4.1やVue2.0が大活躍です。

まずは、G1レースリストのページです。（今年は的中の調子が良かった。。。）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/5a113c07-4587-1cd0-bab7-48bfe4ea00d3.png)

レースを選択すると、レース情報のページに切り替わります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/4f8b430e-e56d-6391-fd25-7b4ac6d497fe.png)

下の方には、払い戻しの情報があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/e9855d3e-5be8-a28a-9ff5-f8901d6cf339.png)

的中入力ボタンを押すと、払い戻し情報のところに数字を入力することができるようになります。的中したところに、賭けた金額を入力し、最後に「確定」ボタンを押下します。これで、localStorageに保存されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/6d53f22e-4bcc-044c-9681-752cdd05da72.png)

保存された情報は、的中レースを選択するといつでも参照できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/601e13ea-81a7-c72e-dcf5-97da8d7148e2.png)

もし、永続的に記録したい場合は、エクスポートボタンを押すと、JSONファイルに出力されます。他のPCやスマホから参照する場合も同期したいところかと思いますので、このJSONファイルを使うとよいかと思います。

#herokuにデプロイする

ちょうど、前回の投稿で、herokuにデプロイする手順をまとめたので、アップロードしてみてください。
　[GitHubまたはGiteaからHerokuにデプロイする手順](https://qiita.com/poruruba/items/f4f8726ab315b3f6add0)

```
> git clone https://github.com/poruruba/Keiba_Kanri.git
> cd Keiba_Kanri
> heroku create
> git push heroku main
> heroku open
```

はい、これだけで、Node.jsサーバが立ち上がって、ブラウザからアクセスできました！

と思ったら、ボタンを押すだけで、GitHubからHerokuへの自動デプロイできるのですね。。。以下に従って、app.jsonを配置しました。
https://devcenter.heroku.com/articles/heroku-button

結局のところ、以下のURLをブラウザで立ち上げた後に表示されるページにある紫色のHerokuボタンでOKでした。
https://github.com/poruruba/Keiba_Kanri

すると、こんな画面が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/04fa674a-4f53-a355-4404-cf8fdf6dc26d.png)


以上