SeleniumでスクレイピングをするためのTips

# はじめに
本記事はSeleniumでスクレイピングをするための要素の取得方法や、デバッグの考え方などについて記載しています。

本記事の環境についてSeleniumは4.4で、ブラウザはchromeを使用しています。
Seleniumの環境構築の方法は、以前書いた[ルーチンワークはPythonにやらせよう　Seleniumで勤怠処理を自動化する](https://qiita.com/Brutus/items/caec20b979db6d49a29a)をご参照ください。

## 前提を確認する
前提が正しくない場合は、デバッグに無駄な時間が発生します。
スクレイピングを実行していて、`NoSuchElementException`の例外が出力された場合は、前提を確認しましょう。

### URLが正しいこと
`NoSuchElementException`の例外が出力された場合は、要素を取得しようとしているページのURLが正しいことを確認しましょう。
[HTTPのリダイレクト](https://developer.mozilla.org/ja/docs/Web/HTTP/Redirections)など、期待したレスポンスが得れないことは大いに考えられます。

```console
selenium.common.exceptions.NoSuchElementException: Message: no such element: Unable to locate element: {"method":"css selector","selector":"[id="app"]"}
```

Seleniumでは`current_url`メソッドが用意されています。

参考：[現在のURLを取得](https://www.selenium.dev/ja/documentation/webdriver/interactions/#%E7%8F%BE%E5%9C%A8%E3%81%AEurl%E3%82%92%E5%8F%96%E5%BE%97)

`current_url`メソッドを使用して、URLの判定を行うことができます。
以下は`current_url`の結果と、定義済みである`base_url`のURLを比較して正しくない場合に、例外を出力する例です。

```python
try:
    if browser.current_url == f'{base_url}':
        <URLが正しい場合の処理>
    else:
        raise BatchError('failure')
finally:
    pass
```

なお、名前解決できない場合は以下のエラーメッセージが出力されます。
URIスキームのホスト名が解決できる状況で、`NoSuchElementException`のメッセージが出力される場合はパスに誤りがある可能性があります。

```console
selenium.common.exceptions.WebDriverException: Message: unknown error: net::ERR_NAME_NOT_RESOLVED
```

### 取得しようとしている要素名が正しいこと
タイポなど取得しようとしている要素名が間違っている場合は、`NoSuchElementException`の例外が出力されます。

例えば、CSSセレクタに'app'を指定したつもりで、間違って1個pを多く入力してしまいました。

```python
element = browser.find_element(By.ID, 'appp')
```

存在しない要素名になるため、`NoSuchElementException`の例外が出力されます。

```console
selenium.common.exceptions.NoSuchElementException: Message: no such element: Unable to locate element: {"method":"css selector","selector":"[id="appp"]"}
```

Seleniumでは`is_enabled()`メソッドが用意されています。

参考：[Web要素に関する情報](https://www.selenium.dev/ja/documentation/webdriver/elements/information/)

`is_enabled()`メソッドを使用して、要素の判定を行うことができます。
以下の例では要素が有効な場合に、`element: True`と出力されます。

```python
element = browser.find_element(By.ID, 'app').is_enabled()
print(f'element: {element}')
```

## 要素を取得するために知っておくこと
スクレイピングで要素を取得するためには、ロケーターの位置について理解することが重要です。

### ロケーターの動きを理解する
要素を検索するために使用する基本的なメソッドとして、`find_element`と`find_elements`が用意されています。
ロケーターの動きや、メソッドの戻り値のデータ型はそれぞれ異なるため、仕様を理解した上で使い分けましょう。

`find_element`メソッドは、ロケーターと一致するDOMの最初の要素を取得します。

```python
element = browser.find_element(By.ID, 'wrapper')
print(f'element: {element}')
print(type(element))
```

上記の出力結果は以下の通りです。

```console
element: <selenium.webdriver.remote.webelement.WebElement (session="83637704440c02c40230f7f3c432a776", element="3962b35a-d2ae-4517-92bf-9d4d15733494")>
<class 'selenium.webdriver.remote.webelement.WebElement'>
```

`find_elements`メソッドは、複数の要素を検索し、リストで返します。

```python
element = browser.find_elements(By.ID, 'wrapper')
print(f'element: {element}')
print(type(element))
```
以下の通りにリストで返ってきているのが確認できます。

```console
element: [<selenium.webdriver.remote.webelement.WebElement (session="b6272bed38cd78b07fdcba2f7fafbb4c", element="14ba230f-78b6-4e8f-bc08-ccd001d950f4")>]
<class 'list'>
```

参考：[Web要素の検索](https://www.selenium.dev/ja/documentation/webdriver/elements/finders/)

::: note
要約すると、`find_element`メソッドで取得できる要素は最初に検索で一致した1個のみです。複数の要素を取得するためには、`find_elements`メソッドを使用します。
:::

### XPathとCSSセレクタ
[XML Path Language](https://ja.wikipedia.org/wiki/XML_Path_Language)（以下、[XPath](https://developer.mozilla.org/ja/docs/Web/XPath)）は、取得したい要素に適切なIDが存在しない場合などには有効です。
XPathの仕組みを踏まえて、ルートから絶対パスで参照しているため、Webサイトに変更が加えられた場合は取得できなくなる可能性があります。

>ユニークなidが使えない場合、きれいに書かれたCSSセレクタが要素を探す方法として適しています。 XPathはCSSセレクタと同様に動作しますが、シンタックスは複雑で大抵の場合デバッグが困難です。 XPathはとても柔軟ですが、ブラウザベンダは性能テストを通常行っておらず、非常に動作が遅い傾向があります。

参考：[ロケータをうまく扱うTips](https://www.selenium.dev/ja/documentation/test_practices/encouraged/locators/)

### table
Seleniumは、[table](https://developer.mozilla.org/ja/docs/Web/HTML/Element/table)要素も取得することができます。
BeautifulSoupと同じような感覚で、取得したtable要素を含むオブジェクトから[tr](https://developer.mozilla.org/ja/docs/Web/HTML/Element/tr)要素及び[td](https://developer.mozilla.org/ja/docs/Web/HTML/Element/td)要素を抽出します。
オブジェクトはリスト型で格納されているため、`for`文などを実行することでtr要素が取得できます。

以下は、スコープを`app`のIDで絞り込み、table要素のデータを取得する例です。

```python
# id
element = browser.find_element(By.ID, 'app')

# table
table = element.find_element(By.TAG_NAME, 'table')

# tr
tr_list = table.find_elements(By.TAG_NAME, 'tr')

```

取得した行の集合となる`tr_list`のオブジェクトから、セルデータとなる`td_list`をインデックスで指定して抽出します。
テーブルのセルデータが2個の場合は、以下のようにインデックスを指定します。

```python
table_list = []

for index, row in enumerate(tr_list):
    # ヘッダはスキップ
    if index == 0:
        continue

    # td
    td_list = row.find_elements(By.TAG_NAME, 'td')

    td_dict = {'name': td_list[0].text,
               'price': td_list[1].text} 
    table_list.append(td_dict)                             
```

### button
Webサイトの構成によって、スクレイピングで連続的にデータを取得する難易度は異なります。
以下のようにリクエストヘッダに、クエリパラメータを指定してHTTP通信を行うサイトの場合は、`page=`の値をインクリメントするだけなので容易いと思います。

```text
https://<WebサイトのURL>/list/?page=2
```

SPAなどで構成されたWebサイトで、[button](https://developer.mozilla.org/ja/docs/Web/HTML/Element/button)要素などクライアントサイドの動きに応じて、コンテンツがロードされて表示が変わるようなサイトの場合は、コンテンツを読み込むための命令が必要です。

従って以下のようにbutton要素で構成されたコンテンツの場合は、ページ数を示す数字をクリックしないと次のデータを表示することができません。

![スクリーンショット 2022-10-21 16.11.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221759/c910d692-90c3-a6b9-3766-f178d02cf34d.png)

ソリューションとして上記で紹介した`is_enabled()`メソッドを使用して、ボタンが存在する場合は次のコンテンツを読み込み、存在しない場合はそのページから抜けるなどのロジックを組めます。

以下の例ではtable要素で構成されたコンテンツに対して、CSSセレクタの[nth-child](https://developer.mozilla.org/ja/docs/Web/CSS/:nth-child)をインクリメントすることで、次に読み込むボタンを判定しています。
`button.is_enabled()`の結果がTrueの場合は、ボタンをクリックして次のコンテンツを読み込みます。

```python
flag = True
num = 2

while flag is True:
    # id
    element = browser.find_element(By.ID, 'app')
    # table
    table = element.find_element(By.TAG_NAME, 'table')
    # tr
    tr_list = table.find_elements(By.TAG_NAME, 'tr')
  
    try:
        if flag is True:
            num += 1
            button = element.find_element(By.CSS_SELECTOR, "CSSセレクタのパス > li:nth-child("+(str(num))+") > button")
            if button.is_enabled() is True:
                button.click()
            else:
                flag = False
    except common.exceptions.NoSuchElementException as e:
       continue
```

### frameとiframe
HTML5では、[frame](https://developer.mozilla.org/ja/docs/Web/HTML/Element/frame)要素及び[frameset](https://developer.mozilla.org/ja/docs/Web/HTML/Element/frameset)要素は非推奨とされています。

レガシーなシステムなどで見かけることがあると思いますが、[iframe](https://developer.mozilla.org/ja/docs/Web/HTML/Element/iframe)同様に、実態を指定してアクセスすることで、スクレイピングできます。

参考：[iFrame と Frame の操作](https://www.selenium.dev/ja/documentation/webdriver/interactions/frames/)

## その他
### プロファイルの使用方法
デフォルトでは、ChromeDriverはセッションごとに新しい一時プロファイルを作成します。
セッションなどのログイン情報を引き継ぎたい場合は、Chromeのユーザープロファイルを使用することで解決できます。

以下はドライバのオプションに、Chromeのユーザープロファイルを使用する例です。
`profile_path`はChromeを起動して、`chrome://version/`にアクセスすることで確認できます。

```python
from selenium import webdriver

options = webdriver.chrome.options.Options()
profile_path = '/Users/<Your Name>/Library/Application Support/Google/Chrome/Default'
options.add_argument('--user-data-dir=' + profile_path)
```

参考：[Options](https://www.selenium.dev/ja/documentation/webdriver/browsers/chrome/#options)

ドライバのオプションに関する詳細は、Seleniumのドキュメントではなく、ChromeDriverの[Common use cases](https://chromedriver.chromium.org/capabilities#h.p_ID_64)より確認できます。

### try/except
要素が見つからないため、`NoSuchElementException`の例外が出力されても処理を中断することなく続けて実行したい場合は、`common.exceptions.NoSuchElementException`を使用して例外をキャッチできます。

```python
from selenium import common

try:
    <何らかの処理>
except common.exceptions.NoSuchElementException as e:
    continue
```

### スリープ処理
要素が存在するのに関わらず、レンダリングに時間がかかるときは`NoSuchElementException`が発生して、要素が取得できない場合があります。

対策として、標準モジュールの`time.sleep`関数を使用すれば手軽にスリープ処理を実装できますが、Seleniumには[明示的な待機](https://www.selenium.dev/ja/documentation/webdriver/waits/#%E6%98%8E%E7%A4%BA%E7%9A%84%E3%81%AA%E5%BE%85%E6%A9%9F)と、[暗黙的な待機](https://www.selenium.dev/ja/documentation/webdriver/waits/#%E6%9A%97%E9%BB%99%E7%9A%84%E3%81%AA%E5%BE%85%E6%A9%9F)と呼ばれる2つの手段が用意されています。

参考：[待機](https://www.selenium.dev/ja/documentation/webdriver/waits/)

### ゾンビプロセス
Selenium実行時に正しく`quit()`で終了しないと、バッググラウンドで実行されているドライバのゾンビプロセスが残ります。
サーバで、cronなどを用いて定期的に実行している場合は、メモリが開放されないため、無駄なメモリを要する原因になります。

```console
hoge     68212   0.0  0.0 37946300   6680 s002  S     4:16PM   0:05.48 /Users/hoge/.wdm/drivers/chromedriver/mac64/106.0.5249/chromedriver --port=61942
hoge     67990   0.0  0.0 37946300   6668 s002  S     4:04PM   0:02.43 /Users/hoge/.wdm/drivers/chromedriver/mac64/106.0.5249/chromedriver --port=58852
hoge     67539   0.0  0.0 37957564   6684 s002  S     3:32PM   0:01.95 /Users/hoge/.wdm/drivers/chromedriver/mac64/106.0.5249/chromedriver --port=50768
hoge     66708   0.0  0.0 37946300   6660 s002  S     2:48PM   0:00.45 /Users/hoge/.wdm/drivers/chromedriver/mac64/106.0.5249/chromedriver --port=55595
```

MacやLinuxなどシェルが使用できる環境の場合に、ワンライナーで処理する例を以下に記載します。

```bash
ps=`ps aux | grep 'chromedriver' | grep -v grep | awk '{print  $2 }'`;for i in $ps; do kill $i; done
```

参考：[Quitting the browser at the end of a session](https://www.selenium.dev/documentation/webdriver/interactions/windows/#quitting-the-browser-at-the-end-of-a-session)

::: note
Seleniumは`quit()`を使用して終了しましょう。
:::

# おわりに
スクレイピングを行う際は、法律についても理解しましょう。

過去には偽計業務妨害容疑で立件された事例や、Webサイトに過度な負荷を与える場合は、刑法の「第二百三十四条」（電子計算機損壊等業務妨害）に触れる可能性があります。

また取得したデータの取り扱いについて問題がある場合は、著作権法に触れる可能性もあります。