📷

Cloud Functions for firebaseでOGP画像を動的に生成する

2022/02/14に公開

以前書いた記事を加筆、修正したものです。

ZennやQiita、はてなブログなどでは記事をSNSでシェアした際、OGP画像を表示するようにしています。
PoliPoli Govでも同様に、コメントをSNS等でシェアする際、以下のようなOGP画像を付けたいという要望がありました。

この記事では動的にOGP画像を作成するために検討した手段および実装を紹介します。

検討手法

今回機能を実装するにあたり、以下の方法を検討しました。

  1. 画像変換サービスcloudinaryを使う
  2. Vercelでog-imageを使う
  3. firebase functionsで独自実装をする

画像変換サービスcloudinaryを使う

https://cloudinary.com/

とりあえず動かすには、一番簡単な手法だと思います。
Cloudinaryはクラウドの画像変換サービスで、あらかじめCloudinaryにアップロードした画像をベースに、サイズ、文字、レイアウトなどなどをURLで指定することで、加工された画像をレスポンスで受け取ることができます。CDNを内蔵しており、一度作成した画像は素早く読み込むことができるというところも魅力の一つです。軽く触ってみるには以下の記事がわかりやすいです。
https://catnose.me/notes/cloudinary-dynamic-ogp-image

ただ今後のOGP画像内にユーザ画像を動的に挿入するという可能性がありました。Cloudinaryで画像を複合するにはあらかじめ複合する画像をサービスにアップロードしておく必要があり、管理工数の観点から今回は採用を見送りました。(※この記事で画像の埋め込みまでは紹介しません。が、拡張は簡単です。)
また、Cloudinaryは基本的にgoogle fontsに対応していると言っているものの、筆者が確認した段階では結構対応していないものがありました。お気をつけください。

Vercelでog-imageを使う

https://github.com/vercel/og-image

こちらのリポジトリはVercel公式から提供されているもので、serverless functionsによる実行を想定しています。テンプレートのようなもので、フォークしてカスタマイズして使ってね!と言った感じです。
中身はHTMLをpuppeteerで描画し、スクリーンショットを撮って画像を作成するという処理になっており、HTMLを自分の表示したい画像に合わせて書き換えることで、簡単にオリジナルのものが作れます。
これ見つけたとき、めっちゃ便利やん!!と興奮したのですが、コストの面から見送りました。
Vercelは商用だと開発者1人あたり20ドルからとなっており、少々お高いです。また本プロジェクトはfirebaseをメインに使っており、管理プラットフォームを増やしたくないという背景もありました。

firebase functionsで独自実装をする

Vercelは値段の問題から見送りましたが、実装はシンプルなため、firebase functionsで実行できるようにカスタマイズすることにしました。既存のサービスもほとんどがfirebase上に構成されており、親和性が高いことも決め手の一つでした。
今回はog-imageを参考に、puppeteerで描画した画面をスクリーンショットし画像を作成するという手段を用いましたが、canvasを使った画像作成などの方法もあります。

実装

パッケージインストール

npm i puppeteer-core chrome-aws-lambda

使用するパッケージはpuppeteer-corechrome-aws-lambdaです。puppeteerのみでも画像の作成はできますが、chrome-aws-lambdaを使うと処理が速くなるという報告があり、こちらも使用します。

Chromium Binary for AWS Lambda and Google Cloud Functions

パッケージ名からAWS Lambda専用みたいな感じがしますが、Cloud Functionsでも使えます。

関数実装

今回はHTTPリクエストで指定された文字列をもとにOGP画像を作成・返却する関数を実装します。
https://us-central1-<project-id>.cloudfunctions.net/<function-name>/<表示したい文字>のリクエストに対し、表示したい文字を中心とした画像を作成します。

コードはこんな感じになります。

import * as functions from 'firebase-functions';
import core from 'puppeteer-core';
import chrome from 'chrome-aws-lambda';

export const onRequest = functions
    .runWith({
      timeoutSeconds: 30,
      memory: '4GB',
    })
    .https.onRequest(async (req, res) => {
      try {
        const title = decodeURI(req.path.slice(1));
        const img = await createOgpImage(title);
        res.type('png').status(200).send(img);
      } catch (error) {
        res.sendStatus(500);
      }
    });

const createOgpImage = async (title: string) => {
  const html = getHTML(title);
  const file = await genImage(html);
  return file;
};

const getHTML = (text: string) => {
  return `<!DOCTYPE html>
  <html>
  <meta charset="utf-8">
  <title>Generated Image</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
      .container {
          position: relative;
      }
      .center {
          position: absolute;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          font-size: 30px;
      }
      #bg-img {
          width: 1200px;
          height: 630px;
      }
  </style>
  <body>
      <div class="container">
          <div class="center">${text}</div>
          <img id='bg-img'
              src='path-to-background-image'>
      </div>
  </body>
  </html>
  `;
};

const genImage = async (html: string) => {
  const options = await getOptions();
  const browser = await core.launch(options);
  const page = await browser.newPage();
  await page.setViewport({width: 1200, height: 630});
  await page.setContent(html);
  const file = await page.screenshot();
  return file;
};

const isLocal = (): boolean => {
  return process.env.FUNCTIONS_EMULATOR === 'true';
};

const localExePath =
  process.platform === 'win32' ?
    'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe' :
    process.platform === 'linux' ?
    '/usr/bin/google-chrome' :
    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';

interface Options {
  args: string[];
  executablePath: string;
  headless: boolean;
}

const getOptions = async () => {
  let options: Options;
  if (isLocal()) {
    options = {
      args: [],
      executablePath: localExePath,
      headless: true,
    };
  } else {
    options = {
      args: chrome.args,
      executablePath: await chrome.executablePath,
      headless: chrome.headless,
    };
  }
  return options;
};

ます注意点として、functionのメモリは4GB以上にしてください。2GB以下だとメモリが足りずに失敗します。

処理としては、まず

const title = decodeURI(req.path.slice(1));

で、URLから表示したい文字列を取得します。日本語を取り扱う場合は、decodeURIが必要になります。

続いてgetHTML関数で画像の元となるHTMLを指定します。ここをいじることで好きなフォーマットの画像を作れます。

genImageでpuppeteerを用いてHTMLからスクリーンショットを撮り、画像を作成します。ローカル(firebase emulator)での実行もできるように、isLocal関数でpuppeteerのオプションを変更できるようにしています。ローカルで実行する場合はLocalExePathが環境に合わない可能性があるので、適宜変更してください。

完成!

最後に

今回はHTTPリクエストに応じて文言を変更するという実装だったため、あまりfirebase functionsを用いる利点はありませんでしたが、firestoreのデータをOGP画像に載せたいといったケースには非常に便利かなと思います。

注意点として、firebase functionsは前のリクエストからの時間が空くと起動が遅くなります。(私が行った中では6~8秒ほどかかる場合もありました。)
検証はしていませんが、クライアントのクローラによってはタイムアウトするかもしれません。対策として、Storageを使ってキャッシュしたり、頑張ってコールドスタート対策したり、割り切って予めOGP画像を作成しておいたりする必要があります。

宣伝

弊社ではフルタイム、パートタイム問わずエンジニアを大募集中です!!!
是非一度お話ししましょう!
https://polipoli.notion.site/polipoli/PoliPoli-97249831893141dc968440811591fbe3

Discussion