pyppeteerは、npmモジュールであるpuppeteerをpythonに移植したものです。
例えば、以下のようなことができます。

  • ヘッドレスブラウザ(chromium)を開く
  • 実際にブラウザ内でページを読み込む
  • CSSセレクタでHTMLエレメントを取得する
  • HTMLエレメントをクリックし、画面遷移する
  • JavaScriptも実行されるので動的に描画された後のHTMLを取得する
  • Cookieも共有されるので、Cookieやセッションが必要なサイトのスクレイピングもできる

インストール

pip install pyppeteer
# or
poetry add pyppeteer

シンプルな使い方

こちらのサイトにログインするサンプルを書いてみました。

import asyncio
from pyppeteer.launcher import launch
from pyppeteer.page import Page, Response
from pyppeteer.browser import Browser
from pyppeteer.element_handle import ElementHandle
from typing import List, Any

def main():
    html: str = asyncio.get_event_loop().run_until_complete(extract_html())
    print(html)

async def extract_html() -> str:
    # ブラウザを起動。headless=Falseにすると実際に表示される
    browser: Browser = await launch()
    try:
        page: Page = await browser.newPage()

        # ログイン画面に遷移
        response: Response = await page.goto('http://quotes.toscrape.com/login')
        if response.status != 200:
            raise RuntimeError(f'site is not available. status: {response.status}')

        # Username・Passwordを入力
        await page.type('#username', 'hoge')
        await page.type('#password', 'fuga')
        login_btn: ElementHandle = await page.querySelector('form input.btn')

        # Loginボタンクリック
        results: List[Any] = await asyncio.gather(login_btn.click(), page.waitForNavigation())
        if results[1].status != 200:
            raise RuntimeError(f'site is not available. status: {response.status}')

        # ログイン後の画面のHTML取得
        html: str = await page.content()

        return html
    finally:
        await browser.close()

if __name__ == "__main__":
    main()

ポイントは以下の通りです。

  • pyppeteerに関する処理は全て非同期で実行されるため、忘れずにawaitする必要がある
  • gotoで画面遷移した場合はResponseオブジェクトを受け取れる。ただし、ネットワークエラーなどのResponseが取得できない状況の場合は、エラーがraiseされる
  • ボタンクリックしたい場合は、selectorでエレメントを取得してエレメントをクリックする必要がある
  • クリック後の画面遷移や特定のエレメントの描画を待ち合わせたい場合は、asyncio.gather()を使って複数の非同期処理を待ち合わせる必要がある
    • asyncio.gather()は複数の非同期処理の戻り値をList形式で受け取れる。順番は引数に指定した順番となる
    • 画面遷移を待ち合わせる場合はpage.waitForNavigation()を、特定のエレメントの描画を待ち合わせたい場合はpage.waitForSelector()を利用する
    • ちなみに、クリック後に画面遷移を待ち合わせずにpage.content()でHTMLを取得しようとするとpyppeteer.errors.NetworkError: Execution context was destroyed, most likely because of a navigation.というエラーが発生する

マルチプロセスで実行

ページ数の多いサイトをスクレイピングすることになったら、分散処理で一気に処理を実行したくなることもあると思います。先ほどのシンプルなケースをマルチプロセスで一気に動かして実際に動くか試してみました。こちらも問題なく動きました。

import asyncio
from pyppeteer.launcher import launch
from pyppeteer.page import Page, Response
from pyppeteer.browser import Browser
from pyppeteer.element_handle import ElementHandle
from typing import List, Any

from concurrent import futures
from concurrent.futures import Future

def main():
    future_list: List[Future] = []
    htmls: List[str] = []
    with futures.ProcessPoolExecutor(max_workers=2) as executor:
        for i in range(10):
            future_list.append(executor.submit(pallalel_func))

        for future in futures.as_completed(fs=future_list):
            htmls.append(future.result())

    print(f'htmls count: {len(htmls)}')

def pallalel_func() -> str:
    return asyncio.get_event_loop().run_until_complete(extract_html())

