seleniumを用いたiタウンページのスクレイピング

#前書き
2020年頃に、iタウンページの仕様が変更されたので、それに対応したスクリプトを作成してみました。
作成するのは以下のようなスクリプトです

キーワードとエリアを入力してiタウンページ上で検索→検索結果から店名と住所を取得してcsv形式で出力する

##注意
※Webスクレイピングはサイトの利用規約などによって禁止されている可能性があります
今回の題材とするiタウンページでは以下が主に禁止されています

- ｉタウンページのサービスに多大な影響を与える行為
- 自動的にアクセスするプログラムを使用してｉタウンページに繰り返しアクセスする行為
- 不正なプログラム・スクリプトなどを用いて、サーバーに負荷を与える行為

本記事で紹介するプログラムではユーザが通常利用する際の速度を大幅に上回るような速度での連続アクセスは行わないため、禁止事項には該当しない（と思われます）

また、第三者が閲覧可能な環境での利用のために複製することは禁じられているため、
説明の際にサイトの画像などは載せられませんあしからず

#iタウンページ
2020年頃にサイトが更新され、検索結果を下にスクロールしていくと**さらに表示**というボタンが出現し、
これを何度も押さなければ全ての検索結果（最大表示1000件）を取得できないようになりました。

#プログラム概要
とりあえずざっとした説明とプログラム全体を載せます。（詳しい説明は後述）

- 動作環境：
    - Python3.9 
- ライブラリ：
    - selenium 3.141.0
    - pandas 1.1.4
    - PySimpleGUI 4.30.0
    - beautifulsoup4 4.9.3
- ソフトウェア：
    - Firefox 82.0.2
    - geckodriver 0.28.0 (firefox使用時)
    - Chrome 86.0.4240.183    
    - chromedriver 86.0.4240.22 (chrome使用時）


PysimpleGUIを使って入力インターフェースを作ります（なくても問題ありません）
seleniumのwebdriverを使用してchrome(or firefox)を起動し、該当ページを表示 & さらに表示ボタンを全部押します
beautifulsoupを使って必要な要素を取得します（今回は店名と住所の２種類）
pandasを使ってデータを成形します


```main.py
#It is python3's app
#install selenium, beautifulsoup4, pandas with pip3
#download firefox, geckodriver
from selenium import webdriver
#from selenium.webdriver.firefox.options import Options
from selenium.webdriver.chrome.options import Options
from selenium.common.exceptions import NoSuchElementException
from bs4 import BeautifulSoup
import pandas as pd
import time
import re
import csv
import PySimpleGUI as sg

#plese download browserdriver and writedown driver's path
#bdriverpath='./chromedriver'
bdriverpath="C:\chromedriver.exe"

#make popup window
layout= [
    [sg.Text('Area >> ', size=(15,1)), sg.InputText('町田')],
    [sg.Text('Keyword >> ', size=(15,1)), sg.InputText('コンビニ')],
    [sg.Submit(button_text='OK')]
]
window = sg.Window('Area and Keyword', layout)

#popup
while True:
    event, values = window.read()

    if event is None:
        print('exit')
        break

    if event == 'OK':
        show_message = "Area is " + values[0] + "\n"
        show_message += "Keyword is " + values[1] + "\n"
        print(show_message)
        sg.popup(show_message)
        break

window.close()
area =values[0]
keyword = values[1]

#initialize webdriver
options = Options()
options.add_argument('--headless')
driver=webdriver.Chrome(options=options, executable_path=bdriverpath)

#search page with keyword and area
driver.get('https://itp.ne.jp')
driver.find_element_by_id('keyword-suggest').find_element_by_class_name('a-text-input').send_keys(keyword)
driver.find_element_by_id('area-suggest').find_element_by_class_name('a-text-input').send_keys(area)
driver.find_element_by_class_name('m-keyword-form__button').click()
time.sleep(5)

#find & click readmore button
try:
    while driver.find_element_by_class_name('m-read-more'):
        button = driver.find_element_by_class_name('m-read-more')
        button.click()
        time.sleep(1)
except NoSuchElementException:
    pass
res = driver.page_source
driver.quit()

#output with html
with open(area + '_' + keyword + '.html', 'w', encoding='utf-8') as f:
    f.write(res)

#parse with beautifulsoup
soup = BeautifulSoup(res, "html.parser")
shop_names = [n.get_text(strip=True) for n in soup.select('.m-article-card__header__title')]
shop_locates = [n.get_text(strip=True) for n in soup.find_all(class_='m-article-card__lead__caption', text=re.compile("住所"))]

#incorporation lists with pandas
df = pd.DataFrame([shop_names, shop_locates])
df = df.transpose()

#output with csv
df.to_csv(area + '_' + keyword + '.csv', quoting=csv.QUOTE_NONE, index=False, encoding='utf_8_sig')

sg.popup("finished")
```

#ブロック毎に解説

##環境構築
以下が今回impoprtしたライブラリ群です。
全てpip3でインストールできます。
コメントアウトしているのは、chromeを使用するかfirefoxを使用するかの部分ですので、好みと環境に合わせて書き換えてください。

```import.py
from selenium import webdriver
#from selenium.webdriver.firefox.options import Options
from selenium.webdriver.chrome.options import Options
from selenium.common.exceptions import NoSuchElementException
from bs4 import BeautifulSoup
import pandas as pd
import time
import re
import csv
import PySimpleGUI as sg
```

##driver
この後解説するwebdriverを使用する場合には、chromeならchromedriver、firefoxならgeckodriverが必要になります。
該当するものを下記サイトからダウンロードしてください。
https://github.com/mozilla/geckodriver/releases
https://chromedriver.chromium.org/downloads
また、このとき自分の使用している**ブラウザ、python、driverの3つのバージョン**が噛み合っていないと動作しません

- まずブラウザは基本的に最新のものを使用してください
- driverはそれに合わせてダウンロードしましょう
    - geckoなら最新を使用する（たぶん）
    - chromeならchromeのバージョン名とdriverのバージョン名が連動しているので、同じものを選ぶ

<br>
ドライバをダウンロードしたら環境変数としてpathを通すか、わかりやすい場所に置いてプログラム内でpathを書きます。
私のwindows環境ではCドライブ直下に置いています。
コメントアウトしていますが、linux（mac)ではプログラムが置いてある場所と同じ場所に置いて使用しました。

