GreenSnap TECH BLOG

GreenSnapのエンジニアチームの取り組みや使っている技術を紹介します

Amazon QuickSightを使って都内のサウナイキタイの「イキタイ」を可視化する

f:id:yamano-hidenori:20211227135439p:plain こんにちは、GreenSnapでiOSエンジニアをやっている山野です。 この記事は、弊社で社内の非エンジニアにも使ってもらえるようなBIツールを探しているときに候補に上がったAWSのQuickSightを調査するにあたって、どうせ色々触ってみるなら、自分の好きなものを対象にしてみたいなと思い、最近のマイブームであるサウナをテーマにして、QuickSightで遊んでみたというネタ記事です。 可視化するデータは、「サウナイキタイ」というポータルサイトを利用します。

aws.amazon.com

サウナイキタイとは

サウナイキタイとは、サウナ好きなら一度は見たことはあるであろう、国内最大規模のサウナのポータルサイトです。サウナにとって重要な指標である、サウナの温度、水風呂の温度などの基本情報から、アメニティなどの細かい情報までまとまっており、サイト内の独自の指標「イキタイ」により、サウナの人気度合いがわかったり、「サ活」でみんなのサウナ日記を見ることもできる神サイトです。

sauna-ikitai.com

基本方針

今回、サウナ分析をするにあたり、以下の方針で進めていきます。

  1. Pythonによるスクレイピングにより、サウナ情報を取得
  2. AWS QuickSightを使って可視化

Step.1 Pythonによるスクレイピングにより、サウナ情報を取得

環境構築が面倒なので、Google Colaboratory を使い、Pythonによるスクレイピングをします。 (可視化だけ見たい方は読み飛ばしてください。)

ソースコード

# 必要なライブラリのインポート
import requests
from bs4 import BeautifulSoup
import os
import pandas as pd
# import geocoder

# はじめに、ベースとなる1ページ目のURLを定義する
base_url = "https://sauna-ikitai.com/search?conditions%5B%5D=target_gender%23is_male_available&ordering=ikitai_counts_desc&prefecture%5B%5D=tokyo&target_gender%5B%5D=male&water_baths__temperature%5Bmin%5D=0"

# データ格納用のデータフレーム
df = pd.DataFrame()
# スクレイピング対象の URL にリクエストを送り HTML を取得する
response =  requests.get(base_url)
# BeautifulSoupによるHTMLのパース処理
soup = BeautifulSoup(response.text, "lxml")
# ページ数を取得
result_number = int(soup.find_all('p', {'class': 'p-result_number'})[0].find_all('span')[0].string)

row = 0
sauna_links = []
item_num = 20
page_num = int(result_number / item_num)
mod = result_number % item_num
if mod != 0:
  page_num += 1
for p in range(page_num):
  # スクレイピング対象の URL にリクエストを送り HTML を取得する
  response =  requests.get(base_url + "&page=" + str(p+1))
  # BeautifulSoupによるHTMLのパース処理
  soup = BeautifulSoup(response.text, "lxml")
  # class が p-saunaList の div 要素を全て取得する
  sauna_list_elms = soup.find_all('div', {'class': 'p-saunaList'})[0]
  # ページ内リンクを取得
  sauna_links += [url.get('href') for url in sauna_list_elms.find_all('a')]
  print(str(p+1) + "ページ / " + str(page_num) + "ページ")
# サウナ名から緯度経度を取得する2
def get_lat_lon_from_address(address):
  geo_link = "http://geocode.csis.u-tokyo.ac.jp/cgi-bin/simple_geocode.cgi?charset=UTF8&addr=" + address
  address_response =  requests.get(geo_link)
  # BeautifulSoupによるHTMLのパース処理
  address_soup = BeautifulSoup(address_response.text, "lxml")
  lat = address_soup.find_all('latitude')[0].string
  lng = address_soup.find_all('longitude')[0].string
  latlng = {"lat": lat, "lng": lng}
  return latlng
import re

