概要
Webスクレイピングとは、Webサイトにあるテキスト情報やデータをプログラムを活用して自動でデータ収集ができるコンピュータソフトウェア技術です。今回、Webスクレイピングを0から開発するフローについて実際のコードを活用して説明します。この記事を一通り演習することでWebスクレイピングの基礎をマスターできます!
こんな方に読んで欲しい
・プログラミング未経験だけど何か作れるようになりたい
・RPAに興味がある
・Webスクレイピングのスキルを身に付けたい
開発環境
開発は「Jupyter Notebook」を活用して説明します。また、Python環境であれば開発は可能なので、その他開発ツールでも問題ないです。最低限のライブラリのダウンロードができるので事前に「Anaconda」をインストールしておくことをオススメします。なお、本記事では事前の環境構築が済んでいることを前提にしております。また、Webスクレイピングはhtmlのコードを解析しデータ収集を行います。従って、最低限のhtmlの前知識がある前提にしております。
注意事項
Webスクレイピングは対象のWebサイトに自動でアクセスしデータを収集します。そのため、大量のリクエストや連続アクセスをするとクライアントサイドのサーバに負荷をかけることになります。その場合、対象のWebサイト側から「DoS攻撃(サーバーに過剰な負荷をかけてサービスを妨害する攻撃)」 と見なされる場合があります。本ハンズオンではそちらの対策も対応していおりますが、万が一本ハンズオンを参考に開発した結果DoS攻撃と見なされた場合は筆者は一切の責任を負いませんので予めご了承ください。
開発内容
今回のハンズオンではJリーグの公式サイトから前日の試合の観客動員数のデータを収集するプログラムを作成していきます。
Jリーグ公式サイト
使用する技術について
1.Requests&BeautifulSoup
RequestsはPythonでHTTP通信を行うためのライブラリです。BeutifulSoupはHTML/XMLファイル(以下HTMLのみで記載)からデータを抽出するPythonのライブラリです[1]。RequestsとBeautifulSoupを活用したスクレイピングの手順は以下のようになります。
1.RequestsでスクレイピングしたいWebサイトのHTMLのデータを取得
2.BeautifulSoupで取得したHTMLファイルを解析
3.欲しい情報を抜く
Requestsで取得したHTMLのデータはテキストデータとして取得するため、Python上で取得したテキストデータをHTMLとして認識させるためにBeautifulSoupを使って解析し認識させます。この2つの長所はあまり処理負担がかからない点です。一方、静的なWebサイトからでしかデータ収集ができず、動的なWebサイトのデータ収集はできない短所を持っています。
2.Selenium
SeleniumはWebブラウザの操作の自動化を可能にしたPythonライブラリです[2]。WebDriverと呼ばれるツールを活用して実装します。RequestとBeautifulSoupを活用した手法とは異なり、対象のWebサイトに仮想のブラウザがアクセスし表示されているWebサイトのHTMLコードを解析しデータ収集を行います。RequestとBeautifulSoupより処理負担がかかる一方、あらゆるWebサイトからデータ収集ができる長所を持っています。
3.私が開発する時はどちらを選んでいるか
私がWebスクレイピングを開発する際は基本両方活用しています。まず、RequestとBeautifulSoupでデータを引き抜いてみて、動的なサイトでデータの収集ができそうになければSeleniumを活用するといった流れです。
開発フロー
ここからはいよいよ実際に手を動かして行きましょう!
今回、Jリーグの過去の観客動員数のデータを収集したいので下記のリンクを対象にします。
URL:https://www.jleague.jp/match/
上記URLに飛ぶと、下記の上部の図の各試合の「試合詳細」をクリックすることで、下部の図のような観客動員数を確認することができます。そのため、上記URLから各試合の「試合情報」をクリックし別のページに遷移し、遷移したページからデータを収集する必要があります。そのため、各試合の「試合詳細」の箇所から、観客動員数が記載されているページのリンクを取得する機能を作成していきます。
まず、RequestsとBeautifulSoupはAnacondaで標準インストールされないので、ライブラリのインストールを行います。
pip install requests
pip install beautifulsoup
晴れてRequestとBeautifulSoupが活用できる環境が作れたので、Jupyter Notebookを開き、コード書いて行きます。まず、使用するライブラリをインポートします。
import requests
from bs4 import BeautifulSoup
#このライブラリは待機処理の実装の際に使用します
import time
次に対象のWebサイトのHTMLデータを取得します。req = session.get()で指定されたWebサイトの情報を取得し変数reqに格納します。今回はJ_urlに対象のWebサイトのリンクを格納しているので、第1引数をJ_urlにします。サーバはクライアントからアクセスがあった際に負荷がかかります。そのため、time.sleep()では指定の秒数処理を待機させることで、DoS攻撃未遂を未然に防ぎます(サーバは短時間、高頻度で同じIPアドレスからアクセスがあるとその時点でアクセス拒否されるのでその対策のためでもあります)。次にsoup = BeautifulSoup()でrequestsで取得したテキストデータを解析します。第1引数に解析対象のデータ(req.text)、第2引数に使用する解析器を指定します。HTML形式で解析を行うため今回は"html.parser"と指定します。
#対象のWebサイトのリンク
J_url = 'https://www.jleague.jp/match/'
req = requests.get(J_url)
time.sleep(2)
soup = BeautifulSoup(req.text, "html.parser")
次にHTMLコードから欲しい情報箇所のみを抽出します。先ほど抽出したHTMLコードを確認すると、試合日ごとに"matchlistWrap"というクラスで括られている事が確認できます。Google Chromeの検証機能を使っても同様のことが確認できます。今回は各試合日の「試合詳細」のリンク情報が欲しいので、このクラス名"matchlistWrap"のsecssionのみを解析対象とするために、このsecssionで括られたHTMLコードを抽出します。
game_date_section = soup.find_all(class_='matchlistWrap')
~.find_all()では指定したHTMLコードから()で指定した条件を満たす箇所のみのHTMLコードを抽出します。今回はsoupに格納されているHTMLコードの中からクラス名が'matchlistWrap'の箇所のみ抽出したいので変数game_date_sectionにsoup.find_all(class_='matchlistWrap')を格納します。指定したクラス名が複数ある場合、2次元配列でhtmlコードが格納されます。
ex.)指定したクラス名が3つ該当する場合以下のような形式になります。
[
[1つ目のHTMLコード:今回の場合下記図の①の箇所が格納],
[2つ目のHTMLコード:今回の場合下記図の②の箇所が格納],
[3つ目のHTMLコード]
]
今回は解析をする日の前日の試合の観客動員数のデータを抽出したいので、解析をする日の前日の試合日のデータのみ抽出するプログラムを実装します。まず前日の日付を取得するプログラムを実装します。Webサイトやコードを見たところ、試合日の日付の表記の仕方は○○○○年○月○日(○)とあるので同様のフォーマットで日付を取得します。Pythonで日付の取得をする方法は下記リンクを参考にしてください。
・参考資料
日付のフォーマットに関して:https://atmarkit.itmedia.co.jp/ait/articles/2111/09/news015.html
前日の日付算出に関して:https://laboratory.kazuuu.net/find-the-date-of-yesterday-today-and-tomorrow-in-python/
#必要なライブラリのインポート
import datetime
from datetime import timedelta
# 今日の日付を算出
weekday = datetime.date.today().weekday()
w_list = ['(月)','(火)','(水)','(木)','(金)','(土)','(日)']
dt_now = datetime.datetime.now()
# 今日の日付から日付を1日マイナスし、昨日の日付を算出
yesterday = dt_now - timedelta(1)
weekday = yesterday.weekday()
word_month = yesterday.strftime('%B').lower()
year = yesterday.year
month = yesterday.month
day = yesterday.day
yesterday_date = str(year) + '年' + str(month) + '月' + str(day) + '日' + w_list[weekday]
これで変数yesterday_dateに昨日の日付が指定のフォーマットで格納されました。次に前日の試合日のみ抽出するプログラムを実装します。
サッカーの試合は毎日あるわけではないので、前日に試合日がある場合と無い場合が考えられます。前日に試合日があった場合は更なるスクレイピングが必要ですが、前日に試合がなかった場合これ以降のスクレイピング作業は無駄です。そのため、前日の試合の有無を変数proscess_judgeを使って判断します。まず変数process_judgeを文字列'NO'と宣言します。次に変数game_date_sectionから要素を1つずつ取り出し、HTMLコードから試合日を抽出します。変数game_date_sectionとGoogle Chromeの「検証」機能から試合日はクラス名 'leftRedTit'に記載されていることが確認できたので、クラス名 が'leftRedTit'の箇所を抽出します。今回はタグで囲まれたテキスト(下記に示す)を抽出したいので~.find().textを使って抽出します。
<p>この箇所<p>
〜の箇所には抽出対象の要素(今回は変数game_date_sectionから1つずつ取り出した要素)、()の中は指定のクラス名(今回は'leftRedTit')を指定します。今回はタグで囲まれたテキスト情報を抽出したいので末尾に.textをつけます。このようにして抽出した試合日を変数game_dateに格納します。その後、試合日が格納された変数game_dateと前日の日付が格納された変数yesterday_dateを比較し、一致していたら変数ansに変数game_date_sectionの該当の試合日の要素を格納します。そして、変数game_dateと変数yesterday_dateが一致していた場合、前日に試合があったことになるので変数process_judgeを文字列'OK'に更新します。
#変数process_judgeは後で実装するプログラムで使用します
process_judge = 'NO'
for i in range(len(game_date_section)):
game_date = game_date_section[i].find(class_='leftRedTit').text
if game_date == yesterday_date:
ans = game_date_section[i]
process_judge = 'OK'
【補足情報】
find()は先ほど使ったfind_allとは機能が若干違います。find_all()は指定した条件と一致する要素全てを抽出していたのに対し、find()では指定した条件と一致する要素1つのみを抽出します。もし、条件と一致した要素が複数あった場合は一番最初の要素のみが抽出されます。詳しくは下記リンクを参考にしてみてください。
・参考資料
find()とfind_all()の違い:https://kubogen.com/web-programing-252/
次に、前日にあった試合情報から観客動員数の表記があるページのリンクをlist形式で取得します。変数ansには前日の試合情報のデータが格納されています。試合毎に「試合詳細」のリンクを抽出し、変数link_listに格納するフローです。
まず、変数ansから下記の図のように試合毎にHTMLデータを抽出します。
試合毎に括られているdivタグのclass名が'linkWrap'なので、tb_list = ans.find_all(class_='linkWrap')で試合毎のHTMLコードを2次元配列で変数tb_listに格納します。その後、tb_listから1試合ずつHTMLデータを取り出します。次に、HTMLでページ遷移する時に使うaタグの情報を抽出します。item_list = tb_list[j].find_all('a')でtb_listの1試合分のHTMLデータからaタグのHTMLコードをitem_listに格納します。前日の試合でも、まだ試合情報が更新されていないケースが考えられます。そのため、aタグのテキストが「試合情報」かどうかを条件分岐し、条件が一致する場合tmp_link = item_list[i].get('href')でリンク情報が格納されているaタグのhrefのデータをtmp_linkに格納します。前日に複数の試合がある可能性があるためlist変数のlink_listに各試合のリンクを追加して行きます。
if process_judge == 'OK':
tb_list = ans.find_all(class_='linkWrap')
link_list = []
for j in range(len(tb_list)):
item_list = tb_list[j].find_all('a')
for i in range(len(item_list)):
if item_list[i].text == '試合詳細':
tmp_link = item_list[i].get('href')
link_list.append(tmp_link)
list変数のlink_listの中身を確認すると下記のようになっていました(一部のみ抜粋)。恐らく、これはページの内のパスで親のURLと結合することで遷移先のリンクを作成しています。
['/match/j1/2022/073001/live/',
'/match/j1/2022/073008/live/',
'/match/j1/2022/073002/live/',
'/match/j1/2022/073003/live/',
'/match/j1/2022/073004/live/',
'/match/j1/2022/073005/live/'
]
そのため同様の処理を実装し、遷移先のページからデータを取得するプログラムを作成して行きます。まず、親ページのリンクを変数J_parent_urlに格納します。次に、先述同様対象のページのリンクを指定し、HTMLコードの解析ができるようにします。前回と異なるのは親ページのリンクと先ほど取得したページのパスをJ_parent_url + link_list[j]のように結合し実装しています。
J_parent_url = 'https://www.jleague.jp'
for j in range(len(link_list)):
# 各試合の詳細ページに遷移
req = session.get(J_parent_url + link_list[j])
time.sleep(2)
soup = BeautifulSoup(req.text, "html.parser")
それではここから観客動員数のデータを抽出して行きます。いつのどの試合の観客動員数かがわかるように今回抽出するデータ項目は「開催イベント名」「試合日」「会場名」「ホームチーム」「アウェイチーム」「観客動員数」のデータを抽出していきます。
まず、「開催イベント名」「試合日」「会場名」を抽出して行きます。先程抽出したデータから「開催イベント名」はクラス名'matchVsTitle__league'、「試合日」はクラス名'matchVsTitle__date'、「会場名」はクラス名'matchVsTitle__stadium'のテキストデータから抽出できることが分かりました。従って、先述同様にlist変数のgame_informationに抽出したデータを格納して行きます。
# 試合情報取得
game_information = []
game_information.append(soup.find(class_='matchVsTitle__league').text)
game_information.append(soup.find(class_='matchVsTitle__date').text)
game_information.append(soup.find(class_='matchVsTitle__stadium').text)
次は、チーム名の取得をして行きます。チーム名はクラス名'embL'のテキストデータから抽出できます。しかし、テキスト情報を収集するにはfind().textを使用しますが、find()では最初の要素のみしか抽出できないため、これではホームチーム名は取得できますがアウェイチームが取得できません。そのため、クラス名'leagAccTeam__clubName'をfind_all()で抽出することでホームチームのdivタグ要素とアウェイチームのdivタグ要素を分けて変数に格納し、順番にfind().textすることで実装します。tmp = soup.find_all(class_='leagAccTeam__clubName')でホームチームとアウェイチームのdivタグ要素を変数tmpに2次元配列で格納します。変数tmpから1つずつ要素を取り出し、クラス名'embL'のテキストデータを抽出し変数teamに追加していきます。こうすることで、変数teamにホームチームとアウェイチームのチーム名が格納できます。
tmp = soup.find_all(class_='leagAccTeam__clubName')
team = []
for i in range(len(tmp)):
tmp2 = tmp[i].find(class_="embL").text
team.append(tmp2)
次に観客動員数を抽出していきます。抽出したHTMLコードを確認してみると、観客動員数のコードが確認できません。Google Chromeの検証機能で確認すると、しっかりとコードを確認できます。これは、この箇所を動的に処理しているものだと思われます。そのため、RequestsとBeautifulSoupではデータの抽出ができないため、観客動員数のみSeleniumを活用してデータ収集して行きます。
まずは、Seleniumの設定からして行きます。まず、SeleniumはAnacondaに標準インストールされていないライブラリなので下記コマンドでライブラリのインストールを行います。
pip install selenium
次に、ライブラリのインポートを行います。
from selenium import webdriver
from webdriver_manager.chrome import ChromeDriverManager
次にWebDriverの設定を行います。SeleniumではWebDriverを活用してブラウザ操作を自動化します。そのWebDriverの設定を行います。options = webdriver.ChromeOptions()でWebDriverの設定を変更するためのオプションを変数optionsに格納します。options.add_argument('--headless')ではWebDriverをヘッドレスモードで起動するためのオプションです。SeleniumでWebDriverを起動するとブラウザが立ち上がり一連の操作がGUI表示されます。しかし、解析中は特にこれらの表示を確認する必要はないため、ヘッドレスモードで起動することで、ブラウザのGUI表示をしないようにするコマンドです(解析中は邪魔ですからね)。また、WebDriverをSeleniumで操作する際、自身のブラウザのバージョンと同じバージョンのDriverをダウンロードする必要があります。そのため、ブラウザのバージョンが変わるとDriverのバージョンも変更しなくてはなりません。毎度バージョン更新の度Driverのバージョンを更新するのはめんどくさいですよね。その際、driver = webdriver.Chrome(ChromeDriverManager().install(), options=options)のコマンドを使えば、実行する度、ブラウザのバージョンと同じDriverをインストールしてくれるコマンドです。こうすることで、バージョンが更新されるた度、Driverの更新をしなくて良いということです。
options = webdriver.ChromeOptions()
options.add_argument('--headless')
driver = webdriver.Chrome(ChromeDriverManager().install(), options=options)
次に、Driverの操作を行なって行きます。観客動員数抽出のフローは、WebDriverを使って対象のWebページに遷移し、動的処理を行なったブラウザに表示されているHTMLコードを取得します。そのHTMLコードをBeautifulSoupを使ってデータ抽出する流れです。
まず、Seleniumを使ってブラウザに表示されているHTMLコードを抽出します。driver.get()で指定のリンクにブラウザからアクセスします。()の中で対象のWebページのリンクを指定します。すぐにHTMLコードの抽出をすると動的処理が完了する前のHTMLコードを抽出してしまうのでWebDriverWait(driver, 30).until(EC.presence_of_all_elements_located)で動的処理が完了するのを待機させるコマンドを実行します。また、今回もサーバへアクセスしたためtime.sleep(5)で待機します。最後にdriver.page_sourceでdriverに表示されているページソースを変数htmlに格納します。WebDriverを使い終わったタイミングでdriver.quit()を実行し、Driverを終了させてください。
driver.get(J_parent_url + link_list[j])
WebDriverWait(driver, 30).until(EC.presence_of_all_elements_located)
time.sleep(5)
html = driver.page_source
最後にBeautifulSoupで先程抽出したHTMLコードを解析させ、観客動員数のデータを取得し変数numに格納します(先述と同じ手法のため説明は割愛します)。
soup = BeautifulSoup(html, "html.parser")
tmp2 = soup.find_all('tbody')
tmp = []
for element in tmp2[1].find_all("td"):
tmp.append(element.text)
for i in range(len(tmp)):
if tmp[i] == '入場者数':
num = tmp[i+1]
これで収集したいデータは全て、変数に格納できました。これを一つのDataFrameにします。試合毎にfor文を回すことで前日の試合の全てのデータを抽出できます。
# 試合情報、試合チーム、動員数を一つのリストに結合
tmp = game_information + team
tmp.append(num)
output = pd.DataFrame(tmp).T
このようにして、対象のWebサイトからデータ収集を自動で行うことができます。次回の記事では、Seleniumの他の使い方も紹介しますので、そちらも確認してみてください。
今回作成したプログラムの全容
import requests
from bs4 import BeautifulSoup
import time
import datetime
from datetime import timedelta
from selenium import webdriver
from webdriver_manager.chrome import ChromeDriverManager
import pandas as pd
driver = webdriver.Chrome(ChromeDriverManager().install(), options=options)
# 今日の日付を更新
weekday = datetime.date.today().weekday()
w_list = ['(月)','(火)','(水)','(木)','(金)','(土)','(日)']
dt_now = datetime.datetime.now()
# 昨日の日付を更新
yesterday = dt_now - timedelta(1)
weekday = yesterday.weekday()
word_month = yesterday.strftime('%B').lower()
year = yesterday.year
month = yesterday.month
day = yesterday.day
yesterday_date = str(year) + '年' + str(month) + '月' + str(day) + '日' + w_list[weekday]
J_url = 'https://www.jleague.jp/match/'
J_parent_url = 'https://www.jleague.jp'
req = requests.get(J_url)
time.sleep(2)
soup = BeautifulSoup(req.text, "html.parser")
game_date_section = soup.find_all(class_='matchlistWrap')
# 指定した試合日の「試合詳細」
# 指定した試合日があったら以下の処理を実行し、なかったら実行しない
process_judge = 'NO'
for i in range(len(game_date_section)):
game_date = game_date_section[i].find(class_='leftRedTit').text
if game_date == yesterday_date:
ans = game_date_section[i]
process_judge = 'OK'
if process_judge == 'OK':
tb_list = ans.find_all(class_='linkWrap')
link_list = []
for j in range(len(tb_list)):
item_list = tb_list[j].find_all('a')
for i in range(len(item_list)):
if item_list[i].text == '試合詳細':
tmp_link = item_list[i].get('href')
link_list.append(tmp_link)
flag = 0
for j in range(len(link_list)):
# 各試合の詳細ページに遷移
req = requests.get(J_parent_url + link_list[j])
time.sleep(2)
soup = BeautifulSoup(req.text, "html.parser")
# 試合情報取得
game_information = []
game_information.append(soup.find(class_='matchVsTitle__league').text)
game_information.append(soup.find(class_='matchVsTitle__date').text)
game_information.append(soup.find(class_='matchVsTitle__stadium').text)
# 試合チーム取得
tmp = soup.find_all(class_='leagAccTeam__clubName')
team = []
for i in range(len(tmp)):
tmp2 = tmp[i].find(class_="embL").text
team.append(tmp2)
# 動員数と開催スタジアムの抽出
driver.get(J_parent_url + link_list[j])
WebDriverWait(driver, 30).until(EC.presence_of_all_elements_located)
time.sleep(5)
html = driver.page_source
soup = BeautifulSoup(html, "html.parser")
tmp2 = soup.find_all('tbody')
tmp = []
for element in tmp2[1].find_all("td"):
tmp.append(element.text)
for i in range(len(tmp)):
if tmp[i] == '入場者数':
num = tmp[i+1]
# 試合情報、試合チーム、動員数を一つのリストに結合
tmp = game_information + team
tmp.append(num)
time.sleep(3)
if flag == 0:
output = pd.DataFrame(tmp).T
flag = 1
else:
output = pd.concat([output, pd.DataFrame(tmp).T])
output = output.reset_index(drop=True)
output.columns = ['タイトル','試合日','スタジアム','ホーム','アウェイ','動員数']
else:
output = '昨日試合はありませんでした。'
driver.quit()
参考資料
[1]Beautiful Soup Documentation
[2]The Selenium Browser Automation Project