突然ですが、 README.md に動的なコンテンツを埋め込みたいと思ったことはないですか?僕はあります。 具体的には、リポジトリのコントリビューターをREADME.mdに埋め込みたいという願望がありました。
つまりこういうことです。
しかし毎回CIなどでREADME.mdを編集するのはセットアップが面倒です。
<contributors-list>
みたいなCustom Elementsが使えたらきれいな世界だなあと思ったのですが、肝心のscriptタグが動かないのでそれは無理です。
ということで、頼れるのは 画像 ということになりました。
Image via Function
README.mdに埋め込めて、なおかつ動的なコンテンツを扱えるのは画像のURL展開だけなので、つまりコントリビューターリストを画像化するHTTPエンドポイントを用意し、そのURLをREADME.mdに埋め込めばいいわけですね。同じことはすでにCIサービスのSVGバッジなどで広く行われているので、これ自体は特筆すべきことではないと思います。
問題はコントリビューターリストの画像をどうやって作るかということです。今回はまずコントリビューターリストを表示するAngularアプリケーションを作り、そのスクリーンショットをPuppeteerで撮影する、という流れを実装しました。
Angularアプリ
シンプルにAngular CLIで作成し、@octokit/rest
を使ってGitHub APIを呼び出しています。単純なアプリケーションです。
これをFirebase Hostingで公開し、Puppeteerで表示できるようにします。
https://contributors-img.firebaseapp.com/repo=angular/angular-ja
ポイントとしては、画像化したい要素にIDを付与していることです。このアプリケーションでは後ほど画像化するときに #contributors
を指定します。
Puppeteer on Cloud Functions
Cloud Functions for FirebaseではHeadless Chromeを便利に扱えるPuppeteerを実行できます。 Puppeteerを使ってHeadless Chromeを起動して、先ほどのAngularアプリケーションにアクセスし、スクリーンショットを撮影します。
async function renderContributorsImage(repository: string): Promise<Buffer> { const browser = await puppeteer.launch( isDebug ? {} : { headless: true, args: ['--no-sandbox'], }, ); const page = await browser.newPage(); await page.goto( `https://contributors-img.firebaseapp.com?repo=${repository}`, ); const screenshotTarget = await page.waitForSelector('#contributors'); await page.waitForResponse(() => true); const screenshot = await screenshotTarget.screenshot({ type: 'png', omitBackground: true, }); return await browser.close().then(() => screenshot); }
ここのポイントは await page.waitForResponse(() => true)
でGitHubアバター画像のリクエストをすべて待っているところです。
これを待たないとHTMLのレンダリングが終わっただけではまだ画像が空っぽになるからです。
Cache in Cloud Storage
Cloud Functions上でPuppeteerを動かすのはメモリを多く使うし実行時間も長くてお金が結構かかりますし、毎回画像を作るのはパフォーマンスも悪いです。 FirebaseのCloud Storage上にリポジトリごとに画像をキャッシュし、キャッシュがある場合はキャッシュから返します。 Cloud StorageのBucketにExpirationの設定をしているので、24時間経った古いキャッシュは自動的に削除されます。Firebaseコンソール側からはこの設定が見えないんですが、GCPのコンソールからアクセスすると普通に使えます。
async function _createContributorsImage(repository: string): Promise<Buffer> { const cacheId = generateCacheId(repository); const cacheFile = bucket.file(cacheId); console.log(`Look for a cache...`); if (await cacheFile.exists().then(data => data[0])) { console.log(`Return from the cache`); return cacheFile.download().then(data => data[0]); } console.log(`Render an image`); const image = await renderContributorsImage(repository); console.log(`Save new cache`); await cacheFile.save(image, {}); console.log(`Return rendered image`); return image; }
Puppeteerを使うHTTP Trigger Functionの定義
Puppeteerはメモリを多量に使うので、Function定義時にメモリの設定が必要です。1GBあればまあ動きます。 ブラウザの起動、Angularアプリの実行、スクリーンショット撮影と保存まで含めると結構時間がかかるので、タイムアウト時間も30秒にしてあります。
export const createContributorsImage = functions .runWith({ timeoutSeconds: 30, memory: '1GB', }) .https.onRequest((request, response) => { const repoParam = request.query['repo']; if (!repoParam || typeof repoParam !== 'string') { response.status(400).send(`'repo' parameter is required.`); return; } _createContributorsImage(repoParam) .then(image => { response.setHeader('Content-Type', 'image/png'); response.status(200).send(image); }) .catch(err => { console.error(err); response.status(500).send(err.toString()); }); });
完成!
というわけで画像を展開できる場所であればどこでもコントリビューターリストを表示できるようになりました!
例:
https://github.com/angular/angular-ja/blob/283047c5686cb3957966a671264e4c1d7b32a073/README.md
24時間ごとにしか更新されない課題はありますが、それほどリアルタイム性があるデータではないので問題ないでしょう。
課題としてはPuppeteerでGitHubアバター画像をダウンロードするときに外部ネットワークが発生するので、僕のお財布にダメージが大きいところです。しばらく従量課金で動かしますが破産しそうになったらリポジトリにホワイトリストを作って運用します。
まとめ
- Cloud Functionを使って画像を返せば、簡単に動的コンテンツを静的ページに埋め込める
- インタラクティブ性はないが諦める
- Cloud Functions + Puppeteer + Webapp で、慣れてるHTML+CSSで作ったUIを画像化できる
- 画像処理頑張らなくてもいい
久々に趣味プロらしい趣味プロをしたら楽しかったです。また何か思いついたらやります。