Posted at

python3 クローリング&スクレイピング


pythonは何でもできるすごい奴

pythonの何がいいってやっぱりライブラリを始め環境が整っていることだよね。

今回はライブラリ"requests"と"Beautiful Soup"を使ってweb上のデータを分析するためのcsvファイルを作っていきたいと思います。


スクレイピングとクローリング

ウェブサイトからhtmlをぶっこ抜いてきて(クローリング)、それをタグを頼りに必要な情報だけを抜き出し(スクレイピング)て出力することを目的にする。

今回は「競馬の結果をウェブサイトから引っ張ってきて分析の為にcsvに落とし込む」。

実行環境は


  • OS : Windows10

  • Python3.6.5


webクローリング

目標のウェブサイトをhttp://sp.netkeiba.com/ とします。

ここにアクセスしてhtmlを取得していきます。

ぶっこ抜くにも1986年からのデータがそろっているのでどこを抜いたらいいのか考えます。

http://race.sp.netkeiba.com/?pid=race_result&race_id=201805040201&rf=rs

このURLを見るとrace_idというのが指定されていますね。ここをいじっていきましょう。

2018:05:04:02:01と分けることができ、2018が年、05がどうやら東京、04:02あたりがレース日、01がレースの番号みたいですね。

掘っていくと2008年以降とその前ではレイアウトが変わっていました。とりあえずここ10年のデータを掘り下げる方向でいきましょう。(暇だったら追記するかも)

細かいところまで見ようか迷ったけど、とりあえず走らせてエラー出したところは無視していくスタイルでいきましょう。


  • 年 2008-2018

  • 場所(中央競馬)
    札幌:01
    函南:02
    福島:03
    新潟:04
    東京:05
    中山:06
    中京:07
    京都:08
    阪神:09
    小倉:10

  • レース 01-12

アルゴリズムはそのままfor文でぶん回すだけです。

O(n^4)とかいうクソアルゴリズムでも気にしたら負けです。

多分もっといい方法がある。(知らんけど)


スクレイピング

スクレイピングはタグを頼りに内容を振り分けていく作業です。

import requests

from bs4 import BeautifulSoup

html = requests.get(url)
soup = BeautifulSoup(html.text, 'html.parser')

これでsoupに抜いてきたhtmlをタグでパサーしたものが入ります。

さてこのどこを抜いて

右クリックして検証を押すとこんな画面になります。



ここでほしいデータを見ます。ここでは6着ダノンスイショウのジャッキーを調べています。

spanタグのclass Detail_Rightにジャッキーの名前と体重が入っています。

type(soup.find_all('span', attrs={'class', 'Detail_Right'}))

# bs4.element.ResultSet

find_allメソッドはそのタグを含む箇所をリストにして返します。pythonの指定語と被ってしまう'class'などはattrsで指定します。

soup.find_all('span', attrs={'class', 'Detail_Right'})[5]

'''Output
<span class="Detail_Right">
三浦<br/>
(55.0)
</span>
'''

中身を取り出す方法はstringとcontentsが主な方法です。

指定したタグ(今回はspan)の中にタグが含まれているとstringが無効になってしまうので個人的にオススメはcontentsです。contentsは中身がリストになって返ってきます。

soup.find_all('span', attrs={'class', 'Detail_Right'})[5].contents

# ['\n三浦', <br/>, '\n(55.0)\n']

このとき中身に改行文字がついてきてしまうのでstrip()などで適宜成型してください。


結局どうなるのさ

こんな感じのことを各ラベルについて繰り返していくと以下のようなコードになります。

import requests

from tqdm import tqdm
import time
from bs4 import BeautifulSoup
import pandas as pd

def numStr(num):
if num >= 10:
return str(num)
else:
return '0' + str(num)

Base = "http://race.sp.netkeiba.com/?pid=race_result&race_id="
dst = ''
df_col = ['year', 'date', 'field', 'race', 'race_name'
, 'course', 'head_count', 'rank', 'horse_name'
, 'gender', 'age', 'trainerA', 'trainerB', 'weight', 'c_weight', 'jackie', 'j_weight'
, 'odds','popu']
df = pd.DataFrame()

