ちょっと話題の記事

CloudFront+S3なSPAにLambda@EdgeでOGP対応する

2021.07.25

吉川@広島です。

案件でCloudFront+S3なSPAに対してOGP対応が必要になってきそうなため、Lambda@Edgeを使った対応について検証しました。

現状、FacebookやTwitterのBotは基本的にクライアントサイドJSを解釈できず、SPA単体でのOGP対応は難しいとされています。OGPメタタグはSSRで返してあげる必要があるため、「UserAgentでBot判定し、その時だけOGPメタタグ入りのHTMLをエッジサーバでレンダリングして返す」というのが基本戦略になります。

SPAをホスティングするCloudFront+S3を作成する

S3バケットを作成する

バケット名だけ入力し、後はデフォルト値で作成します。

そして、本来であればSPA用のHTML/JS/CSSリソースをアップロードするのですが、今回はLambda@Edgeの動作確認ができれば良かったためパスしました(バケットを空にしておく)。「SPAリソースがあるつもり」で進めていきます。

CloudFront Distributionを作成する

Create distributionから、

  • Origin domain: 作成したS3バケット
  • S3 bucket access: Yes...を選択
    • OAI: Create new OAIから作成しそれを選択
    • Bucket policy: Yes...を選択
  • Viewer protocol policy: Redirect HTTP to HTTPSを選択

の設定で作成します。

Lambda@Edgeを作成する

リージョンをバージニア北部に切り替え、Lambda関数の新規作成をしていきます。

設計図の使用を選択します。「cloudfront」で検索し、cloudfront-modify-response-headerを雛形として使用することにします。選択したら「設定」を押下します。

関数名とロール名を入力します。

  • ディストリビューション: 作成したCloudFront Distribution ARNを入力
  • CloudFrontイベント: ビューアーリクエストを選択
  • Lambda@Edgeへのデプロイを確認: チェックを入れる

としてデプロイします。

以上でLambda関数が作成されるので、次はコードの編集をしていきます。「コードを編集」を押下します。

埋め込みのCloud9コンソールよりコードを編集します。今回は次のようなコードとしました。

const URL_PREFIX = 'https://{SPA_CloudFront_サブドメイン}.cloudfront.net'
const BOTS = [
  'Twitterbot',
  'facebookexternalhit',
  'line-poker',
  'Discordbot',
  'SkypeUriPreview',
  'Slackbot-LinkExpanding',
  'PlurkBot',
]

const generateContent = ({
  title,
  description,
  imageURL,
  mimeType,
  width,
  height,
  url,
}) => {
  console.log(
    JSON.stringify(
      {
        title,
        description,
        imageURL,
        mimeType,
        width,
        height,
        url,
      },
      null,
      2
    )
  )
  return `
    <!doctype html>
    <html lang="ja">
    <head>
      <meta charset="utf-8" />
      <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
      <meta content="width=device-width, initial-scale=1.0" name="viewport" />
      <title>${title}</title>
      <meta content="${description}" name="description">

      <meta content="${url}" property="og:url" />
      <meta content="article" property="og:type" />
      <meta content="ja_JP" property="og:locale" />
      <meta content="${title}" property="og:title" />
      <meta content="${description}" property="og:description" />
      <meta content="${width}" property="og:image:width" />
      <meta content="${height}" property="og:image:height" />
      <meta content="${imageURL}" property="og:image" />
      <meta content="${imageURL}" property="og:image:secure_url" />
      <meta content="${mimeType}" property="og:image:type" />

      <meta content="summary_large_image" property="twitter:card" />
      <meta content="@XXXXX" property="twitter:site" />
      <meta content="${title}" property="twitter:title" />
      <meta content="${description}" property="twitter:description" />
      <meta content="${imageURL}" property="twitter:image" />
    </head>
    <body>
    </body>
    </html>
`
}

