注意事項
Webクローリング/スクレイピングは、実装方法によってwebサイトに大量のアクセスを発生させる可能性があります。悪意のあるなしに関わらず、責任を問われる可能性があることを意識してください。
(そもそも限定共有にする予定で、公開するつもりはない(=検索に出てこない)ため大丈夫だとは思いますが、) ⇒(2020/4/23 追記 今更ながら過去の限定共有記事を公開することにしました)
もし、この記事を見てWebスクレイピングを自分でもやりたい!となった場合は、
必ず下記URLを熟読した上で、当記事内のスクリプトを参考にして実行したいかなる結果に関しても自己責任にてお願いします。
Webスクレイピングの注意事項一覧
TL;DR
私事ながら、今度結婚することになったので、最近はいろいろな準備にバタバタしています。
籍を入れたら次にやることは、結婚式場探しに奔走することになる人も多いのではないでしょうか。
ただ、この式場探しですがとにかく式場の数が多く、奥様方の要望を聞き入れつつ地道に探していくのは非常にめんどくさい。。 骨が折れます。(ゼ○シィのページ数の多さには驚きました。。もうあんなの鈍器じゃない。。)
そこで、今回はPythonを使い、「これだ!」っていう式場を探すまではいかなくとも、せめて表形式で統計的に分析がしやすい形にweb上から情報を取ってきて、SeabornというPythonのライブラリで可視化をするためのTipsです。
Webスクレイピングとは?
「Webスクレイピング」は取得したHTMLから任意の情報を抽出することをいいます。もちろん、対象の情報を掲載しているHTMLがバラバラになっている(複数ページにわたっている)場合は、それらのHTMLを網羅的に集めてこないといけません。この網羅的に収集する行為を、「Webクローリング」と呼びます。
今回は、このWebクローリング/スクレイピングのために、「requests」と「BeautifulSoup (リンク先は有志による日本語訳サイトです。)」というPythonのライブラリを使います。BeautifulSoupは現在最新のメジャーバージョンが4となっており、3以前のバージョンの使用は推奨されていません。そもそも、BeautifulSoup3はPython3に対応していないみたいなので、基本は4を利用する前提でこの記事は進みます。
Seabornとは?
「Seaborn」は機械学習でよく利用されるデータ可視化ライブラリです。データの概形を見るのにとても便利で、とりあえずSeabornで可視化しておいて、アタリをつけておいてから細かいデータ分析に入るというデータサイエンティストの方も多いのではないでしょうか。(私はデータサイエンティストではないので、こんなアプローチが普通だよ!っていうのがあればコメントをください)
結婚式場口コミサイトとは
結婚式場の口コミサイトです(トートロジー)。実際にその式場で式を挙げた先輩カップルや、ゲストとしてお呼ばれした人たちが、匿名であることないことをたくさん書いてくれているサイトです。所謂、結婚式場版食べログといったところでしょうか。信憑性に欠ける投稿ももちろんありますが、大多数の人の意見は正しいであろうという前提で、実際にかかった金額等を知ることができるのはありがたいです。(具体的なサイト名を出してしまうと変に問題になってしまったら嫌なので意図的に避けています。)
requests, BeautifulSoupの使い方
では、前置きも済んだところで実際にスクレイピング用のライブラリの使い方から確認していきます。
ちなみに今回は次のような環境で実施しています。
・Windows 10 pro
・Anaconda 4.4.0 (64bit)
・Python 3.6.1
requests
まずはライブラリをインストールしましょう。
C:\WorkSpace>pip install requests
C:\WorkSpace>python -c "import requests; print(requests.__version__)"
2.14.2
インストールができたら、適当にjupyter notebookでも開いてみて(pythonのコードが実行できる環境であればなんでもかまいません)、任意のサイトのURLを取得してみましょう。
import requests
r = requests.get("URLをここに")
print(r.content)
b'\n\n\n\n<!DOCTYPE html>\n<html lang="ja">\n <head>\n <meta charset="utf-8">\n <meta http-equiv="X-UA-Compatible" content="IE=edge">\n <meta name="viewport" …
上記のコードでは、任意のURLに対してgetリクエストを投げて、帰ってきたボディを出力しています。
どういったメソッドでリクエストを投げるか、さらに取得したレスポンスに対して、どういった要素(ヘッダやボディ等)をどういう形式で取り出すかでそれぞれメソッドが用意されています。ここでは詳細までは紹介しないので、詳しく知りたい方は公式のドキュメントを参照してください。requests公式ドキュメント
表示された結果はなかなか人間には可読性が低く、なんじゃこりゃという感じですが、これをBeautifulSoupでうまく調理していきましょう。
BeautifulSoup
まずはrequestsと同じくインストールから。
C:\WorkSpace>pip install beautifulsoup4
先ほど取得したURLの中から、metaタグをすべて取得してみます。
import requests
from bs4 import BeautifulSoup
r = requests.get("URLをここに")
soup = BeautifulSoup(r.content, "html.parser")
soup.find_all("meta")
[<meta charset="utf-8"/>,
<meta content="IE=edge" http-equiv="X-UA-Compatible"/>,
<meta content="width=device-width, initial-scale=1" name="viewport"/>,
<meta content="" name="description"/>,
…
]
metaタグが一つずつ、リスト形式で返されていることがわかります。
この検索は、metaタグでなくても、aやdevのようなほかのタグにも利用できます。
また、classが指定されているタグを個別で検索することも可能です。
例えば下記のような要素を含むhtmlがあったとします。
…
<div class="classA">
<h1>
title.
</h1>
</div>
…
divタグの中でも、このclass="classA"が指定されているタグのみを抽出する場合は、下記のコードを実行します。
import requests
from bs4 import BeautifulSoup
r = requests.get("上記htmlで構成されたwebサイトのURL")
soup = BeautifulSoup(r.content, "html.parser")
soup.find_all("div", class_="classA")
この機能を使って、式場ごとの口コミページにアクセスしていき、必要な情報を取得してデータフレームに追加していこうというのが今回の趣旨です。
いざスクレイピングを実践
※ここからはさらに抽象的な内容が増えますがご了承ください。。
Webスクレイピングでほしい情報を取得してくる場合は、取得したい情報のあるwebサイトのhtmlの構造をよく知る必要があります。まずは、ブラウザで普通に口コミサイトにアクセスして、どういった構造になっているかを観察しましょう。
私が情報を取得したいと考えている口コミサイトでは、地域ごとの式場を表示したり、ランキング順に表示することができるみたいです。幸い今回は東京以外での式は検討していないため、東京都内の式場のページ内でスクレイピングを実施すればよさそうです。
また、地域ごとに式場を表示させた場合は、1ページあたり30件の式場を掲載する作りとなっており、2ページ目以降はurlの最後尾のページ数がhttps://hogehoge/page2,https://hogehoge/page3といった形で増えていく仕組みになっているようです。
なんとなく取得したい情報がありそうなページの検討がついたら、出し抜けに「F12」を押下しましょう。F12で開くのは開発者ツールです。(IE or Chromeの場合です。その他ブラウザについては各自調べてください。)
開発者ツールとは、開いているwebページのソースを表示したり、レスポンスを計測したりすることができる便利ツールです。この開発者ツールでwebページのソースを開き、ctrl + Fで取得したい情報の具体例一つを検索します。すると、その情報がどのようなタグで、どういったclass名で記述されているかがわかります。