# 住所を分割し、市区町村を取得
def get_municipalities(address):
  matches = re.match(r'(...??[都道府県])((?:旭川|伊達|石狩|盛岡|奥州|田村|南相馬|那須塩原|東村山|武蔵村山|羽村|十日町|上越|富山|野々市|大町|蒲郡|四日市|姫路|大和郡山|廿日市|下松|岩国|田川|大村)市|.+?郡(?:玉村|大町|.+?)[町村]|.+?市.+?区|.+?[市区町村])(.+)' , address)
  return matches[2]
def isfloat(s):  # 浮動小数点数値を表しているかどうかを判定
  try:
    float(s)  # 文字列を実際にfloat関数で変換してみる
  except ValueError:
    return False
  else:
    return True
# 男、女、共用のどのタイプにデータがあるかを見て、どれを取得するか判断
def getSpecSoup(soup):
  tmp = soup.find_all('div', {'class': 'p-saunaSpec'})
  for item in tmp:
    elms = item.find('div', {'class': 'p-saunaSpecNot'})
    if elms == None:
      return item
# 温度、収容人数を取得、なければ-を返す
def getSpecNumber(p):
  return p.text.split("\n")[1] if len(p.text.split("\n")) > 1 else "-"
# 値を0,1変換する
def replace(str):
  return str.replace("有り", "1").replace("○", "1").replace("無し", "0").replace("-", "0")

# specでimgがあれば変換
def getSpecItems(td):
  return replace(td.find("img").get("alt")) if td.find("img") != None else "0"
# sauna_linkにサウナ施設のURLを渡すとデータフレームにサウナ情報が追加される
# rowはデータフレームの行
def setSaunaInfoToDataFrame(sauna_link, row):
  # スクレイピング対象の URL にリクエストを送り HTML を取得する
  sauna_response =  requests.get(sauna_link)
  # BeautifulSoupによるHTMLのパース処理
  sauna_soup = BeautifulSoup(sauna_response.text, "lxml")
  # class が p-saunaDetailShop_info の div 要素を取得する
  sauna_list_elms = sauna_soup.find_all('div', {'class': 'p-saunaDetailShop_info'})[0]
  # サウナ情報を取得する
  sauna_infos_keys = [str(td.string) for td in sauna_list_elms.find_all('th', {'class': 'c-table_th'})]
  sauna_infos_values = [str(td.string).replace("\n", "").replace(" ", "").replace("\r", " ") for td in sauna_list_elms.find_all('td', {'class': 'c-table_td'})]
  # イキタイを取得してkey,valueに追加
  ikitai_elms = sauna_soup.find_all('div', {'class': 'p-action_number'})[0]
  sauna_infos_keys.append("イキタイ")
  sauna_infos_values.append(int(ikitai_elms.string))
  # 緯度経度を取得する
  address = sauna_infos_values[2]
  latlon = get_lat_lon_from_address(address)
  sauna_infos_keys.append("緯度")
  sauna_infos_keys.append("経度")
  sauna_infos_values.append(latlon["lat"])
  sauna_infos_values.append(latlon["lng"]) 
  # 市区町村を取得
  municipalities = get_municipalities(address)
  sauna_infos_keys.append("市区町村")
  sauna_infos_values.append(municipalities)
  # 男、女、共用のどのタイプにデータがあるかを見て、どれを取得するか判断
  sauna_spec_soup = getSpecSoup(sauna_soup)
  sauna_spec_elms = sauna_spec_soup.find_all('div', {'class': 'p-saunaSpec_main'})[0]
  # サウナと水風呂の温度、収容人数など取得
  tmp_people = [getSpecNumber(p) for p in sauna_spec_elms.find_all('p', {'class': 'p-saunaSpecItem_people'})[0:2]]
  tmp_temp = [getSpecNumber(p) for p in sauna_spec_elms.find_all('p', {'class': 'p-saunaSpecItem_number'})[0:2]]
  # tmp_peopleがまれにない場合があるので適当に追加
  tmp_people += ["-", "-"]
  # floatに変換できるかどうか判定し、できない場合は"-"を格納
  sauna_people_vals = [float(item) if isfloat(item) else "-" for item in tmp_people]
  sauna_temperature_vals = [float(item) if isfloat(item) else "-" for item in tmp_temp]
  sauna_infos_keys.append("サウナ収容人数")
  sauna_infos_values.append(sauna_people_vals[0])
  sauna_infos_keys.append("水風呂収容人数")
  sauna_infos_values.append(sauna_people_vals[1])
  sauna_infos_keys.append("サウナ温度")
  sauna_infos_values.append(sauna_temperature_vals[0])
  sauna_infos_keys.append("水風呂温度")
  sauna_infos_values.append(sauna_temperature_vals[1])
  # その他情報を取得
  spec_elms = sauna_spec_soup.find_all('table', {'class': 'p-saunaSpecTable'})[0]
  spec_keys = [div.text for div in spec_elms.find_all('div', {'class': 'p-saunaSpecTable_name'})]
  spec_values = [getSpecItems(td) for td in spec_elms.find_all('td', {'class': 'p-saunaSpecTable_mark'})]
  sauna_infos_keys += spec_keys
  sauna_infos_values += spec_values
  # さらに細かい情報を取得
  other_spec_elms = sauna_soup.find_all('div', {'class': 'p-saunaSpecDetail'})[0]
  other_spec_keys = [span.text for span in other_spec_elms.find_all('span', {'class': 'p-saunaSpecList_key'})]
  other_spec_values = [replace(span.text) for span in other_spec_elms.find_all('span', {'class': 'p-saunaSpecList_value'})]
  sauna_infos_keys += other_spec_keys
  sauna_infos_values += other_spec_values

  # サウナ情報をデータフレームに入れる
  for index, sauna_info in enumerate(sauna_infos_values):
    key = sauna_infos_keys[index]
    if sauna_info == "" or sauna_info == "None":
      sauna_info = "-"
    df.loc[row, key] = sauna_info
