8
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

競馬サイトをクローリング&スクレイピングしてみた その2

Last updated at Posted at 2019-11-04

###はじめに
「Pythonクローリング&スクレイピング[増補改訂版]―データ収集・解析のための実践開発ガイドー」第3章までの知識を用いてオリジナルプログラムを作成する。

今回はnetkeibaの競走馬検索機能の検索結果から個別ページのURLを取得し、そのURLにアクセスして各馬の情報を取得し、データベースに保存する、というプログラムを作成した。

###検索結果ページの構造
検索窓は基本netkeiba内の最上部にあるので割愛。
詳細検索機能では血統、毛色、表示順などいろいろな検索条件が指定できるが、
今回は検索窓を使用せず、ディープインパクトの詳細ページの繁殖成績欄から検索結果ページに飛んだ(この辺、のちに記述するJavaScript絡みと関係あり)

(赤丸で囲んだリンクをクリック)

まずこちらが検索結果のページ。検索結果はデフォルトで賞金順でソートされている。
今回はこの検索結果とリンクされる各馬のページをスクレイピングの対象とする。

この検索結果ページからも馬名、厩舎、血統、馬主などの情報がわかるが、まずは各馬の詳細ページのURLを取得することに専念する。
各馬名は馬の詳細ページへのリンクとなっており、そのURLの末尾は生年+六桁の数字となっている。

検索結果ページのURLパラメータにはsire_idとしてディープインパクトの詳細ページの末尾の数字「2002100816」が指定されており、ディープインパクトを父に持つ馬が絞り込まれている。(一番上に「の検索結果」とあるがこの部分は馬名で検索すると「(馬名)の検索結果」となる。)

今回は最初の結果ページのみならず「次」クリックして飛べる2ページ以降も続けて取得したいがその部分のリンクは

<a href="javascript:paging('2')">次</a>

みたいになっていて、クリックすると次のページが表示されるものの
URLが https://db.netkeiba.com/ になっていて、POSTを使って画面遷移が行われていることがわかる。(これに関しては詳細検索フォームを使ったときの検索結果ページも同じである。)
実際のpaging関数の部分は

function paging(page)
{
document.sort.page.value = page;
document.sort.submit();
}

になっていてjavascriptを使ってsubmitされていることがわかる。
ブラウザの検証ツールを起動させて「次」をクリックすると
2019-11-03 19.04.56 db.netkeiba.com 06565a7f139a.png
こんな感じの値がPOSTされているので、
db.netkeiba.com/?pid=horse_list&_sire_id=2002100816&page=(ページ番号)
という風に検索結果ページのURLの末尾にpageのパラメータを指定してあげると2ページ以降もGETで取得することができる。

###詳細ページの構造
今回は詳細ページのメインコンテンツ上部の馬名・現役or抹消・性別・毛色、
生年月日などの情報が入ったテーブル、その下にある血統のテーブルを解析して、その結果をデータベースに保存する。

2019-10-20 23.17.03 db.netkeiba.com f349a0d9a4b6.png
2019-10-20 23.23.46 db.netkeiba.com a15e20bf34a7.png

上2枚は記事執筆時点でのディープインパクト産駒の獲得賞金1位・2位の2頭の詳細ページだが、生年月日などのデータが表示されているテーブルの項目数が違うことに気がつく。
netkeibaではいわゆる一口クラブの所有馬のページでは「募集情報」の項目が追加されるため、非クラブ馬とはテーブルの項目数が異なるため、スクレイピングの際はこのことを頭に入れなくてはならない。

また(今回対象とするディープインパクト産駒はほぼ関係ないが)地方競馬所属馬や外国産馬はそれぞれ馬名の前□地、○外みたいな記号がつくため、これを除いて馬名のみ取得する。
また下の1枚目の画像を見るとわかるように、□地(地方競馬所属馬)には現役or抹消の表記がないためここにも配慮してスクレイピングする。
2019-10-27 23.08.38 db.netkeiba.com 2cc6e24ab680.png
2019-10-27 23.08.53 db.netkeiba.com 86af2843684b.png

###完成したコード

keiba_scraping.py
import requests
import lxml.html
import time
from pymongo import MongoClient
import re
import sys

def main(sire_id,n):
    client = MongoClient('localhost', 27017) # ローカルホストのMongoDBに接続する。
    collection = client.scraping.horse_data #scrapingデータベース。無ければ作成
    collection.create_index('key', unique=True) #データを一意に識別するキーを格納するkeyフィールドにユニークなインデックスを作成する。

    session = requests.Session()

    for i in range(n):
        response = session.get("https://db.netkeiba.com/?pid=horse_list&_sire_id=" + sire_id + "&page=" + str(i))
        response.encoding = response.apparent_encoding #エンコーディングをappearent_encodingで推測したものに変更
        urls = scrape_list_page(response) #詳細ページのURL一覧を得る
        for url in urls:
            key = extract_key(url) #URLの末尾の数字をキーとして取得
            h = collection.find_one({'key': key}) #該当するkeyのデータを検索
            if not h: #DBに存在しない場合
                time.sleep(1) #一秒ごとに実行(取得先サイトの負担軽減)
                response = session.get(url)#詳細ページを取得
                horse = scrape_horse_page(response)#詳細ページをスクレイピング
                collection.insert_one(horse)#馬の情報をDBに保存

