PythonでPuppeteerを使う(pyppeteer) / CentOS8

###はじめに

CentOSでpyppeteerを使おうとしたら、動かすまでにやや苦労したので参考になれば、と。

**環境**
CentOS 8
Python 3.6.8
pyppeteer 0.2.5


なにはともあれ、まずはpyppeteerのインストールからです。

```
$ python3 -m pip install pyppeteer
```

そして適当なサンプルを動かしてみましょう。一応コピペ用のサンプルを置いておきます。

```sample.py
import logging
import asyncio
from pyppeteer import launch


async def get_html(url: str) -> str:
    async def handle_request(request):
        # リソースタイプがDocumentかScript、xhr以外はアクセスしない
        if request.resourceType in ['document', 'script', 'xhr']:
            # continue_()なのはおそらく予約語と被るため
            return await request.continue_()
        else:
            return await request.abort()

    # Log Levelはlaunch時に指定
    browser = await launch(logLevel=logging.WARNING)
    try:
        page = await browser.newPage()
        # ユーザーエージェント設定
        await page.setUserAgent('pyppeteer/your@mail.address')
        # リソースタイプを指定するため割り込みを有効にする
        await page.setRequestInterception(True)
        page.on(
            'request',
            lambda request: asyncio.ensure_future(handle_request(request))
        )

        # ページ取得
        response = await page.goto(url, timeout=60000)
        if response.status != 200:
            raise RuntimeError('Error: status_code {}'.format(response.status))

        # サイズを指定してスクリーンショット取得。fullPage=Trueにすると当然高さは無視される
        await page.setViewport({'width': 800, 'height': 600})
        await page.screenshot(path='ss.jpg', fullPage=True)  # 拡張子でjpg/png形式を自動設定

        # HTMLを取得して返す
        return await page.content()
    finally:
        await browser.close()

if __name__ == '__main__':
    html = asyncio.get_event_loop().run_until_complete(get_html('https://qiita.com/'))
    print(html)
```

たぶん、動かないと思います。エラーメッセージも良くわかりませんよね。一つ一つ見ていきましょう。

###Chromiumのダウンロードに失敗する

```
ssl.SSLError: ("bad handshake: Error([('SSL routines', 'tls_process_server_certificate', 'certificate verify failed')],)",)
```
このエラーは環境によって出たりでなかったりします。もし出てしまったら、下記パッチを参考に、pyppeteerの一部ファイルを直接書き換えて暫定的に対応するしかないかも。

https://github.com/kiwi0fruit/pyppdf/blob/master/pyppdf/patch_pyppeteer/patch_pyppeteer.py

```Python:pyppeteer/chromium_downloader.py　(Before)
def download_zip(url: str) -> BytesIO:
    with urllib3.PoolManager() as http:
        data = http.request('GET', url, preload_content=False)
```


```Python:pyppeteer/chromium_downloader.py　(After)
def download_zip(url: str) -> BytesIO:
    import certifi
    with urllib3.PoolManager(cert_reqs='CERT_REQUIRED',
                             ca_certs=certifi.where()) as https:
        data = https.request('GET', url, preload_content=False)
```


###予期せぬBrowserエラーで終了してしまう
```
pyppeteer.errors.BrowserError: Browser closed unexpectedly:
```

一番多いのがこのエラーです。これの原因は複数ありますが、まずはライブラリの不足を疑いましょう。そのままでは何が不足しているか分からないので、<a href=""https://rinoguchi.net/2020/08/pyppeteer-manual.html>こちら↓のブログ</a>を参考に、

https://rinoguchi.net/2020/08/pyppeteer-manual.html

下記のPythonスクリプトに適当な名前を付けて実行してみてください。

```Python:
from pyppeteer.launcher import Launcher

cmd = ' '.join(Launcher().cmd)
print(cmd)
```
コンソールに不足しているパッケージのひとつが表示されたと思います。面倒ですが、ひとつひとつ調べてインストールする必要があります。例えば`libnss3.so`の含まれるパッケージを調べるなら

```
$ dnf whatprovides */libnss3.so
```

で表示されたパッケージをインストールしましょう。これを繰り返します。
自分の環境では最終的に追加したものは以下の通りです。CentOSをMinimalでセットアップしていたので数が多いですね。環境によって追加しなければいけないパッケージが違うと思いますので、これでもまだライブラリ不足のエラーが出るようなら、該当のライブラリを調べて追加してください。

```
$ dnf install -y libXcomposite libXcursor libXi libXtst nss cups-libs libXScrnSaver libXrandr alsa-lib pango atk at-spi2-atk gtk3
```

日本語フォントをまだひとつもインストールしてなければ、ついでに入れておきましょう。
（pyppeteerでスクリーンショットを取る予定がなければ不要です）