start_index = 0
for row, sauna_link in enumerate(sauna_links[start_index:]):
  print(sauna_link)
  setSaunaInfoToDataFrame(sauna_link, row + start_index)
  print(str(row + start_index + 1) + " / " + str(result_number))
# 空欄を0埋め
df.fillna(0, inplace=True)

# csv出力
df.to_csv('sauna.csv', index=False)

上記コードを上から順に実行することで、最終的にsauna.csvというcsvファイルが生成されます。 ソースコードはGitHubにも上げているので参考にしてください。

sauna.ipynb · GitHub

Step.2 AWS QuickSightによる可視化

いよいよ、QuickSightの出番です。 QuickSightは初見では少しわかりづらいですので、順を追って解説します。

1. データセットのインポート

Step.1で作成したsauna.csv というファイルをQuickSightのデータセットとしてアップロードします。 QuickSightのメニューから、データセットを選択し、右上の「新しいデータセット」をクリックします。

f:id:yamano-hidenori:20211224141135p:plain
新しいデータセット

すると、データセットとして選択できるものの一覧が表示されます。 今回はcsvを直接アップロードするので、「ファイルのアップロード」を選択します。

f:id:yamano-hidenori:20211224141140p:plain
データセットソース一覧

Step.1で作成したsauna.csvをアップロードします。

f:id:yamano-hidenori:20211224142419p:plain
アップロード
アップロードが完了すると、↑のようにアップロード内容のプレビューがでます。 問題なければ、左下の「設定の編集」をクリックします。

2. データセットの編集

f:id:yamano-hidenori:20211224142339p:plain
編集
するとデータセットの編集画面へ遷移します。 このまま何もしなくても問題ありませんが、今回は緯度と経度があるので、データの型を修正します。 対象の型をタップして、「緯度」または「軽度」と選択するとデータ型が切り替わります。
f:id:yamano-hidenori:20211224142350p:plain
編集完了
完了したら、公開して視覚化をクリックします。 すると、分析画面へ遷移します。

3. データの可視化(基本編)

