2020年2月2日日曜日

[Python]JRAのWEBページをPythonでスクレイピングしてみる(その2)

 以前使用していたJRAのホームページをスクレイピングする自作プログラムをリファクタリングした(スクレイピングの性質上対象となるページが変われば都度の変更を余儀なくされるが、それが重なって酷く見にくくなったためと、よりPythonらしくしたかったため)。これを期にメモ代わりにまとめておきたいと思います。
 それと「BeautifulSoupを使ったスクレイピングはそんなに難しくない」の参考になればとと思い記しておきます。


使用モジュール


 JRAのホームページに限らずWEBページをスクレイピングするには以下のものがあればよいかと思います。


 requestsはその名前の通りサーバのリクエストを発行するために使用します。bs4は所謂BeautifulSoupと言われるものでHTMLからタグやらテキストやらの抽出に使用します。lxmlはbs4の後ろで動かすHTMLパーサとして使います。htmlモジュールでも使用することができるようですが、時々パースエラーを出したり、遅いと思われたりしたのでlxmlを使用しています。reは正規表現を行うモジュールでHTMLに限らず文字列から特定の文字を抽出する場面ではよく使われます。
 re以外はpipでインストールすることができます(が、lxmlなんかはxml関連の外部ライブラリーに依存しているので環境によっては再ビルド行ったりします。Windowやインテル系チップ上のLinuxならば多分大丈夫だとは思いますが、コンパイラやら外部ライブラリのヘッダファイルやらが必要だったりしますので...まぁ、御健闘をお祈りいたします)。

以上の事から、次のようにモジュールをインポートしました。

import requests
import bs4
from bs4 import BeautifulSoup
import re


doAction


 JRAのホームページで遷移を行う大部分はdoActionというJavaScript関数によって行われます。そこで、以下のようなdoActionと同じ動作をする関数を用意します。

def doAction(arg1, arg2):
    with requests.post('http://'+JRABase+arg1, data='cname='+arg2) as r1:
        r1.encoding = 'Shift_jis'
        bs_obj = BeautifulSoup(r1.text, 'lxml')
    return bs_obj


 この関数は<a ... onclick="return doAction(arg1, arg2);">...</a>の様なリンクをクリックするのと同じ動作をします。with文はクローズが必要なオブジェクトに対する操作を記述するのに便利な仕組みで、Python2系の開発が終わったので使いました。この様に使う限り(この資源の)クローズのし忘れはないと看做せるので精神衛生上(?)良いです(特にデバッグの時なんか)。

メニュー取得


 doActionの2番めの引数('pw01hde10062020010820200125/3A'みたいなやつ)の構成がわかれば直に希望するページに飛べるのですが後ろ3桁の作り方がいまいち解らないので、メニューから芋づる式に必要なページを辿る事にします。ちなみにdoActionの1番めの引数は

"accessI" # 開催お知らせ/詳細
"accessD" # 出馬表
"accessO" # オッズ
"accessH" # 払戻金
"accessS" # レース結果
"accessT" # 特別レース登録馬
"accessR" # 競走馬検索
"accessV" # 競走馬登録・抹消一覧

となっているようです。

2番めの引数は(_は実際にはついていませんがわかりやすくするために付けました)、
pw01srl10_06_2020_01_08_2020_01_25_/56
|       |  |          |          +-- 不明(チェックデジット?)
|         |  |          +------------- 開催年月日
|         |  +------------------------ 開催回日
|         +--------------------------- 競馬場コード
+------------------------------------- コンテンツによりほぼ固定(?)

 不明な部分が2つ(実質は最後の3桁)ですが、メニューから辿ればあまり気にする必要は無くなります。

 そんな理由で、トップのページのコンテンツを抽出するには以下の様な関数を使用します。

def getTopPage():
    r = requests.get('http://'+JRABase)
    r.encoding = 'Shift_jis'
    bs_obj = BeautifulSoup(r.text, 'lxml')
    return bs_obj

 この関数はJRAのトップページをBeautifulSoupのオブジェクトで返すので以降はBeautifulSoupのお作法に従ってコンテンツを操作出来ます。

 トップページのコンテンツが抽出できたら、次のような関数でコンテンツの中の要素を取り出します。

ACTION_PATTERN = re.compile('.*doAction.*')

def __getActions(bs_obj):
    actArgs = [__getActionArgs(x)
               for x in bs_obj.find_all(attrs={'onclick':ACTION_PATTERN})]
    return actArgs

 引数bs_objにはgetTopPage関数が返した値を与えます。ここで使用しているgetActionArgs関数は、以下のようになものです。

