Raspberry Piでクローリングする方法まとめ(Selenium,Pyppeteer,Requsts-HTML)

ラズパイで各種クローリングライブラリを使用すると、エラーが出る等ですんなり実行できません。この記事では、ラズパイで`Selenium`・`Pyppeteer`・`Requsts-HTML`を用いてクローリングできるようにするための設定をまとめます。

## 実行環境
Raspberry Pi 4 Model B

```bash
$ uname -a
Linux raspberrypi 5.15.84-v7l+ #1613 SMP Thu Jan 5 12:01:26 GMT 2023 armv7l GNU/Linux
$ lsb_release -a
No LSB modules are available.
Distributor ID:	Raspbian
Description:	Raspbian GNU/Linux 11 (bullseye)
Release:	11
Codename:	bullseye
```

# Selenium
まずはクローリングで特に有名な、`Selenium`についての設定方法です。
Seleniumでは、使用したいブラウザのバージョンに適したドライバーをインストールする必要があります。自動的に各種ブラウザの最新のドライバーをインストールするために、[`webdriver-manager`を併用することが多いです](https://qiita.com/centerfield77/items/0aebd6ed452b278d872c)。今回はChromeでのクローリングを行います。
```python: 例文
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager

service = Service(ChromeDriverManager().install()) # 最新ドライバーを自動インストールしてパスを返す
driver = webdriver.Chrome(service=service)
driver.get('https://google.com')
```

## ライブラリバージョン
selenium == 4.7.2
webdriver-manager == 3.8.5

## エラーと解決法
ラズパイでこのまま実行すると、エラーが発生しました。[Chrome用ドライバーの配布元](https://chromedriver.chromium.org/downloads)からは、Linuxが64bit版しか配布されていません。このOSはarmv7l(32bit)なので、ダウンロードできないためです。
```
Traceback (most recent call last):
  File "/home/pi/test.py", line 10, in <module>
    service = Service(ChromeDriverManager().install()) # 最新ドライバーを自動インストールしてパスを返す
  File "/home/pi/.local/lib/python3.9/site-packages/webdriver_manager/chrome.py", line 39, in install
    driver_path = self._get_driver_path(self.driver)
  File "/home/pi/.local/lib/python3.9/site-packages/webdriver_manager/core/manager.py", line 30, in _get_driver_path
    file = self._download_manager.download_file(driver.get_url())
  File "/home/pi/.local/lib/python3.9/site-packages/webdriver_manager/core/download_manager.py", line 28, in download_file
    response = self._http_client.get(url)
  File "/home/pi/.local/lib/python3.9/site-packages/webdriver_manager/core/http.py", line 33, in get
    self.validate_response(resp)
  File "/home/pi/.local/lib/python3.9/site-packages/webdriver_manager/core/http.py", line 16, in validate_response
    raise ValueError(f"There is no such driver by url {resp.url}")
ValueError: There is no such driver by url https://chromedriver.storage.googleapis.com/110.0.5481.77/chromedriver_linux32.zip
```
調べてみると、[`sudo apt-get install chromium-chromedriver`を行ってドライバーをダウンロードする](https://stackoverflow.com/questions/70886717/chromedriver-for-linux32-does-not-exist-python-selenium-chromedriver)解決法が挙げられていました。以下のようにして使用します。
```python: 例文
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service

options = Options()
options.BinaryLocation = ("/usr/bin/chromium-browser") # バイナリ実行可能ファイルの場所指定 (chromium)
service = Service("/usr/bin/chromedriver") # インストールしたドライバーのパスを指定
driver = webdriver.Chrome(options=options, service=service)
driver.get('https://google.com')
```

もし、実行できなかった場合はchromiumとドライバーのバージョンが異なっている可能性が高いので、`sudo apt-get install chromium-browser`で更新(インストール)してください。

# Pyppeteer
`Pypetter`は非同期処理を行ってアクセスします。複数のサイトに同期にアクセスなどがやりやすいです（チュートリアル[１](https://zenn.dev/mook_jp/articles/pyppeteer-simple-tutorial), [２](https://qiita.com/loach/items/c881f7d9ef5add132c7b)）。
```python: 例文
from pyppeteer import launch
import asyncio

async def crawling():
    browser = await launch() # Chroniumをヘッドレスモードで起動
    page = await browser.newPage()
    await page.goto('https://google.com')

asyncio.get_event_loop().run_until_complete(crawling()) 
```

## ライブラリバージョン
pyppeteer == 1.0.2

## エラーと解決法
ラズパイでこのまま実行すると、エラーが発生しました。
```
Traceback (most recent call last):
  File "/home/pi/test.py", line 9, in <module>
    asyncio.get_event_loop().run_until_complete(crawling())
  File "/usr/lib/python3.9/asyncio/base_events.py", line 642, in run_until_complete
    return future.result()
  File "/home/pi/test.py", line 5, in crawling
    browser = await launch() # Chroniumをヘッドレスモードで起動
  File "/home/pi/.local/lib/python3.9/site-packages/pyppeteer/launcher.py", line 307, in launch
    return await Launcher(options, **kwargs).launch()
  File "/home/pi/.local/lib/python3.9/site-packages/pyppeteer/launcher.py", line 148, in launch
    self.proc = subprocess.Popen(  # type: ignore
  File "/usr/lib/python3.9/subprocess.py", line 951, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
  File "/usr/lib/python3.9/subprocess.py", line 1823, in _execute_child
    raise child_exception_type(errno_num, err_msg, err_filename)
OSError: [Errno 8] Exec format error: '/home/pi/.local/share/pyppeteer/local-chromium/588429/chrome-linux/chrome'
```
`'/home/pi/.local/share/pyppeteer/local-chromium/588429/chrome-linux/chrome'`を[fileコマンド](https://qiita.com/shinnshinn/items/e11dc2bf0c2368fdc38e)で確認すると、64bit用のchromeを実行しようとしていることがわかります。
```bash
$ file '/home/pi/.local/share/pyppeteer/local-chromium/588429/chrome-linux/chrome'
/home/pi/.local/share/pyppeteer/local-chromium/588429/chrome-linux/chrome: 
ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), 
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped
```

任意のChrome実行ファイルを指定できるオプションがあるか調べると、[`executablePath`キーワードに指定が無いと上記のパスが設定されるようでした](https://github.com/miyakogi/pyppeteer/blob/f5313d0e7f973c57ed31fa443cea1834e223a96c/pyppeteer/launcher.py#L82)。つまり、executablePathにラズパイのchromiumのパスを指定すれば良さそうですね。
```python: 例文
from pyppeteer import launch
import asyncio

async def crawling():
    options = {
        "headless": False, # 非ヘッドレスモード(起動をわかりやすくする)
        "executablePath": "/usr/bin/chromium-browser" # 実行ファイル指定
    }
    browser = await launch(options) # Chroniumをヘッドレスモードで起動
    page = await browser.newPage()
    await page.goto('https://google.com')

asyncio.get_event_loop().run_until_complete(crawling()) 
```
ちなみに、Pyppeteerは[JavaScriptパッケージPuppeteerを移植したもの](https://miyakogi.github.io/blog/20170902/pyppeteer.html)なので、Puppeteerについて調べるとよく出てくるかもしれません。実際、[Puppeteerで実行ファイル指定する方法](https://gunmagisgeek.com/blog/puppeteer/7516)なら書いてありました...

# Requests-HTML
最後に、`Requests-HTML`についての設定方法です。
Requests-HTMLは、前述の`Pyppeteer`や`Beautiful Soup`などをラップしているので、[クローリングからスクレイピングまでこれ一つで行うことができます](https://blog.ikedaosushi.com/entry/2019/09/15/162445)。
```python: 例文（同期的にクローリング）
from requests_html import HTMLSession

session = HTMLSession()
res = session.get("https://google.com")
# URLとtitleを表示
# res.html.findはこの時点ではrequests.Response.content（静的情報のみ）から検索する
print(res.html.url, res.html.find('title')[0].text)
res.html.render() # pyppeteerを使用してサイトにアクセス -> 動的情報取得
print(res.html.find('title')[0].text) # 動的情報から検索
```
```python: 例文（非同期的にクローリング）
from requests_html import AsyncHTMLSession
asession = AsyncHTMLSession()

async def static_crawling():
    return await asession.get('https://google.com')
async def dynamic_crawling():
    resp = await asession.get('https://google.com')
    await resp.html.arender() # 非同期版render
    return resp 

resp = asession.run(static_crawling, static_crawling)[0] # 複数サイトを非同期クローリング可
print(resp.html.url, resp.html.find('title')[0].text)
resp = asession.run(dynamic_crawling, dynamic_crawling)[0] # 複数サイトを非同期クローリング可
print(resp.html.url, resp.html.find('title')[0].text)
```

## ライブラリバージョン
requests-html == 0.10.0

## エラーと解決法
ラズパイにおいて`render`や`arender`を実行するプログラムでは、エラーが発生しました。`pyppeteer`を内部に使っているので、[同様の原因](#エラーと解決法-1)でエラーが発生しています。
```
Traceback (most recent call last):
  File "/home/pi/test.py", line 8, in <module>
    res.html.render() # pyppeteerを使用してサイトにアクセス -> 動的情報取得
  File "/home/pi/.local/lib/python3.9/site-packages/requests_html.py", line 586, in render
    self.browser = self.session.browser  # Automatically create a event loop and browser
  File "/home/pi/.local/lib/python3.9/site-packages/requests_html.py", line 730, in browser
    self._browser = self.loop.run_until_complete(super().browser)
  File "/usr/lib/python3.9/asyncio/base_events.py", line 642, in run_until_complete
    return future.result()
  File "/home/pi/.local/lib/python3.9/site-packages/requests_html.py", line 714, in browser
    self._browser = await pyppeteer.launch(ignoreHTTPSErrors=not(self.verify), headless=True, args=self.__browser_args)
  File "/home/pi/.local/lib/python3.9/site-packages/pyppeteer/launcher.py", line 307, in launch
    return await Launcher(options, **kwargs).launch()
  File "/home/pi/.local/lib/python3.9/site-packages/pyppeteer/launcher.py", line 148, in launch
    self.proc = subprocess.Popen(  # type: ignore
  File "/usr/lib/python3.9/subprocess.py", line 951, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
  File "/usr/lib/python3.9/subprocess.py", line 1823, in _execute_child
    raise child_exception_type(errno_num, err_msg, err_filename)
OSError: [Errno 8] Exec format error: '/home/pi/.local/share/pyppeteer/local-chromium/588429/chrome-linux/chrome'
```

つまり、Requests-HTML内でpyppeteerを呼び出すときのブラウザ指定があればいいのですが、[受け付けるようになっていませんでした](https://github.com/kennethreitz/requests-html/blob/026c4e5217cfc8347614148aab331d81402f596b/requests_html.py#L780)。

### 解決法その１
Requests-HTMLのコードを、ブラウザ実行ファイル設定も受け付けるように少し書き換えてしまいましょう。ライブラリの`requests_html.py`（上記エラー文が出現した場合であれば、`/home/pi/.local/lib/python3.9/site-packages/`内）の`BaseSession`クラスの定義を以下のように変更すれば良いと思います。
```diff_python:BaseSessionクラス定義の変更箇所
  class BaseSession(requests.Session):
      """ A consumable session, for cookie persistence and connection pooling,
      amongst other things.
      """
  
      def __init__(self, mock_browser : bool = True, verify : bool = True,
-                  browser_args : list = ['--no-sandbox']):
+                  browser_args : list = ['--no-sandbox'], browser_path : str = None):
          super().__init__()
  
          # Mock a web browser's user agent.
          if mock_browser:
              self.headers['User-Agent'] = user_agent()
  
          self.hooks['response'].append(self.response_hook)
          self.verify = verify
  
          self.__browser_args = browser_args
+         self.__browser_path = browser_path
  
  
      def response_hook(self, response, **kwargs) -> HTMLResponse:
          """ Change response enconding and replace it by a HTMLResponse. """
          if not response.encoding:
              response.encoding = DEFAULT_ENCODING
          return HTMLResponse._from_response(response, self)
  
      @property
      async def browser(self):
          if not hasattr(self, "_browser"):
-             self._browser = await pyppeteer.launch(ignoreHTTPSErrors=not(self.verify), headless=True, args=self.__browser_args)
+             self._browser = await pyppeteer.launch(ignoreHTTPSErrors=not(self.verify), headless=True, args=self.__browser_args, executablePath=self.__browser_path)
  
          return self._browser
```

そして、先の例文の`HTMLSession()`や`AsyncHTMLSession()`に、キーワード引数`browser_path`を`"/usr/bin/chromium-browser"`として指定するとうまく機能するでしょう。

### 解決法その２
ライブラリを書きかえることが抵抗がある人に、もう一つの解決法を提示します。
それは、chrome実行ファイルを置き換えるということです。`pyppeteer`は、実行時にchromeをダウンロードします。上記エラー文から、`/home/pi/.local/share/pyppeteer/local-chromium/588429/chrome-linux/chrome`がそれに該当することがわかります。

この実行ファイルをラズパイのchromiumに置き換えましょう。
以下のコマンドを実行すればいいでしょう（バージョン更新などでパスは変化する可能性があります）。

```bash
$ rm /home/pi/.local/share/pyppeteer/local-chromium/588429/chrome-linux/chrome
$ ln -s /usr/bin/chromium-browser /home/pi/.local/share/pyppeteer/local-chromium/588429/chrome-linux/chrome
```
こうすれば、ライブラリはそのままで機能すると思います。先の例文で言うと、プログラムはそのままでいいです。

# まとめ
快適にクローリング・スクレイピング生活を送りましょう！