async def extract_html() -> str:
    # 「シンプルな使い方」と同一なので省略

if __name__ == "__main__":
    main()

ポイントは以下です。

  • ProcessPoolExecutorを使ってマルチプロセスで動かしているが、executor.submit()の引数にはコルーチン関数(asyncがついた関数)は使えないので、pallalel_funcという関数を間に挟んでいる
    • コルーチン関数を引数に指定すると、TypeError: cannot pickle 'coroutine' objectというエラーが発生する

Linux上で実行

Dataprocを使ってPySparkクラスタ構成のワーカーノード側で分散して処理を実行しているのですが、Mac OS Xだとうまく動くのですが、Linux(Ubuntu 18.04 LTS)だとうまく動かない部分がたくさん出てきて大変でした。以下の2つは解決に少し時間がかかったので紹介しておきます。
ちなみに、実装自体はMac OS X上で動かしている時と特に変更ありません。

pyppeteer.errors.BrowserError: Browser closed unexpectedly

launch処理でに以下のようなエラーが発生したのですが、まったく原因がログに出力されていません。ライブラリの実装を見るとブラウザのWebSocketのエンドポイントを取得しようとしてタイムアウトが発生しているようです。

  File "/opt/conda/default/lib/python3.7/site-packages/pyppeteer/launcher.py", line 305, in launch
    return await Launcher(options, **kwargs).launch()
  File "/opt/conda/default/lib/python3.7/site-packages/pyppeteer/launcher.py", line 166, in launch
    self.browserWSEndpoint = get_ws_endpoint(self.url)
  File "/opt/conda/default/lib/python3.7/site-packages/pyppeteer/launcher.py", line 225, in get_ws_endpoint
    raise BrowserError('Browser closed unexpectedly:\n')
pyppeteer.errors.BrowserError: Browser closed unexpectedly:

そこで、chromiumを実行するコマンドを直接実行する処理を書いて、実行してみました。

from pyppeteer.launcher import Launcher
import os

cmd: str = " ".join(Launcher().cmd)
print(f'cmd: {cmd}')
os.system(cmd)

すると、以下のようなログが出力されたため、libatk-1.0.so.0が存在しないことが真の原因ということが判明しました。

A 2020-08-07T01:46:22.313971522Z cmd: /home/.local/share/pyppeteer/local-chromium/588429/chrome-linux/chrome --disable-background-networking --disable-background-timer-throttling --disable-breakpad --disable-browser-side-navigation --disable-client-side-phishing-detection --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=site-per-process --disable-hang-monitor --disable-popup-blocking --disable-prompt-on-repost --disable-sync --disable-translate --metrics-recording-only --no-first-run --safebrowsing-disable-auto-update --enable-automation --password-store=basic --use-mock-keychain --headless --hide-scrollbars --mute-audio about:blank --remote-debugging-port=42815 --user-data-dir=/home/.local/share/pyppeteer/.dev_profile/tmp2xd0ki_i 
A 2020-08-07T01:46:22.318604089Z error while loading shared libraries: libatk-1.0.so.0: cannot open shared object file: No such file or directory 

で、結局、以下のようにUbuntuにライブラリを追加してあげることでこの問題は解決できました。

sudo apt-get update
sudo apt-get install -y libatk1.0-0
sudo apt-get install -y libatk-bridge2.0-0
sudo apt-get install -y libgtk-3-0

ssl.SSLError: ("bad handshake: Error([('SSL routines', 'tls_process_server_certificate', 'certificate verify failed')])",)

issueにも上がっていて、urllib3 (1.25). の問題らしいです。

パッチが提供されているので試してみました。pip install pyppdfして、launchをimportする前にパッチを当てましたが、適用方法が間違ってるのかエラーは解決できませんでした。

import pyppdf.patch_pyppeteer
from pyppeteer import launch

仕方ないので、少しセキュリティ的には怖いのですが、urllib3のバージョンを1.24.3に落としてみたところ、うまく動くようになりました。