ACTION_ARGS = re.compile(""".*doAction[(][ ]*([^,]*)[ ]*,[ ]*([^)]*)[ ]*[)].*""")

def getActionArgs(act):
    m = ACTION_ARGS.match(str(act))
    if m is None:
        return (None, None, None)
    return (dropQuote(m[1]),
            dropQuote(m[2]), 
            dropQuote(act.text).strip() if type(act) is bs4.element.Tag else None)


 dropQuote関数は引用符で囲まれた文字列から引用符を取り去るもので、

def dropQuote(s, qt=None):
    if qt is None:
        qt = s[0]
    if not (qt in ["'", '"']):
        return s
    return dropQuote(s[1:-1], qt) if s[-1] == qt else s


といったもので、本当はもっと簡単なものでよいのかもしれませんがスニペットとして登録してあるのでこれを使いました(不都合がない場合は実績があるコードを使い回した方が無難です)。
getActions関数を評価(LispやらHaskell,Erlangやらを使うことが多くなったもので、関数は実行するものではなく"評価"するという風に思っている。そう、関数は実行するものではなく値を求めるものなんです)すると次のような値が得られます。


[
  ('/JRADB/accessI.html', 'pw01ide01/4F', '開催お知らせ'),
  ('/JRADB/accessD.html', 'pw01dli00/F3', '出馬表'),
  ('/JRADB/accessO.html', 'pw15oli00/6D', 'オッズ'),
  ('/JRADB/accessH.html', 'pw01hli00/03', '払戻金'),
  ('/JRADB/accessS.html', 'pw01sli00/AF', 'レース結果'),
  ('/JRADB/accessT.html', 'pw03trl00/29', '特別レース登録馬'),
  ('/JRADB/accessR.html', 'pw02uliD19999', '競走馬検索'),
  ('/JRADB/accessV.html', 'pv156liH1/98', '競走馬登録・抹消一覧'),
  ('/JRADB/accessD.html', 'pw01dli00/F3', '出馬表'),
  ('/JRADB/accessO.html', 'pw15oli00/6D', 'オッズ'),
  ('/JRADB/accessS.html', 'pw01sli00/AF', 'レース結果'),
  ('/JRADB/accessH.html', 'pw01hli00/03', '払戻金'),
  ('/JRADB/accessT.html', 'pw03trl00/29', '特別レース登録馬'),
  ('/JRADB/accessI.html', 'pw01ide01/4F', '詳細')
]


 この部分は(多分)そうそう変わるものではないので1日一回とか取り出しておいて、ファイルなどに保存しておけば良いと思います。リストの各要素の1番目と2番目のデータをdoAction関数に与えて評価すればそれぞれのページに遷移することが出来ます。

オッズのトップメニュー(開催選択)取得


 ('/JRADB/accessO.html', 'pw15oli00/6D', 'オッズ')の部分を使って、次のような関数を用意します。

KAISAI_NAME_PATTERN = re.compile('.*([1-9])回'+JYO_PATTERN+'([1-9][1-9]?)日.*')

def getOddsTop(s=None):
    if s is None:
        bs_obj = __doAction('/JRADB/accessO.html', 'pw15oli00/6D')
    else:
        bs_obj = BeautifulSoup(s, 'lxml')
    return bs_obj


def getOddsTopActs(page):
    acts = [__getActionArgs(x) 
            for x in page.select('div.content a[href="#"][onclick*="doAction"]') if KAISAI_NAME_PATTERN.search(str(x)) is not None]
    return acts


引数なしでgetOddsTop関数を評価し、その値をgetOddsTopActsに与えて評価することによって次のような値を得ることが出来ます。

[
  ('/JRADB/accessO.html', 'pw15orl10052020010120200201/C2', '1回東京1日'),
  ('/JRADB/accessO.html', 'pw15orl10082020020120200201/D1', '2回京都1日'),
  ('/JRADB/accessO.html', 'pw15orl10102020010520200201/45', '1回小倉5日'),
  ('/JRADB/accessO.html', 'pw15orl00052020010220200202/C9', '1回東京2日'),
  ('/JRADB/accessO.html', 'pw15orl00082020020220200202/D8', '2回京都2日'),
  ('/JRADB/accessO.html', 'pw15orl00102020010620200202/4C', '1回小倉6日')
]

 このデータを使用すると各開催の各レース毎のオッズ選択するページに遷移することが出来ます。

各開催のレース毎オッズ選択メニュー取得 


 ここまでデータを抽出できたら、あとは個別にページの各要素にアクセスする部分を以下のような関数で実装します。