いよいよ可視化に移ります。 まずは、サウナイキタイにおける人気度をあらわす「イキタイ」数を地図上にマッピングしてみます。 まずは、左下のビジュアルタイプから、「地図上のポイント」を選択します。

f:id:yamano-hidenori:20211224142328p:plain
地図選択

次に、左のフィールドリストから、緯度、経度を探し、画面上部のフィールドウェルの中の、Geospatial内へドラッグアンドドロップします。 さらに、Sizeにイキタイ(合計)、Colorに施設名をドラッグアンドドロップします。 するとこのように、地図上にイキタイ数に応じて円のサイズが違うものが地図上にマッピングされます。

f:id:yamano-hidenori:20211224142406p:plain
マッピング
ズームして見た感じ、池袋エリア、上野エリア、錦糸町エリアに人気のサウナがまとまってそうな気がします。
f:id:yamano-hidenori:20211227145532p:plain
マップズーム

他にも、いろんなビジュアルタイプがあるので、手当り次第触ってみるのがいいかと思います。たとえば、サウナと水風呂の温度を散布図を使ってマッピングすると、

f:id:yamano-hidenori:20211224164746p:plain
散布図
こんな感じで表現できますし、 シンプルに表を使い、
f:id:yamano-hidenori:20211224164950p:plain
イキタイ数順に並び替えたりもできます。

4. データの可視化(応用編)

また、可視化対象をフィルターする機能も便利なので、少し紹介させてください。 フィルターは少し複雑ですが、慣れれば色々と柔軟に行えて便利な機能です。 今回は、上記で作成した図を、施設名で絞り込んでみたいと思います。 手順は3つあります。

4.1. パラメータ作成

まずはパラメータを作成します。パラメータには、検索対象のフィールドを指定します。適当な名前をつけ、↓のように設定して作成しておきます。

f:id:yamano-hidenori:20211224165224p:plain
パラメータ作成

4.2. コントロールの追加

パラメータが作成されると上記のような画面が出るので、コントロールの追加をおこないます。これにより、フィルタ時に利用するコンポーネントを追加できます。

f:id:yamano-hidenori:20211224165450p:plain
コントロールの追加
今回は施設名でフィルターしたいので、テキストフィールドを使用します。
f:id:yamano-hidenori:20211224165531p:plain
コントロール作成

4.3. フィルタの作成

最後にフィルタの作成を行います。今回は施設名で絞りたいので、フィルタするフィールドに、「施設名」を選択します。

f:id:yamano-hidenori:20211224180617p:plain
パラメータ項目選定
カスタムフィルタを選択し、1で作成したパラメータと紐付けます。
f:id:yamano-hidenori:20211224180926p:plain
カスタムフィールド

少し複雑でしたが、設定は以上です。実際にフィルターを使ってみます。先程作成したテキストフィールドから、試しにわたしのホームサウナである「駒の湯」と入力すると、ダッシュボード内の図が、すべて駒の湯だけに絞り込まれました。

f:id:yamano-hidenori:20211224181007p:plain
フィルター機能1

f:id:yamano-hidenori:20211224181205p:plain
フィルター機能2

まとめ

今回は、サウナイキタイのデータをスクレイピングして収集し、QuickSightを使って可視化してみました。QuickSightを使ってみた所感としては、正直慣れるまでかなり使いづらかったです… ただ、個人的に地図上マッピングは感動しましたし、フィルター機能も使いこなせば色々なことができそうな気はしています。今回の調査を踏まえて、ダッシュボードを作って社内に公開することで、社内のメンバーなら誰でもGreenSnap内のデータ分析ができるようなダッシュボードを作っていけたらなと思います。この記事が、これからQuickSightを使おうか悩まれている方、そしてサウナが大好きな方の参考になりましたら幸いです。笑

最後に

弊社では絶賛エンジニア募集中です。BtoCのサービス開発をしてみたい方や、植物に興味のある方は是非応募してください。 カジュアルに話だけでも聞きたいという方もお待ちしてます。 www.wantedly.com