【Node.js】Cloud Functions for FirebaseでPuppeteerを使用する上での留意点
中学生の時に趣味でZ80マシン語やFortran等を始めてから、現在まで数多くのプログラミング言語を経験。ShopifyによるECサイト構築では主にカスタマイズを担当。
はじめに
Google Cloudのサービスの一つであるGoogle Cloud Platform(GCP)のCloud Functions、特にFirebaseのCloud FunctionsはFirebase CLIで簡単にエミュレートしたりデプロイしたりが出来るので、API等をサクッと作る上でとても便利です。
最近、APIの無いサイトから定期的にダウンロードした最新データを利用する必要ができ、そのスクレイピングにPuppeteer(パペティア)を使用しました。Cloud FunctionsやAWS Lambda等FaaSでのPuppeteerの使用方法については他サイトに詳しく載っているので、ここでは私の忘備録としてFirebase Cloud FunctionsでPuppeteerを使う上での留意点を残そうと思います。
Puppeteerとは
PuppeteerはGoogleのChrome DevToolsチームがメンテナンスをしており、そのPuppeteer公式サイト上で説明されている通りChrome DevTools Protocol(CDP)でChromeやChromiumを制御するAPIを提供するNode.jsライブラリです。簡単に言えば、Puppeteerを使えばブラウザ操作の自動化が案外簡単に実装できます。
余談ですが、Windowsアプリを作る時にChromium Embedded Framework(CEF3)を使うことがあるのですが、アプリ内でクライアント用JavaScriptのコードを実行できるので、このCEF3を使ってもブラウザ操作の自動化アプリの作成は十分可能です。
もしかするとAtomやVisual Studio CodeやSlackやMS Teams等でおなじみのElectronでもブラウザ操作の自動化アプリの作成は可能かもしれませんが、残念ながら私はまだ使用したことがないので分かりません。
Puppeteerを使う上での留意点
Chromiumのダウンロードディレクトリを変更する
Cloud Functionsのプログラムが配置されるディレクトリ内は書き込み不可になっています。しかしルート直下のtmpディレクトリ、つまり「/tmp」だけは書き込みが出来るので、ダウンロード先にはここを指定しましょう。
ChromiumまたはChromeのダウンロードディレクトリを変更するにはChrome DevTools Protocol(CDP)のsetDownloadBehaviorコマンドを使用します。setDownloadBehaviorコマンドは以前はPage領域(Domain)にあったので「Page.setDownloadBehavior」と記述していたのですが、今回使用しているPuppeteer(v19.7.1)で使用しているChromiumではsetDownloadBehaviorコマンドがBrowser領域の方に移動しているようなので、このPuppeteerのバージョンでは「Browser.setDownloadBehavior」と記述します。
※「Browser.setDownloadBehavior」も実験的(EXPERIMENTAL)となっているので、またどこかに移動する可能性も否定できません。そのためPuppeteerを新たにダウンロードして使う場合は、面倒かもしれませんがその都度Chrome DevTools Protocol(CDP)を確認しましょう。
const isEmulate = process.env.NODE_ENV !== 'production'; const browser = await puppeteer.launch({ headless: isEmulate ? false : true, defaultViewport: null, args: [ "--disable-gpu", "--disable-dev-shm-usage", "--disable-setuid-sandbox", "--no-first-run", "--no-sandbox", "--no-zygote", "--single-process" ] }); const page = (await browser.pages())[0]; const cdpSession = await page.target().createCDPSession(); await cdpSession.send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: isEmulate ? 'c:\\tmp' : '/tmp', eventsEnabled: true });
環境変数「process.env.NODE_ENV」がproductionかどうかでエミュレーターか実環境かを判定しています。
※process.env.FUNCTIONS_EMULATORがtrueかどうかでも判定できるらしいのですが、試したことはありません。
私のローカル環境はWindowsなのでエミュレート中は「c:¥tmp」にダウンロードして動作確認しています。¥マーク(バックスラッシュ)はバックスラッシュでエスケープしておいてください。
ファイルダウンロード終了を検知する
PuppeteerやCDPにはダウンロード終了を通知してくれるようなメソッドとかAPIとかが準備されていないようです。そのためあまり綺麗なやり方ではないのですが、私の場合は定期的にtmpディレクトリを監視し、拡張子が「.crdownload」のファイルが存在しない時はダウンロードが完了したと判定する、あるいは目的の拡張子のファイルが出現した時にダウンロードが完了したと判定したりしています。
const fs = require('fs'); const path = require('path'); let filename; //(省略) //csvファイルが出来るまで待つ例 filename = await ((async() => { let files, filename; while (!filename || filename.endsWith('.crdownload')) { files = await fs.readdirSync('/tmp'); filename = await files.find(fname => path.extname(fname) == '.csv'); await page.waitForTimeout(3000); } return filename; })());
ファイルができてから、Cloud Storageに保存処理する等をしてください。
以下がその例です。
const fs = require('fs'); const path = require('path'); const admin = require('firebase-admin'); admin.initializeApp(); const storage = admin.storage(); //(省略) const isEmulate = process.env.NODE_ENV !== 'production'; //(省略) //Cloud Storageへの転送例 let buffer, filename = 'tmpディレクトリにある保存元のファイル名。上のコード例のfilenameを流用する場合はここで指定する必要はありません'; if(!isEmulate){ buffer = await fs.readFileSync(path.join('/tmp/', filename)); destpath = '保存先(Cloud Storage)のファイル名'; await storage.bucket().file(destpath).save(buffer); }
上記でFirebaseの同じプロジェクトのCloud Storageに保存できます。
※firebase CLIのデフォルトではCloud Storageのエミュレートは動作しないので、エミュレーターの時は動作しないようにしています。(最近のfirebase CLIはCloud Storageのエミュレートが実装されたらしいのですが、実はやり方がまだよく分かっていません。。。)
tmpディレクトリ内のファイル削除例
次々ダウンロードする必要がある時、そのままではメモリを圧迫していくのでダウンロードが完了するたびにtmpディレクトリ内に出来たファイルをCloud Storage等に保存後に削除したほうが良いでしょう。
しかし、このtmpディレクトリに「.cache」ディレクトリができるので、Chromium終了前にtmpディレクトリ内のファイルを全削除するのは絶対にしないでください。
const fs = require('fs'); const path = require('path'); //(省略) //tmp内のファイル削除例 const cleantmp = () => { const directory = '/tmp/'; fs.readdir(directory, (err, files) => { if (err) throw err; for (let file of files) { if(path.extname(file) == '.csv' || path.extname(file) == '.zip'){ fs.unlinkSync(directory+file); } } }); }
上記はtmpディレクトリ内のcsvファイルとzipファイルを削除する例で、「cleantmp();」で呼び出します。
Puppeteer v19以降についての追記(2023/4/4)
Puppeteerのv19以降では、互換性に影響する破壊的な変更がありました。
v19以降、Puppeteer はデフォルトではChromiumを「$home/.cache/puppeteer」(Windowsの場合は「%homepath%/.cache/puppeteer」)にインストールできるようになりました。グローバルな場所に置かれたことにより各プロジェクトで共通のChromiumが使えるようになり効率的になりました。
ローカル環境やCloud RunやAWS Lambdaとかコンテナイメージ等で実行するだけならこのままでも問題はないのですが、このグローバルな場所のままではデプロイ後の実行環境であるCloud FunctionsからChromiumへの参照が出来ません。そこでローカルでのChromiumのインストール先をプロジェクト内のfunctionsディレクトリに変更する必要があります。
以上の理由からv19以降のPuppeteerを使用する場合は、functionsディレクトリ直下に以下のファイルを作成して設置する必要があります。
ファイル名「.puppeteerrc.cjs」
const {join} = require('path'); /** * @type {import("puppeteer").Configuration} */ module.exports = { // Changes the cache location for Puppeteer. cacheDirectory: join(__dirname, '.cache', 'puppeteer'), };
すでにPuppeteerがインストール済みの場合はPuppeteerを再インストールし直してください。これで「プロジェクト名/functions/.cache/puppeteer」にローカル環境版(私の場合はWindows版)のChromiumがインストールされます。
npm uninstall puppeteer npm install puppeteer
HerokuやRender等の場合はここまでの対応で良いのでしょうが、Cloud Functionsの場合はファイルサイズが大き過ぎてデプロイに失敗するのでもうひと工夫がいります。Cloud Functionsの場合はChromium本体自体のデプロイは不要なので、プロジェクト内の「.cache」ディレクトリ内のファイルがデプロイされないように「firebase.json」のignoreを編集します。デプロイ時はCloud Functions側にLinux版のChromiumが自動的にインストールされます。
{ "functions": [ { "source": "functions", "codebase": "default", "ignore": [ "node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log", ".cache" ] } ] }
以前のバージョンのPuppeteerではChromiumはnode_modules配下にインストールされていたので上記「firebase.json」のignoreを見ても分かるようにデフォルトのままでも気にする必要はありませんでしたが、v19以降ではファイル「.puppeteerrc.cjs」の設置と「firebase.json」のignoreに.cacheを追加する必要があるので注意してください。
最後に
GoogleはECの拡大に力を入れており、2021年5月にShopifyとの連携強化を発表しました。特にアメリカではAmazonとGoogleとの競争が激化しているようです。
ShopifyはインフラをGoogle Cloud Platformに頼っているでけではなく、Shopify「Googleチャネルアプリ」で Google Merchant Centerと簡単に同期できたり簡単に無料リスティング広告を掲載できたり等、Googleとの関係を次々深めており、今後はShopifyマーチャントだけではなくECサイトにかかわる運営者達に大きな影響を与えるでしょうね。