def getKaisaiOddsActs(page):
    tblRows = page.select('table#race_list tbody tr')
    tblRecs = [{
     'time': x.find('td', class_='time').string,
     'race_name1': x.select('td.race_name li')[0],                   # 本題
     'race_name2': x.select('td.race_name li')[1],                   # 副題
     'tanpuku': __getActionArgs(x.select_one('li.tanpuku a')),       # 単勝/複勝
     'wakuren': __getActionArgs(x.select_one('li.wakuren a')),       # 枠連
     'umaren': __getActionArgs(x.select_one('li.umaren a')),         # 馬連
     'wide': __getActionArgs(x.select_one('li.wide a')),             # ワイド 
     'umatan': __getActionArgs(x.select_one('li.umatan a')),         # 馬単
     'trio': __getActionArgs(x.select_one('li.trio a')),             # 3連複
     'tierce': __getActionArgs(x.select_one('li.tierce a')),         # 3連単
     } for x in tblRows]
    return tblRecs


def getKaisaiOddsTable(oddsTopActs):
    oddsTbl = [(x[2], getKaisaiOddsActs(__doAction(x[0], x[1]))) 
                    for x in oddsTopActs]
    return oddsTbl


 getKaisaiOddsTable関数にgetOddsTopActs関数で得られた値を与えて評価すると以下のような値を得ることができます。

[
  ('1回東京1日', 
    [{'time': '発走済', 
      'race_name1': '3歳未勝利[指定]', 
      'race_name2': '\xa0', 
      'tanpuku': ('/JRADB/accessO.html', 
                  'pw151ou1005202001010120200201Z/C7', '単勝複勝'), 
      'wakuren': ('/JRADB/accessO.html', 
                  'pw153ou1005202001010120200201Z/CF', '枠連'), 
      'umaren': ('/JRADB/accessO.html', 
                 'pw154ou1005202001010120200201Z/53', '馬連'), 
      'wide': ('/JRADB/accessO.html', 
               'pw155ou1005202001010120200201Z/D7', 'ワイド'), 
      'umatan': ('/JRADB/accessO.html', 
                 'pw156ou1005202001010120200201Z/5B', '馬単'), 
      'trio': ('/JRADB/accessO.html', 
               'pw157ou1005202001010120200201Z99/41', '3連複'), 
      'tierce': ('/JRADB/accessO.html', 
                 'pw158ou1005202001010120200201Z/63', '3連単')}, 
                         :
                         : 12-2レース分繰り返す
                         :
    ]
  ),
                         :
                         : 開催分-1繰り返します
                         :
]


 ここまで(doActionの引数を)抽出できればあとはこれらをもとに望むレースの望む券種のオッズを取得できます。

doActionの第2引数は券種やらレース番号などが増えて長くなっていますが、(多分)以下のようになっていると思われるので慣れてくると見るとなんの要求か大体わかるようにまります。というか、券種と馬番順(u)/人気順(p)でどういう抽出を行うかを決めることができます。