def scrape_list_page(response):#詳細ページのURLを抜き出すジェネレータ関数
    html = lxml.html.fromstring(response.text)
    html.make_links_absolute(response.url)
    for a in html.cssselect('#contents_liquid > div > form > table > tr > td.xml.txt_l > a'):
        url = a.get("href")
        yield url

def scrape_horse_page(response):#詳細ページを解析
    response.encoding = response.apparent_encoding #エンコーディングを指定
    html = lxml.html.fromstring(response.text)

    #ページ上部から名前、現役or抹消,性別,毛色の情報を取得
    for title in html.cssselect('#db_main_box > div.db_head.fc > div.db_head_name.fc > div.horse_title'):
        name = parse_name(title.cssselect('h1')[0].text.strip()) #馬名を取得。stripで余計な空白文字を削除し、parse_nameに渡す
        #現役か抹消か、性別、毛色はスペース区切りの文字列となっているのでsplitで分割してmapでそれぞれ変数に収めている
        data = title.cssselect('p.txt_01')[0].text.split()
        if len(data) > 2:
            status,gender,color = map(str,data)
        else:
            gender,color = map(str,data) #地方馬だと現役末梢の情報がないので
            status = None

    #血統のテーブルから父・母・母父の情報を取得
    for bloodline in html.cssselect('#db_main_box > div.db_main_deta > div > div.db_prof_area_02 > div > dl > dd > table'):
        sire = bloodline.cssselect('tr:nth-child(1) > td:nth-child(1) > a')[0].text
        dam = bloodline.cssselect('tr:nth-child(3) > td.b_fml > a')[0].text
        broodmare_sire = bloodline.cssselect('tr:nth-child(3) > td.b_ml > a')[0].text

    club_info = html.cssselect('#owner_info_td > a') #クラブ馬の募集価格を表示
    for data in html.cssselect('#db_main_box > div.db_main_deta > div > div.db_prof_area_02 > table'):
        birthday = data.cssselect('tr:nth-child(1) > td')[0].text #誕生日の情報を取得し、date型に変換
        trainer = data.cssselect('tr:nth-child(2) > td > a')[0].text #調教師の情報を取得
        owner = data.cssselect('tr:nth-child(3) > td > a')[0].text #馬主の情報を取得
        #クラブ馬の場合は生産者以下の::nth-childの数値が一つずつずれるのでclub_infoの要素がある場合に1足している
        if len(club_info) > 0:
            breeder = data.cssselect('tr:nth-child(5) > td > a')[0].text #生産者
            prize_money = data.cssselect('tr:nth-child(8) > td')[0].text.strip().replace(' ','') #賞金stripで両端の空白を取り除き、replaseでテキスト内のスペースを除去
        else:
            breeder = data.cssselect('tr:nth-child(4) > td > a')[0].text
            prize_money = data.cssselect('tr:nth-child(7) > td')[0].text.strip().replace(' ','')


    horse = {
        'url': response.url,
        'key': extract_key(response.url),
        'name':name,
        'status': status,
        'gender':gender,
        'color':color,
        'birthday':birthday,
        'sire':sire,#父
        'dam':dam,#母
        'broodmare_sire':broodmare_sire,#母父
        'owner':owner,
        'breeder':breeder,
        'trainer':trainer,
        'prize_money' : prize_money
    }

    return horse

def extract_key(url):
    m = re.search(r'\d{10}', url).group() # 最後の/から文字列末尾までを正規表現で取得。
    return m

def parse_name(name):
    m = re.search(r'[\u30A1-\u30FF]+', name).group() #○地とか□地の馬から馬名のみ取り出す。カタカナの正規表現パターンにマッチする部分を取り出せばおk
    return m

if __name__ == "__main__":
    main(sys.argv[1],int(sys.argv[2]))#コマンドライン引数から産駒の検索結果一覧を取得したい馬のURL末尾の数字と検索ページ結果の取得ページ数を取得しmain関数を呼び出す
python keiba_scraping.py 2002100816 4

ディープインパクト産駒の一覧を4ページ取得の条件で実行し、
mongoのシェルからhorse_dataのコレクションを全件表示しますと…
mongopng.png
キタ━━━━━━(゚∀゚)━━━━━━ !!!!!
色々検索条件を指定して絞りこむこともできる
mongo2.png

というわけでひとまず完成ということで

###おわりに
大したプログラムじゃない割にモチベーションや時間の問題で記事を投稿するのに時間がかかってしまいました。
追加したい機能としてはディープインパクト産駒からさらにその産駒を・・・みたいな再帰的なページの取得といったあたりでしょうか。
参考するところがあったり、面白いと思ったらいいね押してくださるとありがたいです。
また、疑問点・ツッコミ所はコメントに是非お願いします。

8
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?