for year in tqdm(range(2008, 2019)):
for i in tqdm(range(1, 11)):
for j in tqdm(range(1, 11)):
for k in tqdm(range(1, 11)):
for l in range(1, 13):
# urlでぶっこ抜く
url = Base + str(year) + numStr(i) + numStr(j) + numStr(k) + numStr(l)
time.sleep(1)
html = requests.get(url)
html.encoding = 'EUC-JP'

# scraping
soup = BeautifulSoup(html.text, 'html.parser')
# ページがあるかの判定
if soup.find_all('div', attrs={'class', 'Result_Guide'})!=[]:
break
else:
#共通部分を抜き出す
CommonYear = year
CommonDate = soup.find_all('div', attrs={'class', 'Change_Btn Day'})[0].string.strip()
CommonField= soup.find_all('div', attrs={'class', 'Change_Btn Course'})[0].string.strip()
CommonRace = soup.find_all('div', attrs={'Race_Num'})[0].span.string
CommonRname= soup.find_all('dt', attrs={'class', 'Race_Name'})[0].contents[0].strip()
CommonCourse= soup.find_all('dd', attrs={'Race_Data'})[0].span.string
CommonHcount= soup.find_all('dd', attrs={'class', 'Race_Data'})[0].contents[3].split()[1]

for m in range(len(soup.find_all('div', attrs='Rank'))):
dst = pd.Series(index=df_col)
try:
dst['year'] = CommonYear
dst['date'] = CommonDate
dst['field']= CommonField #開催場所
dst['race'] = CommonRace
dst['race_name'] = CommonRname
dst['course'] = CommonCourse
dst['head_count'] = CommonHcount #頭数
dst['rank'] = soup.find_all('div', attrs='Rank')[m].contents[0]
dst['horse_name'] = soup.find_all('dt', attrs=['class', 'Horse_Name'])[m].a.string
detailL = soup.find_all('span', attrs=['class', 'Detail_Left'])[m]
dst['gender'] = list(detailL.contents[0].split()[0])[0]
dst['age'] = list(detailL.contents[0].split()[0])[1]
dst['trainerA'] = detailL.span.string.split('・')[0]
dst['trainerB'] = detailL.span.string.split('・')[1]
if len(detailL.contents[0].split())>=2:
dst['weight'] = detailL.contents[0].split()[1].split('(')[0]
if len(detailL.contents[0].split()[1].split('('))>=2:
dst['c_weight'] = detailL.contents[0].split()[1].split('(')[1].strip(')') #多分馬の体重変動
detailR = soup.find_all('span', attrs=['class', 'Detail_Right'])[m].contents
if "\n" in detailR or "\n▲" in detailR or '\n☆' in detailR:
detailR.pop(0)
dst['jackie'] = detailR[0].string.strip()
dst['j_weight'] = detailR[2].strip().replace('(', '').replace(')', '') #多分jackieの体重変動
Odds = soup.find_all('td', attrs=['class', 'Odds'])[m].contents[1]
if Odds.dt.string is not None:
dst['odds'] = Odds.dt.string.strip('倍')
dst['popu'] = Odds.dd.string.strip('人気') #何番人気か
except:
pass
dst.name = str(year) + numStr(i) + numStr(j) + numStr(k) + numStr(l) + numStr(m)

df = df.append(dst)

df.to_csv('keiba_PS.csv', encoding='shift-jis')

(クソアルゴリズムで)すごい時間かかるのでサーバーかなんかに投げることをお勧めします。

たぶん1レコードごとにSerise作ってくっつけているので時間がかかるんだと思います(じゃあ直せよってね)


最後に

ご承知とは思いますが、requestはやりすぎるとサーバー負荷になるので少なくとも1秒は間をあけるのがマナーです。

こんなことをする前にAPIを探してホントにそういうことをできるものはないのか調べるのが妥当です。クローリング、スクレイピングでデータをサルベージするのは最終手段というのを覚えておいていただきたいです。

※tqdmでなんかruntimeエラー吐いたりするので(調べたらなんかtqdmのバグなのか??)困ったら消してください。