先日、毎朝8時に私の住んでいる地域の洗濯指数をSlackで送ってくれるプログラムを作りました。天気予報とか自分で見ないもので、、今日洗濯しようかどうか決めるのに役立っています。
仕組みとしては、GCPのCloud Scheduler→Pub/Sub→FirebaseのCloud Functionsの流れでPuppeteerでtenki.jpの特定のページをスクレイピングしています。Puppeteerいいですよねぇ。
ただ、最初に実装したときはPuppeteerの速度があまりにも遅すぎて、タイムアウト(30秒以上)が発生してしまう状況でした。スクレイピングしているのは1つのページだけなので、どう考えてもおかしかったです。(というかそもそも、Puppeteerのlaunchの時点でかなり時間がかかってるっぽかった)調べてみたところ、同じことを感じてる人は他にもいるようで、公式にもissueが立っていました。
https://github.com/GoogleChrome/puppeteer/issues/3120
どうやらAWS Lambdaに比べて、Cloud FunctionsはPuppeteerとの相性が悪く、かなり遅くなってしまうようです。GCP/Firebase好きの私にとっては悲報だったのですが、色々試してみるとかなり早くなったので、高速化のTipsとしてご紹介します。次の2点になります。
- launchのオプションを設定する
- 不要なリクエストをabortする
launchオプションを設定する
Puppeteerをlaunchする際に{headless: true}
だったりオプションを設定できるのですが、argsに追加の引数を設定できることができます。
上記のissueに「このオプションにしたら速くなったぜ!」とコメントしている人がいたので、そのまま拝借させていただいていますが、正直あまり内容は理解できていません。。でも確かに速くなりました。
https://github.com/GoogleChrome/puppeteer/issues/3120#issuecomment-415553869
puppeteer.launch({
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--no-first-run',
'--no-sandbox',
'--no-zygote',
'--single-process'
]
})
ちなみに設定できる値とその意味の一覧は以下になります。めちゃくちゃ膨大ですが、良ければ参考にしてみてください。
https://peter.sh/experiments/chromium-command-line-switches/
不要なリクエストをabortする
Puppeteerにはリクエストをインターセプトする機能があります。ChromeのデベロッパーツールのNetworkをご覧になったことがある方にはわかると思いますが、ブラウザからページにアクセスする際に、たった1つのページにアクセスするのにかなり多くのリソースへのリクエストが走ります。今回スクレイピングの対象にしているページも200以上のリクエストが走っていました。
しかし、通信内容を良く見てみると、最初のリクエストの時点でスクレイピングに必要なHTMLは既に返ってきていて、あとのリクエストでは画像やスタイルシート、スクリプトなどが読み込まれていることがわかります。(今回のtenki.jpの場合はそうでしたが、もちろんサイトによって異なります)つまり、200以上のリクエストのうち最初の1つのリクエスト以外は不要となります。
Puppeteerではpage.setRequestInterception(true)
でリクエストに対するインターセプトを有効にして、page.on('request', request => { /* do something */ })
でインターセプト時のコールバック関数を指定できます。
const scrapingUrl = 'https://tenki.jp/indexes/cloth_dried/3/15/4510/'
page.on('request', request => {
if (scrapingUrl === request.url()) {
request.continue().catch(err => console.error(err))
} else {
request.abort().catch(err => console.error(err))
}
})
今回は最初のリクエスト以外は弾きたいので、request.url()
を参照してcontinueするかabortするか振り分けています。
他にもrequest.resourceType()
であったりURLの拡張子で判定して画像ファイルのリクエストはabortするとか色々なやりかたができます。スクレイピング対象のサイトにあった方法を模索してみてください。
https://github.com/GoogleChrome/puppeteer/blob/v1.11.0/docs/api.md#pagesetrequestinterceptionvalue
最後に
以上の対応で、30秒以上かかってタイムアウトしていたのが、結果的に5秒程度で処理が完了するようになりました。でもそもそもはこんなことしなくても速く動くようになってほしいですね。。(切な願い)
最終的なコード(pageのsetUpまで)は以下になります。リポジトリでもソースを公開していますので、良ければ参考にしてみてください。
https://github.com/masaki-koide/clothes-index-slack-bot
let page: puppeteer.Page
const scrapingUrl = 'https://tenki.jp/indexes/cloth_dried/3/15/4510/'
async function setUpPage() {
if (page) {
return
}
const browser = await puppeteer.launch({
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--no-first-run',
'--no-sandbox',
'--no-zygote',
'--single-process'
]
})
page = await browser.newPage()
await page.setRequestInterception(true)
page.on('request', request => {
if (scrapingUrl === request.url()) {
request.continue().catch(err => console.error(err))
} else {
request.abort().catch(err => console.error(err))
}
})
}