```driver.py
#plese download browserdriver and writedown driver's path
#bdriverpath='./chromedriver'
bdriverpath="C:\chromedriver.exe"
```

##PySimpleGUI
参考文献
[Tkinterを使うのであればPySimpleGUIを使ってみたらという話](https://qiita.com/dario_okazaki/items/656de21cab5c81cabe59)

レイアウトを決定し、デフォルトの入力を書いておきます（町田、コンビニ）

``` layout.py
#make popup window
layout= [
    [sg.Text('Area >> ', size=(15,1)), sg.InputText('町田')],
    [sg.Text('Keyword >> ', size=(15,1)), sg.InputText('コンビニ')],
    [sg.Submit(button_text='OK')]
]
```
<br>
ウィンドウを作成し、ループで読み込み続けます。
ウィンドウにあるOKボタンがおされると、入力内容がvalues[]に読み込まれます。
処理終了後はwindow.close()で終了し、プログラム内の変数に入力内容を渡します。

``` window.py
window = sg.Window('Area and Keyword', layout)

#popup
while True:
    event, values = window.read()

    if event is None:
        print('exit')
        break

    if event == 'OK':
        show_message = "Area is " + values[0] + "\n"
        show_message += "Keyword is " + values[1] + "\n"
        print(show_message)
        sg.popup(show_message)
        break

window.close()
area =values[0]
keyword = values[1]
```

##webdriverの起動
webdriver(selenium)は通常のブラウザ(firefox, chrome等)をプログラムから操作するためのライブラリです

まず起動時のオプションに`--headless`を追加します。これはブラウザをバックグラウンド実行にするオプションです。
もしブラウザが自動で動く様子がみたい場合は`options.add_argument('--headless')`をコメントアウトしてください。
次に、`driver=webdriver.Chrome()`でchromeを起動します。
同時にoptionと、driverのパスを入力します。`options=options, executable_path=briverpath`


``` init.py
#initialize webdriver
options = Options()
options.add_argument('--headless')
driver=webdriver.Chrome(options=options, executable_path=briverpath)
```
##webdriverで検索
`driver.get`でタウンページのトップにアクセスします。
`driver.find~`でキーワードとエリアを入力するinputボックスを探して、`.send_keys()`で入力も行います。
また、同じ方法で検索開始ボタンも探して、`.click()`でボタンを押します。
※htmlは、chrome等でiタウンページのサイト開いた状態でデベロッパーツール（ソースを見る）などすると見せてくれます。


```search.py
#search page with keyword and area
driver.get('https://itp.ne.jp')
driver.find_element_by_id('keyword-suggest').find_element_by_class_name('a-text-input').send_keys(keyword)
driver.find_element_by_id('area-suggest').find_element_by_class_name('a-text-input').send_keys(area)
driver.find_element_by_class_name('m-keyword-form__button').click()
time.sleep(5)
```
###htmlの例
例えば以下のページでは、キーワードの入力ボックスがあるのはidが`keyword-suggest`で、classが`a-text-input`です。

```keyword.html
<div data-v-1wadada="" id="keyword-suggest" class="m-suggest" data-v-1dadas14="">
<input data-v-dsadwa3="" type="text" autocomplete="off" class="a-text-input" placeholder="キーワードを入力" data-v-1bbdb50e=""> 
<!---->
</div>
```

##さらに表示を押しまくる
ループを使って、さらに表示ボタン`class_name = m-read-more`が見つかり続ける限りボタンを押すようにします。
また、ボタンを押してからすぐに同じボタンを探そうとすると、新しいボタンがまだ読み込まれておらず途中で終了ということが起きるので`time.sleep(1)`で待機時間を設けます
ボタンが見つからなくなると、webdriverがエラーを起こしてプログラムが終了してしまうので、前もってエラーを予測`except`しておきます
except後はそのまま次へと進み、手に入れたhtml(さらに表示を全部押してある）を`res`に入れて、`driver.quit()`
でwebdriverは終了します

```button.py
from selenium.common.exceptions import NoSuchElementException

#find & click readmore button
try:
    while driver.find_element_by_class_name('m-read-more'):
        button = driver.find_element_by_class_name('m-read-more')
        button.click()
        time.sleep(1)
except NoSuchElementException:
    pass
res = driver.page_source
driver.quit()
```

##htmlを出力
念の為、手に入れたhtmlを出力しておきます。必須ではありません

```html.py
#output with html
with open(area + '_' + keyword + '.html', 'w', encoding='utf-8') as f:
    f.write(res)
```

##htmlの解析
beautifulsoupに先程取得したhtmlを渡します。
`soup.select`で要素を検索し、`.get_text()`で店名（住所）だけを取得します。
`get_text()`だけだと改行や空白が含まれてしまいますが、`strip=True`オプションをつけてあげると、ほしい文字だけになります。
また、住所についてですがタウンページのサイトでは`class_name=m-article-card__lead__caption`というクラスが、住所だけでなく電話番号や最寄駅といったものにも設定されていたため、文字列による抽出で住所だけを入手しています。`text=re.compile("住所")`

```parse.py
#parse with beautifulsoup
soup = BeautifulSoup(res, "html.parser")
shop_names = [n.get_text(strip=True) for n in soup.select('.m-article-card__header__title')]
shop_locates = [n.get_text(strip=True) for n in soup.find_all(class_='m-article-card__lead__caption', text=re.compile("住所"))]
```

##データの成形
pandasを使用してデータを整えています。
beautifulsoupで入手したデータはリストになっているので、2つを合体します。
それだけだと横向きのデータになってしまうので、縦向きにするために`transpose()`します。

``` pandas.py
#incorporation lists with pandas
df = pd.DataFrame([shop_names, shop_locates])
df = df.transpose()
```

##データの出力
今回はcsv形式で出力しました。
ファイル名には、ユーザが入力した`area`と`keyword`を使用します。
pandasのデータは出力すると縦に番号が振られますが、邪魔なので`index=False`で消しています。
また、出力したデータをエクセルで開くと文字化けするという問題があるので、`encoding='utf_8_sig'`で回避します。

```csv.py
#output with csv
df.to_csv(area + '_' + keyword + '.csv', quoting=csv.QUOTE_NONE, index=False, encoding='utf_8_sig')
```

#終わりに
seleniumを使用したwebスクレイピングをしてみましたが、感想としては動作が安定しなかったです。
実際にブラウザを動かしているため、読み込みを行った後やボタンを押した後の動作が保証されません。
今回はそれを回避するために`time.sleep`を使用しました。
（本来はseleniumの暗黙的・明示的待機を使用するのですが、私はうまく動作しませんでした。）
あと、webdriverをダウンロードしたらなぜか古いバージョンになっており、それに気づかず2日位エラーに悩まされていたのでめちゃくちゃ腹立ちました（自分に）