exports.handler = async (event) => {
  console.log(JSON.stringify({ event }, null, 2))
  const request = event.Records[0].cf.request
  const path = request.uri

  const userAgent = request.headers['user-agent'][0].value
  console.log({ userAgent })
  const isBot = BOTS.some((v) => {
    return userAgent.includes(v)
  })
  console.log({ isBot })

  if (isBot) {
    const url = `${URL_PREFIX}${path}?${request.querystring}`

    // pathによって画像を出し分ける
    let body
    switch (path) {
      case '/foo':
        body = generateContent({
          title: 'FOO',
          description: 'FOO Page',
          imageURL: 'https://{OGP画像_CloudFront_サブドメイン}.cloudfront.net/2400x1260.png',
          mimeType: 'image/png',
          width: 1200,
          height: 630,
          url,
        })
        break
      default:
        body = generateContent({
          title: 'OGPタイトル',
          description: 'OGP説明',
          imageURL: 'https://{OGP画像_CloudFront_サブドメイン}.cloudfront.net/1200x630.png',
          mimeType: 'image/png',
          width: 1200,
          height: 630,
          url,
        })
    }

    return {
      status: '200',
      statusDescription: 'OK',
      headers: {
        'content-type': [
          {
            key: 'Content-Type',
            value: 'text/html',
          },
        ],
      },
      body,
  }

  return request
}

コードは主に下の記事を参考に、

AmplifyでOGP対応はできない。でもLambda@edgeを使えば大丈夫! | Fixel株式会社

BOTS 一覧はこちらの記事を参考にさせて頂きました。

OGP取得用クローラのユーザーエージェントを、TwitterやLINEなど8サービスで調べてみた - Crieit

コードを編集しデプロイしたら、新規にバージョンを発行します。

CloudFront Behaviorを編集して最新Lambda関数バージョンを反映する

今、Lambda関数バージョンを発行したので、CloudFrontに紐付けるバージョンも更新する必要があります。ARNのバージョン部分を書き換えて更新します。キャプチャはバージョン3と紐付ける例です。

OGP画像をホスティングするCloudFront+S3を作成する

S3バケット、CloudFront Distributionを作成する

SPAホスティングと同じ手順で作成します。

動作確認用の画像をアップロードする

OGPの出し分けを確認するために、画像を2つ用意します。Placehold.jpで1200x630と2400x1260のpng画像を作成し、S3バケットにアップロードしました。

Placehold.jp|ダミー画像生成 モック用画像作成

S3バケットのCORS対応(ハマった)

S3バケットのアクセス許可からCross-Origin Resource Sharing (CORS)を編集します。任意のドメインからのアクセスを許可します。

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET"
        ],
        "AllowedOrigins": [
            "*"
        ],
        "ExposeHeaders": [],
        "MaxAgeSeconds": 3000
    }
]

これを行わない場合、後述するSharing Debugger - Facebook for Developersで確認した際、

Provided og:image URL, https://{SUB_DOMAIN}.cloudfront.net/example.png could not be processed as an image because it has an invalid content type

とエラーになり、OGP画像が表示されなかったので注意しましょう。

動作確認

デバッグツール

表示のされ方を確認したいというのもそうですが、特にUserAgent判定を動作確認するために本物のBotに見に行かせたいという事情があります。

を利用すると良いでしょう。

動作確認結果

Facebook、Twitterの両方とも、placehold.jpで作成したOGP画像を表示できました。URLパスによる出し分けも意図通り機能していました。

Facebook

/fooを確認した場合:

/foo以外を確認した場合:

Twitter

/fooを確認した場合:

/foo以外を確認した場合:

CloudWatchログは米国リージョンも確認する(ハマった)

上記デバッグツールからのアクセスがCloudWatchログに出力されず首をひねっていました。東京リージョンのCloudWatchを見ていて、手元のMacからのアクセスはログ出力されているのに、Botからのアクセスは出ない、なぜ・・・?と思っていたのですが、バージニア北部リージョンを確認すると出力されていました。

これはLambda@Edgeがアクセス元に近いエッジサーバで実行されるためと考えられます。(当たり前といえばそうですが)FacebookやTwitterのBotは米国で動作しているのでしょう。

参考