(モザイクかけすぎて何が何だかわからないですが雰囲気だけ掴んでもらえれば。。すいません。。)
これで検索するタグとclassがわかったので、あとはそれを持ってくるPythonコードを書くのみです。
import requests
from bs4 import BeautifulSoup
import pandas as pd
import time
# 検索したいURLを入れたらスープのリクエストを返す関数
def get_soup(url):
r = requests.get(url)
soup = BeautifulSoup(r.content, "html.parser")
return soup
# 検索したいURLと、タグ名、さらにクラス名を入れたら該当のタグが並んだリストを返す関数
def get_specify_class(url, tag_type, class_name=None):
r = requests.get(url)
soup = BeautifulSoup(r.content, "html.parser")
if class_name is None:
matching_list = soup.find_all(tag_type)
else:
matching_list = soup.find_all(tag_type, class_=class_name)
return matching_list
# レーティングを返す関数
def get_avg_rate(soup):
tags = soup.find_all("span", class_="hogehoge")
if tags:
rate = float(str(tags).split(">\n")[1].split("\n<")[0])
else:
rate = 0
return rate
# レビューの数や本番の投稿数を返す関数
def get_review_num(soup):
tags = soup.find_all("div", class_="hogehoge")
if tags:
kuchikomi_num = int(str(tags).split("\n")[2].strip("件"))
toukou_num = int(str(tags).split("\n")[5].strip("件"))
else:
kuchikomi_num = 0
toukou_num = 0
return kuchikomi_num, toukou_num
# 平均のコストとゲストの人数を返す関数
def get_cost(soup):
tags = soup.find_all("div", class_="hogehoge")
if tags:
avg_cost = str(tags).split(">")[2].split("<")[0]
if avg_cost.isnumeric():
avg_cost = int(avg_cost)
else:
avg_cost = 0
gest_num = str(tags).replace("(","(").replace(")",")").split("(")[1].split("人)")[0]
if gest_num.isnumeric():
gest_num = int(gest_num)
else:
gest_num = 0
else:
avg_cost = 0
gest_num = 0
return avg_cost, gest_num
# エリアを返す関数
def get_area(soup):
tags = str(soup.find_all("a", class_="hogehoge"))
large_area = tags.split(">")[1].split("<")[0]
small_area = tags.split(">")[3].split("<")[0]
return large_area, small_area
# 最寄り駅と会場ジャンルを返す関数
def get_moyori(soup):
tags = soup.find("div", class_="hogehoge").find_all("dd")
if len(tags) == 2:
if tags[0].find("a") is None:
tags_moyori = tags[0]
moyori = str(tags_moyori).split(">")[1].split("<")[0].replace("\n","")
else:
tags_moyori = tags[0].find("a")
moyori = str(tags_moyori).split(">")[1].split("<")[0].replace("\n","")
if tags[1].find("a") is None:
tags_genre = tags[1]
genre = str(tags_genre).split(">")[1].split("<")[0].replace("\n","")
else:
tags_genre = tags[1].find("a")
genre = str(tags_genre).split(">")[1].split("<")[0].replace("\n","")
else:
tags = tags[0]
if tags.find("a") is None:
genre = str(tags).split(">")[1].split("<")[0].replace("\n","")
else:
tags = tags.find("a")
genre = str(tags).split(">")[1].split("<")[0].replace("\n","")
moyori = "情報なし"
return moyori, genre
# まずは東京のページのURLから、全部で何件の式場があるのか、そのうえでページ数は何件になるのかを表示
url = "https://hogehoge/hoge/"
total_num_text = get_specify_class(url, "div", "hogehoge")
total_num = int(str(total_num_text[0]).split("<div>全<span>")[1].split("</span>件中</div>")[0])
total_page_num = (total_num // 30) + 1
# ホール名とURLが入ったデータフレームを作る
url = "https://hogehoge/hoge/"
list_df = pd.DataFrame(columns=['hall_id','hall_name','URL'])
hall_id = 1
for i in range(1, total_page_num+1):
if i == 1:
hall_list = get_specify_class(url,"h2","hogehoge")
else:
hall_list = get_specify_class(url+"page"+str(i),"h2","hogehoge")
for hall in hall_list:
hall_url = "https://hogehoge" + str(hall).split('a href="')[1].split('"')[0]
hall_name = str(hall).split('a href="')[1].split('"')[1].split(">")[1].split("</a")[0]
tmp_se = pd.Series( [ "W" + str(hall_id).zfill(4), hall_name, hall_url ], index=list_df.columns )
list_df = list_df.append( tmp_se, ignore_index=True )
hall_id = hall_id + 1
time.sleep(1)
# リストの数だけページにアクセスして必要な情報を取ってくる
data_df = pd.DataFrame(columns=['hall_id', 'average_rate', 'kuchikomi_num', 'honban_toukou_num', 'average_cost', 'average_guest_num', 'large_area', 'small_area', 'moyori', 'genre'])
for i in range(len(list_df.URL)):
soup = get_soup(list_df.URL[i])
tmp_se = pd.Series([list_df.hall_id[i],get_avg_rate(soup),get_review_num(soup)[0],get_review_num(soup)[1],get_cost(soup)[0],get_cost(soup)[1],get_area(soup)[0],get_area(soup)[1],get_moyori(soup)[0],get_moyori(soup)[1]], index=data_df.columns)
data_df = data_df.append( tmp_se, ignore_index=True )
print(data_df.tail(1)) # 進捗確認用
time.sleep(1)
全体的に、とてつもなくダサくてゴリ押し感が強いです。(お恥ずかしい。。温かい目で見ていただけければ幸いです。。)
実際のURLだったりタグのクラス名は、一応元サイトに配慮してhogeに置き換えています。
上のほうの関数定義では、requestsでhtmlを取ってきてBeautifulSoupで解析できる形にhtmlをパースしたり、タグ要素を検索したりする処理を関数にまとめています。
また、その下の関数定義では実際に取得してきたタグ要素から必要な情報を必要な型で抜き出す処理を関数としてまとめました。
「# まずは東京のページのURLから、~」というコメントの行からが実際の処理になっていますが、今回は大きく3つの処理に分けました。
- 選択した地域の式場紹介のページ数が何ページになるかを取得する部分
- すべての式場名と個別の式場ページのURLを取得し、データフレームに追加していく部分
- すべての式場の情報を取得し、データフレームに追加していく部分
上記の通りです。注意頂きたいのは下の二つの部分で、それぞれwebページにアクセスする処理を複数回実施する必要があります。for文で繰り返し処理をしているのですが、一度アクセスした後には、time.sleep()関数を入れて1~2秒待つようにしましょう。クローリングする際のお作法みたいです。(これをしておけば大丈夫というわけではありません。)
では、作成されたデータフレームを見てみましょう。
下記は1つ目のデータフレームです。

例によって式場名とURLはモザイクです。こちらのデータフレームは、適当に連番で振られたIDと式場名、URLだけのシンプルなつくりであることがわかります。
2つ目のデータフレームです。

おお、こちらはなんとなくそれなりにデータらしきものになっていそうです。式場名やURLとは、hall_idで結合させれば具体的な式場との紐づけができそうです。
以降では、このデータフレームを利用して各種統計値の確認や可視化を行っていこうと思います。
可視化してみる
Seabornというライブラリを使います。
例のごとく、ライブラリがインストールされていない場合はインストールしましょう。
C:\WorkSpace>pip install seaborn
他のライブラリ同様、importして利用するのですが、このseabornはnumpyやpandas、matplotlibといったライブラリを前提としているので、それらのライブラリも同時にimportしてあげる必要があります。
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
%matplotlib inline
sns.set(font='IPAMincho')
最後の行で、IPAMinchoというフォントを設定していますが、matplotlibやそれをベースに動くseabornは、デフォルトだと日本語がうまく表示されなくて□になってしまうので、日本語が表示できるフォントをIPAのサイトからダウンロードしてきて使っています。IPAフォント
ダウンロードすると、.ttfという拡張子のファイルが入っているので、それらをまとめてどこかにあるmatplotlibのmpl-data\fonts\ttfの下に格納します。
どこに環境をインストールしたかによって変わりますが、anaconda3をユーザー単位でインストールした私の場合は、
C:\Users\User\Anaconda3\Lib\site-packages\matplotlib\mpl-data\fonts\ttf
にありました。
それでは、準備が済んだのでまずは先ほど作ったデータフレームに対して、pairplot()という関数を使って散布図行列を表示させてみましょう。
sns.pairplot(data=data_df, size=2)
散布図行列は、数値データ同士の全通りの散布図を一行のコードで出してくれる超絶便利関数です。
対戦表みたいな形になっていて、対角線上の要素は、その変数の頻度表(ヒストグラム)を表示してくれています。
早速中身を見ていくと…あれ、なにかおかしいですね。。データが偏りすぎている気がします。それぞれの変数のヒストグラムを見ると、左端のビンだけ突出して数が多いように見えます。
これは、口コミや、平均コストなどの投稿が無い(すべての数値要素が0の)式場が大半であることを示しています。
ではどのくらい0のデータがあるのでしょうか。調べてみましょう。
data_bool_df = (data_df == 0)
data_bool_df.sum()
データフレーム内の0の要素をTrueに、それ以外をFalseに変換した、data_bool_dfというデータフレームを作成して、合計を表示させています。
表示された結果を見ると、口コミ点数平均(average_rate)が0の行数が542、平均コスト(average_cost)が0の行数が544あるということが分かります。。(うう。。800件ちょっとくらいしかデータ数がないのに。。)
しかし、背に腹は代えられないので、0の要素がある行は除外しましょう。
nonzero_data_df = data_df[(data_df['average_rate'] != 0) & (data_df['average_cost'] != 0)]
data_bool_df = (nonzero_data_df == 0)
data_bool_df.sum()
きれいになりました。
再度散布図行列を見てみると…
sns.pairplot(data=nonzero_data_df, size=2)
少しはまともな散布図行列になったのではないでしょうか。
ヒストグラムを見ると口コミ点数平均は4.2~4.3あたり、平均コストは300万弱あたりにボリュームゾーンがありそうです。
また、散布図を見ると、やはり口コミ数と本番投稿数は正の相関がありそうですし、平均コストと平均ゲスト人数も正の相関を持っていそうです。
ただ、これだけではまだまだ「そりゃそうだ」という情報しかわかりません。
pairplotには、カテゴリ変数ごとに色分けをするオプションがあるみたいなので、それを使ってみましょう。
sns.pairplot(data=nonzero_data_df, hue="genre",size=2)
sns.pairplot(data=nonzero_data_df, hue="small_area",size=2)
↑の図は式場のジャンルごとに、↓の図は式場のエリアごとに色分けしてみました。
少しわかりづらいですが、ジャンルごとの平均コストと平均ゲスト数を見てみると「チャペル・教会」「神社・仏閣」は少人数(親戚のみくらいの人数でしょうか)で予算を抑えた挙式が多く、「ゲストハウス」というジャンルは平均よりコストが高めの式場が多いように見えます。
エリアごとの散布図は…うーん…これといった特徴は無いように見えますね。
では、気になる「口コミ点数平均」と「平均コスト」についてもう少し詳しく見ていきましょう。
plt.figure(figsize=(1,5))
sns.boxplot(data=nonzero_data_df, x="large_area", y="average_rate")
まずは、東京全体での口コミ点数平均の箱ひげ図を見てみます。
箱ひげ図の細かい見方はここでは解説しませんが、中央値が4.2ちょっとで、データの半分くらいが4.1から4.3あたりに集まっているみたいですね。
エリアごとの箱ひげ図も見てみましょう。
plt.figure(figsize=(15,5))
fig, ax = plt.subplots()
labels = ax.get_xticklabels()
plt.setp(labels, rotation=90, fontsize=10)
sns.boxplot(data=nonzero_data_df, x="small_area", y="average_rate")
文字が小さくて申し訳ないですが、銀座・新橋エリアの式場は口コミ点数が高めな傾向があるようです。
同じように平均コストについても箱ひげ図を表示します。
nonzero_data_df.average_cost = nonzero_data_df.average_cost.astype('float')
plt.figure(figsize=(1,5))
sns.boxplot(data=nonzero_data_df, x="large_area", y="average_cost")
plt.figure(figsize=(15,5))
fig, ax = plt.subplots()
labels = ax.get_xticklabels()
plt.setp(labels, rotation=90, fontsize=10)
sns.boxplot(data=nonzero_data_df, x="small_area", y="average_cost")
※平均コストのエリアごとの箱ひげ図を出そうとするとエラーが出ましたが、型をfloatに直すと表示されました。…ただ、直接代入しているせいかfloatに直すコードそのもので警告が出ます。。
まずは東京全体で見てみると、やはり中央値は300弱あたりに位置しています。また、上のほうの外れ値を見ると700を超えている式場もありますね。。ものすごく豪華なところなんでしょうか。。
エリアごとで見てみると、
「六本木・赤坂・麻布」、「恵比寿・代官山・白金」、「お台場、竹芝、晴海」エリアあたりは平均して高めでな傾向があります。たしかに地価も高級そうな地域が並んでいます。
箱ひげ図だけだと、分布がつかみにくいので、ボリュームが分かりやすいよう点をプロットしてくれる関数もあります。swarmplotという関数です。
plt.figure(figsize=(2,5))
sns.swarmplot(data=nonzero_data_df, x="large_area", y="average_cost")
plt.figure(figsize=(15,5))
fig, ax = plt.subplots()
labels = ax.get_xticklabels()
plt.setp(labels, rotation=90, fontsize=10)
sns.swarmplot(data=nonzero_data_df, x="small_area", y="average_cost")
どんな分布になっているのかがわかりやすいですね。東京全体でみるとボリュームゾーンは確かに250~350あたりですが、450あたりにもこぶができていて、クラスタリングなんかをやってみると面白いかもしれません。
ぶっちゃけたいして意味のある結果は得られてないですが、長くなってきたのでこのあたりで終わろうかと思います。webで情報を集めてきて、可視化をする流れがなんとなく伝わっていればうれしいです。
今後の展望としては、
- クラスタリングをやってみる
- いいなと思うところをまずは手探りで探してみて、フラグをたてて学習させてみる
- 現状ゲスト数が違うコストを同等の尺度で比べているため、平均コストと平均ゲスト数を使って新しい変数を作る(特徴量エンジニアリング)
- テキストで書かれている式場のいいところを使って、口コミ点数を予測してみる(何の意味が。。)
あたりでしょうか。では、長々とお付き合いいただきありがとうございました。