🌤️

React Native for WebでカスタムOGP

2023/01/30に公開

https://parque.io

株式会社パルケの手を動かすCTO、みつるです。

パルケでは、ビジネスチャットツールのパルケトークを提供しています。

とにかく簡単につながる無料ビジネスチャット パルケトーク

パルケトークで、プロフィールや一部の公開メッセージなどのURLをシェアした時にその内容に応じたカスタムOGPを表示したいという要件が発生しました。

パルケトークはExpoのReact Native製で、Web版はReact Native for Webで提供しています。
WebアプリはSPAとしてビルドされますので、そのままではページの内容に即したOGP情報を出力する事ができません。

そこで、先人たちの知見を参考にさせていただき、パルケトークでカスタムOGPを実現しました。

次のZennの記事は大変参考になりました。

https://zenn.dev/funteractiveinc/articles/cloudflare-pages-functions_ogp

https://zenn.dev/moga/articles/spa-ogp-wiith-cloudflare-worker-kv

実現方法の概要

vercel/ogで、表示する情報を組み合わせたOG画像を動的に生成します。

Cloudflare WorkersのKVにOGPに必要な情報を格納しておきます。

Cloudflare Workersで対象のルートのリクエストの場合に、KVから取得した情報を元にOGPのmetaタグの値を更新します。

各実装の説明

vercel/og

https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation

URLより、OG画像を自動で生成するEdge Functionを作成し、Vercelにデプロイします。
キャッシュが効くので、たくさんのリクエストが発生しても、Edge Functionの実行回数は抑えられるようです。

pages/api/og.tsx

import { ImageResponse } from '@vercel/og';
import { NextRequest } from 'next/server';

export const config = {
  runtime: 'edge',
};
  • vercel/ogライブラリのImageResponseを利用すると、JSXから画像を生成して戻せるようになります。
  • runtimeはedgeを指定します。
export default async function handler(req: NextRequest) {
  const { searchParams } = req.nextUrl;
  const title = searchParams.get('title');
  const contents = searchParams.get('contents');
  const photoUrl = searchParams.get('photoUrl');
  
  // 途中省略
  return new ImageResponse(
    (
      <div
        style={{
          background: '#009B9B',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
        }}
      >
        {/* 内容省略。OG情報を生成するためのコンテンツ */}
      </div>
    ),
    {
      width: 1200,
      height: 600,
    }
  );
}
  • ハンドラーでは、searchParamsからOG画像生成に必要な情報を取得します。
  • そのsearchParamsの情報を使って、JSXで出力する内容を作成し、ImageResponseで画像として返します。
  • あとは、Vercelにデプロイすれば、URLが発行されてそのURLから呼び出せるようになります。

Cloudflare Workers KV

https://developers.cloudflare.com/workers/learning/how-kv-works/

アプリでOG情報を表示したいコンテンツが作成、更新された時に、KVにその情報を送信して更新します。
先に、Cloudflareのコンソール上で、KVの名前空間を作成しておきます。

preview用とproduction用を作成しておくと運用しやすくて良いと思います。

REST APIでのKVの更新

https://api.cloudflare.com/#workers-kv-namespace-write-key-value-pair

