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は第一候補で問題ないと思います。