その他Tips

chromiumのコマンドラインオプションを指定する

chromiumのコマンドラインオプションは色々あるのですが、以下のようにして指定することができます。ignoreDefaultArgsで一旦デフォルト引数を無視した上で、追加したいオプションを設定する感じです。

browser: Browser = await launch(
    ignoreDefaultArgs=True,
    args=['--disable-gpu', '--user-data-dir=/tmp']
)

aタグのエレメントをclick()しても何故か画面遷移しないことがある

何故かaタグがうまく描画されず、aタグのHtmlElementを取得してclick()しても画面遷移が発生しないことがありました。aタグのHtmlElementは正常に取得できていたためそのケースはelement.click()ではなくpage.goto()で画面遷移してしまうことにしました。以下のような感じです。

link_element: ElementHandle = await page.querySelector('.hoge .fuga a')
href_property: JSHandle = await element.getProperty('href')
href_value: str = str(await href_property.jsonValue()).strip()
if href_value.startswith(('http://', 'https://')):
    await page.goto(href_value)
else:
    await link_element.click()

page.goto()による画面遷移の待ち合わせ

上のサンプルで、element.click()の方の待ち合わせの方法は軽く紹介したのですが、ここではpage.goto()による待ち合わせの方法を紹介します

waitUntilで特定のイベントを待つ

page.goto()waitUntilという引数でいつまで待つかを指定することができます。waitUntilには最大4つのイベントを指定することができます。

  • load: loadイベントが発火するまで
  • domcontentloaded: DOMContentLoadedイベントが発火するまで
  • networkidle0: networkコネクションが0個になって500msec経つまで
  • networkidle2: networkコネクションが2個になって500msec経つまで

自分の場合は安全めに、画像やスクリプトが全部読み込まれてloadイベントが発火し、かつ、0コネクションになって500msec経つという設定で以下のようにしています。

await page.goto('http://quotes.toscrape.com', waitUntil=['load', 'networkidle0'])

page.waitForSelectorで特定のエレメントの出現を待つ

例えばnextリンクが出現するまで待ち合わせたい場合は、以下のようにasyncio.gather()を使って実装します。この戻り値は引数の順番に格納されるため、この場合はindex=0がpage.goto()の結果のResponseになります。

results: List[Any] = await asyncio.gather(
    page.goto('http://quotes.toscrape.com', waitUntil=['load', 'networkidle0']),
    page.waitForSelector('document.querySelector('li.next')')
)
response: Optional[Response] = results[0]

page.waitForFunctionで指定したJavaScriptの関数がTrueを返すまで待つ

JavaScriptの関数がTrueを返すまで待つ方法もあります。waitForSelectorの例と同様に、nextリンクが表示されるのを待ちたい場合は以下のような実装になります。

await asyncio.gather(
    page.goto('http://quotes.toscrape.com', waitUntil=['load', 'networkidle0']),
    page.waitForFunction('() => { return document.querySelector("li.next") != null; }')
)

ファイルダウンロードする

以前試した時は調査が足りずファイルダウンロードは諦めたのですが、「リクエストイベントの内容をキャプチャしてその内容でrequestsモジュールで別途リクエストを送信する」という形でファイルダウンロードを実現できました。別記事を起こしてますので、そちらを参照ください。
https://rinoguchi.net/2020/12/pyppeteer-fire-download.html

リポジトリ

今回検証したソースコードは全てgithubにあげてありますので、必要があればこちらを参照ください。

さいごに

javascriptの世界では、puppeteerのほうがseleniumより高速だし安定性があるし新しいし圧倒的に人気なので、あまり深く考えずにpyppeteerを利用することにしたのですが、特に今のところは問題はなさそうです。
pythonでasync/awaitで非同期処理を同期的に書くのは初めてだったのですが、ほぼjavascriptと同じノリでかけるのでそこもすぐ慣れました。
pythonでヘッドレスブラウザが必要な状況なら、自分の中ではpyppeteerは第一候補で問題ないと思います。