KVの情報更新は、REST APIで更新するのが一番手っ取り早いと思います。
バックエンドサーバーで、対象のデータの追加・更新があった時に、REST APIを呼び出して変更を反映します。

  const keyName = `/pr-profile/${id}`;
  const value: OgpInfo = {
    url: 'some url',
    type: 'article',
    title: 'some title',
    description: 'some description',
    image: 'image url' // vercel/ogで画像を生成するためのURL
  };
  const baseUrl = `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_NAME_ACCOUNT_ID}/storage/kv/namespaces/${process.env.CLOUDFLARE_KV_NAME_SPACE_ID}`
  const url = `${baseUrl}/values/${encodeURIComponent(keyName)}`;
  const body = JSON.stringify(value);
  • KVのキーkeyNameは、OG情報を表示するURLのpathnameと同じにしておくと便利です。
  • keyNameは、encodeURIComponentでencodeしておきます。
  • KVの値valueは、オブジェクトをJSON.stringifyで文字列化しておきます。
  • baseUrlは、CloudflareのAPIのエンドポイントとなります。
  const res = await fetch(url, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${apiToken}`,
    },
    body,
  })
  • fetchのPUTでKVのキーバリューを更新します。キーが存在しなければ作成されて、存在すれば更新されます。
  • 保持期間などをクエリパラメータで指定することもできるようです。
  • Authrizationには、マイ プロフィールのAPIトークンで取得したTokenをセットします。

これで、カスタムOGPをセットする準備が整いました。

Cloudflare Workers

https://developers.cloudflare.com/workers/

Cloudflare Workersを利用することで、戻り値のHTMLを強制的に書き換える事ができます。
この機能を使って、HTMLの<head>タグの中の<meta>タグのOG情報を更新します。

前提

Cloudflareに対象のサイトが登録されていて、プロキシされている事が前提となります。
プロキシされている事で、Cloudflare Workersで処理が挟めるようになります。

Routeの割り当て

Workersのトリガーのルートで、特定のサブドメイン、またはパスに該当する場合に起動するように指定できます。

これを使って、カスタムOGPを追加したいルートを絞り込んで、必要以上にWorkerが実行されるのを防ぐ事ができます。

Workerの実装

src/index.ts

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // まずはオリジナルのHTMLを取得
    const response = await fetch(request);
    const url = new URL(request.url);
    // KVから、URLのpathnameをキーにOG情報を取得
    const value = await env.PR_OGP_KV.get(url.pathname);
    if (value) {
      try {
          // 文字列をオブジェクトに変換
        const jsonParsed = JSON.parse(value);
	// zodのスキーマを使ってバリデーション
        const parsed = OgpInfoSchema.parse(jsonParsed);
	// CloudflareのHTMLRewriterを使ってmetaタグを変換
        const transformed = new HTMLRewriter()
          .on('meta', new ElementHandler(parsed))
          .transform(response);
        return transformed;
      } catch (e) {
          // JSONのparseやzodのチェックでエラーになったらオリジナルをそのまま返す
        return response;
      }
    } else {
        // OG情報が取得できなければ、オリジナルをそのまま返す
      return response;
    }
  },
};
  • 先にオリジナルのHTMLを取得して、KVからOGP情報が取得できればmetaタグを置換します。
  • HTMLの変換には、CloudflareのHTMLRewriterモジュールを使用します。

https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/

class ElementHandler {
  og_url: string | undefined;
  og_type: string | undefined;
  og_title: string | undefined;
  og_description: string | undefined;
  og_image: string | undefined;

  // SearchParamsからOGP情報を取得
  constructor(parsed: OgpInfo) {
    this.og_url = parsed.url;
    this.og_type = parsed.type;
    this.og_title = parsed.title;
    this.og_description = parsed.description;
    this.og_image = parsed.image;
  }
  // metaエレメントのOGのコンテンツをセットする
  element(element: Element) {
    // An incoming element, such as `div`
    console.log(`Incoming element: ${element.tagName}`);
    if (element.hasAttribute('property')) {
      const property = element.getAttribute('property');
      switch (property) {
        case 'og:title':
          this.og_title && element.setAttribute('content', this.og_title);
          break;
        case 'og:url':
          this.og_url && element.setAttribute('content', this.og_url);
          break;
        case 'og:type':
          this.og_type && element.setAttribute('content', this.og_type);
          break;
        case 'og:description':
          this.og_description && element.setAttribute('content', this.og_description);
          break;
        case 'og:image':
          this.og_image && element.setAttribute('content', this.og_image);
        default:
          break;
      }
    }
  }
}
  • metaタグを変換するElementHandlerのClassを定義して、HTMLRewriterに渡します。

これでHTMLのOGP情報が更新されるようになりました。

環境設定

wrangler.toml

main = "src/index.ts"

[env.staging]
name = "cloudflare-add-ogp-staging"
vars = { ENVIRONMENT = "staging" }
kv_namespaces = [
  { binding = "PR_OGP_KV", id = "xxx" }
]

[env.production]
name = "cloudflare-add-ogp-prod"
vars = { ENVIRONMENT = "production" }
kv_namespaces = [
  { binding = "PR_OGP_KV", id = "xxx" },
]
  • ステージング環境と本番環境を別々でデプロイできるようにしておき、それぞれで利用するKVの指定しておきます。これで、それぞれの環境とKVをマッピングできます。
  • 実装できたら、wranglerを使ってデプロイします
# ステージング環境のデプロイ
wrangler publish --env staging

# 本番環境のデプロイ
wrangler publish --env production

Cloudflare Workersはデプロイが早いのがいいですね。

React Native for Web

ベースとなるindex.htmlに、OGPの初期値をセットしておきます。

web/index.html

<head>
  <meta property="og:url" />
  <meta property="og:type" />
  <meta property="og:title" />
  <meta property="og:description" />
  <meta property="og:site_name" />
  <meta property="og:image" />
</head>

まとめ

以上、 React Native for WebのようなSPAでも、Cloudflare Workersとvercel/ogを使って動的にOGPを表示する方法を記載しました。

React Native for Webに限らず、他のSPAでも応用可能かと思います。

ただ、複数のサービスにまたがっており、対応箇所も多いため、SSRに比べると遥かに複雑だと思います。

SEO対応が重視されるようなサービスであれば、最初からNext.jsやRemix.runのようなSSRのフレームワークを利用したいですね。

最後に

株式会社パルケでは、無料で誰でも簡単に使えるミーティングアプリ、チャットアプリを運用しています。
興味がありましたらぜひ利用してみてください。

無料でずっと話せるミーティングアプリ パルケミート
とにかく簡単につながる無料ビジネスチャット パルケトーク

また、React・React Nativeを使ったアプリ開発のご依頼も承っております。
お仕事のご相談は気軽にTwitterのDMからご相談ください。

Discussion