```
$ dnf install -y google-noto-cjk-fonts-common google-noto-sans-cjk-ttc-fonts google-noto-serif-cjk-ttc-fonts
```

お疲れさまでした。これで上記sample.pyは動くようになったと思います。
sample.pyは例としてごちゃ混ぜにした結果、CSSも画像もないページのスクリーンショットを撮るという意味不明なことをしてますが気にしないでください。
以降は実際にDjangoなどに組み込んでみて遭遇するエラーや、設定項目のTIPSなど。


###cronで実行させたらまたBrowserエラー
これまで設定してきたユーザとは違うユーザ権限で実行すると、またしても`Browser closed unexpectedly`エラーが出ることがあります。
起動オプションに`browser=await launch(options={'args': ['--no-sandbox']})`と指定するか、またはcronの実行コマンドを次のように元のユーザ権限で実行するよう設定してみてください。
`/bin/su [user] -c "/home/[user]/pyppeteer.sh"`　※[user]部分を置き換えてください。


###途中でタイムアウトして終了してしまう。

```
pyppeteer.errors.TimeoutError: Navigation Timeout Exceeded: 30000 ms exceeded.
```

通信状況によっては処理がタイムアウトしてしまうことがあります（デフォルトは30秒）。どうしても頻発するようであれば、タイムアウトまでの時間を延ばして対応することもできます。

```Python:
response = await page.goto(url, timeout=60000)
```


###通信ログがコンソールに大量に流れて困る
Djangoなどに組み込んで開発してるときにありがちだと思うのですが、Log LevelがDEBUGになっているとSEND/RECVの通信ログが大量に流れます。それを抑止するためにはLog Levelを別個に指定すると良いです。

```Python:
import logging
browser = await launch(logLevel=logging.WARNING)
```
CRITICAL/ERROR/WARNINGなど必要に応じて設定しましょう。


###ユーザーエージェントを設定したい

```Python:
await page.setUserAgent('pyppeteer/your@mail.address')
```

で設定できます。


###画像などにはアクセスしないようにしたい
テキストだけが欲しい場合、負荷軽減のために余分なアクセスは減らしたいですよね。
`page.goto()`前に割り込みを有効化することで、リソースタイプを調べてからアクセスするかどうかを決めることができます。

```Python:
async def handle_request(request):
    # リソースタイプがDocumentかScript以外はアクセスしない
    if request.resourceType in ['document', 'script', 'xhr']:
        # continue_()なのはおそらく予約語と被るため
        return await request.continue_()
    else:
        return await request.abort()

# リソースタイプを指定するため割り込みを有効にする
await page.setRequestInterception(True)
page.on(
    'request',
    lambda request: asyncio.ensure_future(handle_request(request))
)
```


###スクリーンショットを取りたい

```Python:
# サイズを指定してスクリーンショット取得。fullPage=Trueにすると当然高さは無視される
await page.setViewport({'width': 800, 'height': 600})
await page.screenshot(path='ss.jpg', fullPage=True)  # 拡張子でjpg/png形式を自動設定
```

`page.setViewport`で仮想画面のサイズ設定をして、`page.screenshot`で保存できます。
画像形式は`path`のファイル名から推測されますが、`type='jpeg'または'png'`としてどちらかを指定することもできます。`fullPage=True`を指定すると、高さ設定は無視されて最下部までの画像が保存されます。`clip={'x': 0, 'Y': 0, 'width': 400, 'height': 300}`と指定して切り取り範囲も限定可能です。


###click()が動作してないように見える

```Python:
await page.click('button.evented')
```

としてもクリックされていないように見えることがあります。スクレイピング先のサイトの作りによってはclickイベントが発火しないようです。一つの対策として、

```Python:
await page.evaluate('() => document.querySelector("button.evented").click()')
とか
button = await page.querySelector("button.evented")
await page.evaluate('elm => elm.dispatchEvent(new Event("click"))', button)
```

とJavascriptを走らせて、その中でclickイベントを発火させる方法があります。


###Cookieをファイルに保存してアクセス時に使いたい
```Python:
cookie_file = 'path/to/cookie'
# 書き込み
cookies = await page.cookies()
with open(cookie_file, 'w', encoding='utf-8') as file:
    json.dump(cookies, file)

# 読み込み
if os.path.exists(cookie_file):
    with open(cookie_file, 'r', encoding='utf-8') as file:
        cookies = json.load(file)
        for cookie in cookies:
            await page.setCookie(cookie)

```


###おわり

スクレイピングはアクセス先の利用規約と良識を守って行いましょう。


<!--urllib3.exceptions.NewConnectionError: <urllib3.connection.VerifiedHTTPSConnection object at 0x7f785fc26550>: 
Failed to establish a new connection: [Errno -2] Name or service not known--->