pythonスクレイピング ～川崎じもと応援券～

# はじめに：私のスクレイピング経歴
私は研究開発の部門でスクラム開発チームに所属していますが、入隊当初「Pythonがこのチームのスタンダードですよ」とそれ以外はチームとして認めませんよという清々しいまでの宣言を後輩からいただき、Pythonを学ぶこととなりました。やっていけるか不安です。

そんな中、エンジニア御用達の[オライリーのページ](https://www.oreilly.co.jp/index.shtml)で”Python”を検索して片っ端から読むことにしたのです。しかし、他のPython本とは違いスクレイピングをする機会がなかなかなく、積ん読の山に埋もれる事になりました。めでたしめでたし。ではなく、埋もれそうなので何か題材を使って勉強することにしました。

- 購入書籍：[PythonによるWebスクレイピング 第2版](https://www.oreilly.co.jp/books/9784873118710/)
- 3年積まれてた本：[Pythonによるスクレイピング＆機械学習 開発テクニック](https://www.socym.co.jp/book/1079)

# 題材：川崎じもと応援券
「川崎じもと応援券」なるものがあります。

* 川崎市:「川崎じもと応援券」の発行について  
 http://www.city.kawasaki.jp/280/page/0000117271.html

>「緊急事態宣言」以降、消費者の購買活動は大きく変化しており、市民生活を支える飲食店などに大きな影響が出ています。
>「川崎じもと応援券」は、特別定額給付金の給付にあわせて、新型コロナウイルス感染症の影響により売上が大幅に減少している飲食店や生活関連サービス等における消費を促し、市内循環を図ることで、早期の経済回復を目的として発行するプレミアム付の商品券です。

応援券は藤子・F・不二雄プロの全面的な協力で、どら○もんのイメージ。すばらしい。

この応援券、購入者は利用店舗を確認して「いいな」と思ったら応募する流れになります。利用店舗の確認には検索窓に住所などを入れて検索結果を見るのですが、現在地近くの利用店舗が知りたいと思ったときは、GoogleMapから住所を見てからそれで検索して探す、という作業が必要になります。

そんな中「住んでる近くの店舗をマップにサクッと表示してくれよー」とつぶやく友人の横顔をエネルギーに、やってみようとなりました。
## 前提環境
今回は次の環境で開発しています。理由は既にあったから。

* python 3.7.9
* pycharm 2018-2.4
* windows 10 64bit（最新パッチあててます）

# 作りたいもの
川崎市じもと応援券のサイトにある店舗データ⇒Google MAP（マイマップ）。
① 川崎市じもと応援券のサイトからスクレイピングしてデータ取り出し。← この記事で説明する内容
② Google MAP(マイマップ)への貼り付けをなんとか自動化して定期的に更新するジョブを作る 
③ 欲しがっていた友人に見せてドヤる  （プライベート）

# 事前調査：Google MAP（マイマップ）
最終的には共有できるGoogle MAP（マイマップ）に取り込みたいのでフォーマットを確認します。

* マイマップヘルプ > 地図作成 > 地図の作成地図上の対象物をファイルからインポートする  
https://support.google.com/mymaps/answer/3024836?hl=ja

>ファイルのインポート
>次のいずれかの形式で情報が保存されていることを確認します。
>
>CSV
>TSV
>KML
>KMZ
>GPX
>XLSX
>Google スプレッドシート
>Google ドライブまたは Google フォトに保存された写真

Google MAPは住所データを入力すると勝手に緯度経度に変更してくれる（多少のクセはありますが）。
今回はサイトから取り出したデータをcsvで保存するまでを実装し、Google MAP（マップ）のUIを使って取り込むことにします。

# 1. まずはデータを取得する
[川崎じもと応援券-利用店舗一覧](https://premium-gift.jp/kawasaki-jimoto/use_store)を訪問し、検索時の動作を確認する。データを取得するための検索条件と指定の仕方を調査します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695006/a3e9bb15-8f23-a129-4144-9301ebb0fddc.png)
##  ページ取得パターン
画面とアドレスバーからわかること。

* 検索条件は「店舗名」「市区町村/町名」「業種（選択式）」
* 「市区町村/町名」に「川崎市」を入力して検索すると全件検索できる。（件数はページ上部）
* 店舗検索用ページで、検索結果は21店舗ずつ。
* 検索結果ページのクエリ文字列
    * events=search
    * id=
    * store=
    * addr=川崎市(URLエンコード)
    * industry=
* 2ページ目以降のクエリ文字列
    * events=page
    * id=2(ページ番号)
    * store=
    * addr=川崎市(URLエンコード)
    * industry=
* 最終ページでは「次へ」がなくなる
* 最終ページ以降のページ番号を指定すると最終ページの結果が返る

今回は、GETメソッドでクエリ文字列指定。結果はCSSセレクタで抽出できそうなのでurllib3と[BeatifulSoup4](https://www.crummy.com/software/BeautifulSoup/bs4/doc/)で実行することにします。


## 認証プロキシを使う場所で使う時のおまじない
説明は割愛します。

```python:main.py
from urllib3 import ProxyManager
import certifi

PROXY_BASIC_AUTH = '<user_name>:<password>'
PROXY_URL = '<proxy_url>:<proxy_port>'

if __name__ == '__main__':
    headers = make_headers(proxy_basic_auth=PROXY_BASIC_AUTH)
    proxy = ProxyManager(proxy_url=PROXY_URL, proxy_headers=headers,
                         cert_reqs='CERT_REQUIRED', ca_certs=certifi.where())

```
## CSSセレクタでHTML要素を検索するためのライブラリをインポート
どれがベストかわからないので、参考書籍を信じて[BeautifulSoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/)を使います。

```python
> pip install beautifulsoup4
```

## 初めの検索結果を取得する
まずは取得してみます。調べたクエリ文字列をjson形式で指定してGetで取得。
かなり簡単に作ることができますね。ここまでのコードは以下。

```python:main.py
from urllib3 import ProxyManager
import certifi

from urllib3.util import make_headers
from urllib.parse import urlencode
from bs4 import BeautifulSoup
import csv
import datetime

PROXY_BASIC_AUTH = '<user_name>:<password>'
PROXY_URL = '<proxy_url>:<proxy_port>'

if __name__ == '__main__':

    headers = make_headers(proxy_basic_auth=PROXY_BASIC_AUTH)
    proxy = ProxyManager(proxy_url=PROXY_URL, proxy_headers=headers,
                         cert_reqs='CERT_REQUIRED', ca_certs=certifi.where())

    jimoto_base = 'https://premium-gift.jp/kawasaki-jimoto/use_store'
    page_id = 0
    jimoto_vals = {
        'events': '',          # イベント
        'id': page_id,         # ページ番号
        'store': '',           # 店舗名。
        'addr': '',            # 住所部分文字列。
        'industry': ''         # 業種（ドロップダウン）
    }

    # 初期ページ取得（イベントは検索）
    jimoto_vals['events'] = 'search'
    page_id = page_id + 1
    jimoto_vals['id'] = page_id
    params = urlencode(jimoto_vals)
    request_url = jimoto_base + '?' + params

    # proxy経由で取得
    r = proxy.request('GET', request_url)\
    body = r.data.decode('utf-8')
    print(f'## debug print : {body}')

```

# 2. 検索結果からデータを抽出してcsvに出力する
検索結果をHTMLデータとして取得した後に、データの値をパースしてcsvに取得します。
検索結果はページングされているので、全ページの情報を取得する必要があります。
出力は、Google MAP（マイマップ）へのデータ入力用にcsv形式です。

## 検索結果の構造を確認
構造は次の通り（かなり省略）でした。

* body -- div -- form -- main -- div -- div class="l-lowercontainer" 以下に検索結果がある
* class="l-store-header" に全件表示
* class="store-card__item"のそれぞれの要素に店舗情報がある

上記要素とCSSセレクタを使って検索結果から値を抽出してcsvに出力します。
## 店舗データ抽出とcsv出力
store-card__itemの属性から店舗データが読み出せます。

```python:main.py
    soup = BeautifulSoup(body, 'html.parser')
    soup.prettify()

    dt_now = datetime.datetime.now()  # 出力ファイル名のための時刻取得

    # CSVファイル作成
    with open(f'./kanagawa-jimoto{dt_now.strftime('%Y%M%d-%H%M%S'}.csv', 'w', encoding='utf-8') as file:

        writer = csv.writer(file, lineterminator='\n')
        writer.writerow(['ID', '店舗名', '店舗種別', '住所', '電話番号', 'URL']) # ヘッダ

        all_item = soup.select('.store-card__item') # クラス store-card__itemを全て取得
        for item in all_item:
            shop_name = item.h3.string         # 最初のh2は店舗名
            shop_type = item.p.string          # 最初のpは店舗種別
            table = item.findAll('table')[0]   # 詳細データはテーブル
            rows = table.findAll('tr')
            address = ''
            tel = ''
            for row in rows:
                name = row.th.string
                if name.startswith('住所'):       #先頭に'住所'があれば住所の値
                    address = row.td.string
                elif name.startswith('電話番号'):  # 文字列の先頭が'電話番号'なら電話番号
                    tel = row.td.string
            shop_url = item.a.string # 最初のaはURL
            counter = counter+1
            csv_string = f'"{counter}","{shop_name}","{shop_type}","{address}","{tel}","{shop_url}"'
            writer.writerow([counter, shop_name, shop_type, address, tel, shop_url] #書き込む
```

# 3. 2ページ目以降の検索結果を取得する
１ページ目の検索結果を抽出できたので、後は残りの結果を抽出してcsvに保存するだけです。
ただし、このページはページ番号が超過してもエラーを返さずに最終ページを出し続けるので、完了判定が必要になります。
今回は、結果ページにデータ件数があるのでその数値分取得できたら終了することにします。

## データ件数（店舗数）を取得
`store-header__num`にその情報があるので、そこから数字を読み込みます。
文字列切り貼り。スマートじゃない。。

```python:main.py
    shop_counter = soup.select_one('.store-header__num')
    page_info = shop_counter.string.split('件／全')
    if not page_info or len(page_info) != 2:
        print(f'page {page_counter}')
        exit(0)

    num_of_shops = page_info[1].replace('件', '')
    print(f'number of shops: {num_of_shops}')
```

## データ件数を使って検索結果ページを最後まで読み込む
データ件数が分かったので、全部取得できるまでページを更新して読み込んでいきます。

```python:main.py

    while True:
        for item in all_item:
        #  ここで店舗データ抽出とcsv出力
        #    ： (省略)
            counter = counter+1
        #    ： (省略)

        # 取得店舗数チェック
        if counter >= int(num_of_shops):
            exit(0)

        # 次のページを見る
        jimoto_vals['events'] = 'page'
        page_id = page_id + 1
        jimoto_vals['id'] = page_id
        params = urlencode(jimoto_vals)
        request_url = jimoto_base + '?' + params
        r = proxy.request('GET', request_url)
        body = r.data.decode('utf-8')
        soup = BeautifulSoup(body, 'html.parser')
        soup.prettify()
```

# おわりに
今回はここまで。csvが出力されていると思いますのでGoogle MAPからcsv取り込んで※表示させて見てください。  

- ※ただし、住所データが不完全であったり、番地と「ビル名や階数」の間に空白が無いと取り込みに失敗したり、変なところをポイントしたりしますのでご注意ください。

参考：取り込み済みGoogle MAP（マイマップ）- 川崎市の方ご活用ください。
https://www.google.com/maps/d/edit?mid=115NfUBK8GUfwOEBpAaD8DqUd06JwOJ4s&usp=sharing

## 参考文献
この記事は以下の情報を参考にして執筆しました。

- [PythonによるWebスクレイピング 第2版](https://www.oreilly.co.jp/books/9784873118710/)
- [Pythonによるスクレイピング＆機械学習 開発テクニック](https://www.socym.co.jp/book/1079)
- [Google マイマップ](https://support.google.com/mymaps/answer/3024836?hl=ja)

また、

* O'reillyは、O'Reilly Media, Inc.の米国または他の国における商標登録または商標です。
* Googleは、Google LLC の米国または他の国における商標登録または商標です。
* Pythonは，Python Software Foundationの米国または他の国における商標登録または商標です。
* 記載の会社名、製品名、サービス名等はそれぞれの会社の商標または登録商標です。