pw15_1_o_u_10_05_20200101_01_20200201_Z/C7
     | | |    |  |        |  |        +- チェックデジット
     | | |    |  |        |  +---------- 開催日(年月日)
     | | |    |  |        |              この例だと2020年02月01日
     | | |    |  |        +------------- レース番号
     | | |    |  |                       この例だと第01競走
     | | |    |  +---------------------- 開催日(回日)
     | | |    |                          この例だと2020年1回1日目
     | | |    +------------------------- 競馬場コード
     | | |                               この例だと東京競馬場
     | | +------------------------------ 馬番順(u)/人気順(p)
     | +-------------------------------- オッズ
     +---------------------------------- 券種
                                         この例だと単勝・複勝


 ここで出てくる競馬場コードというのは中央競馬だと以下のようになっています。他にも(出走記録とかの管理上)地方競馬場や海外のコードもあるのですが”Out of 眼中”なのでコード化していません(^^;とはいえ一番良く使いしかも一番長いこと変更されずに生き延びている便利なコードですww。

JYOCD = {
 '01': {'name':'札幌競馬場', 'l1':'札', 'l2':'札幌', 'l3':'札幌', 'en':'SAPPORO'},  
 '02': {'name':'函館競馬場', 'l1':'函', 'l2':'函館', 'l3':'函館', 'en':'HAKODATE'},  
 '03': {'name':'福島競馬場', 'l1':'福', 'l2':'福島', 'l3':'福島', 'en':'FUKUSHIMA'},  
 '04': {'name':'新潟競馬場', 'l1':'新', 'l2':'新潟', 'l3':'新潟', 'en':'NIIGATA'},  
 '05': {'name':'東京競馬場', 'l1':'東', 'l2':'東京', 'l3':'東京', 'en':'TOKYO'},  
 '06': {'name':'中山競馬場', 'l1':'中', 'l2':'中山', 'l3':'中山', 'en':'NAKAYAMA'},  
 '07': {'name':'中京競馬場', 'l1':'名', 'l2':'中京', 'l3':'中京', 'en':'CHUKYO'},  
 '08': {'name':'京都競馬場', 'l1':'京', 'l2':'京都', 'l3':'京都', 'en':'KYOTO'},  
 '09': {'name':'阪神競馬場', 'l1':'阪', 'l2':'阪神', 'l3':'阪神', 'en':'HANSHIN'},  
 '10': {'name':'小倉競馬場', 'l1':'小', 'l2':'小倉', 'l3':'小倉', 'en':'KOKURA'}  
}

 使い方は推して知るべしということで。

オッズを取得する


 最終的なオッズページのコンテンツを抽出できたら以下の様な関数でオッズを取得します。
def getTanpukuOddsRec(idx, updt, x):
    rec = {}
    rec["raceIndex"] = idx
    rec["updateTime"] = updt
    try:
        rec["num"] = x.select_one('td.num').text
        rec["horse"] = x.select_one('td.horse').text
        rec["odds_tan"] = x.select_one('td.odds_tan').text
        rec["odds_fuku_min"] = x.select_one('td.odds_fuku span.min').text
        rec["odds_fuku_max"] = x.select_one('td.odds_fuku span.max').text
        rec["age"] = x.select_one('td.age').text
        rec["weight"] = x.select_one('td.weight').text
        rec["jockey"] = x.select_one('td.jockey').text
        rec["trainer"] = x.select_one('td.trainer').text
    except Exception as e:
        print(e)
    return rec


def getTanpukuOdds(page):
    raceIndex = page.select_one('div.header_line.no-mb span.opt').text
    updateTime = page.select_one('div.refresh_line div.cell.time').text
    rows = page.select('div#odds_list table tbody tr')
    tanpukuRecs = [
        getTanpukuOddsRec(raceIndex, updateTime, x)
        for x in rows]
    return tanpukuRecs

 getOddsTopActs関数で得られたデータから欲しい開催とレース番号、券種でdoActionの引数を探して、これをdoActionに与えて評価しその値をgetTanpukuOddsに与えて評価すると以下のような値を得ることができます。

[{
   'raceIndex': '2020年2月1日(土曜)1回東京1日 1レース', 
   'updateTime': '最終オッズ', 
   'num': '1', 
   'horse': 'エストラード', 
   'odds_tan': '8.3', 
   'odds_fuku_min': '2.2', 
   'odds_fuku_max': '3.2', 
   'age': '牡3', 
   'weight': '56.0', 
   'jockey': '江田 照男', 
   'trainer': '武市 康男'},
                :
                : 出走馬分-1繰り返す
                :
]


 さすがに、この辺になってくるとページべったりの記述になってしまいますが関数を分けて、エラーを気にしなければならない部分とエラーは低位に任せとけ的な部分にして包括リストを生成構文に嵌め込んでしまいます。また、他のページではgetTanpukuOddsRecに相当する部分が(ページに合わせて)変わってくるはずです。まぁ、実際のHTMLを見ると共通して使えそうな関数を作れそうな気がしますが。

 こんなやつをDBに入れるなりファイルに保存するなり、はたまたJSONにしてどっかにぶん投げるなりpandasオブジェクトに入れ込むなんかすればスクレイプ終了です。

最後に


 リストの包括記述は結構インパクトありますね。以前はforループでぶん回して中で各要素をappendしていましたがこっちのほうがスッキリして良い感じです。また、ものによってはappendが高くつくものがあったりするので毎回appendしなくて良くなると結構パフォーマンス的にも有利だと思います。

 あとBeautifulSoupですがDOMにアクセスするようにfindが使えたりcssのセレクタでアクセスするようにselectが使えたりで結構気に入っています。reモジュールとの組み合わせも割と自然にできて結構複雑そうな要素選択もサラッとできる点も気に入っています。他にも似たようなパッケージがあると思いますがまぁ好み(や、プロジェクトの縛り)によって様々ということで。

 何にせよ、変なループがなくなってスッキリしました。

 次は出馬表の柱と馬毎データを抽出する部分のリファクタリングをしようと思います。そうすれば「やった!」感が出てくるでしょう。

0 件のコメント:

コメントを投稿