12
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Organization

[React+TypeScript+Firebase]FunctionsとHostingで動的にOGP表示するSPAを作った

と思う〇〇であったジェネレーター

アプリのURL: https://thinking-generator.web.app/

Screenshot_20191028-002059_60.png

Twitterでログインして投稿すると「と思う〇〇であった」という画像をOGP表示してツイートします。

作成理由

僕のフォロワーがこのテンプレ画像(?)を自作して投稿していました。
それを見て、「『と思う〇〇であった』画像を自動生成してツイートするアプリ需要あるな!!!!!!」

とは思いませんでしたが、Firebase Functionsを使ってSPAなのにTwitterでの動的なOGP表示の知見を得られるなと思い作成に至りました。

アプリの特徴

Reactで作成されたSPA(Single Page Application)です。

Twitterアカウントでログインして名前とツイート内容を入力して「ツイートする!」ボタンを押すと、Twitterの投稿画面にツイート内容とURLとともに遷移します。
リンクを消さずに投稿すればTwitterがURLを解析してOGP画像を生成してくれて、「と思う〇〇であった」という画像を付けて投稿したような体験が得られます。

あくまでツイートにはURLが記載してあるだけなので、Twitterクライアントの画像一覧である「メディア」タブを汚染することがありません。(ここ重要。たぶん)
また、「ユーザーの使用 = アプリのURLをシェア」とみなせるので、アプリの利用拡大を見込むことができます。

TwitterクライアントがOGP表示を実装しているので利用できますが、非公式クライアントユーザーからはただのURLに見えてしまうので、公式クライアントで閲覧してください。(非公式クライアントユーザーのために画像だけを保存できる機能の実装を検討しています)

ソースコード

Github: https://github.com/stin-dev/thinking-generator

汚いソースコードを公開しています。コンポーネントの分け方のベストプラクティスを知らないので読みにくいかもしれませんがご容赦ください。

SPAでも動的OGP表示について

TwitterやFacebookのクローラーはJavaScriptを実行しません。
なのでクライアントでレンダリングを行うSPAは、そのままでは1種類のOGP表示しかできません。

サーバーサイドを自前で用意している場合は、URLの形によって動的にHTMLのmetaタグを構築してクローラーに返却することができますが、今回はFirebase Hostingを利用しているのでそれは叶いません。

しかしFirebase Hostingは特定のURLへのリクエストを受けた場合、Functionsに処理を委譲する機能を備えています。その機能を使えば動的なOGP生成が可能になります。

同じことを考えて解説している先駆者がたくさんいらっしゃるのでもっとわかりやすい記事を読みたい方は以下を参照してください。(僕も参考にして非常に助かりました。)

https://qiita.com/yuneco/items/5e526464939082862f5d
https://qiita.com/serinuntius/items/3017fb6ef51cd47352f6
https://qiita.com/mitsudaman/items/1956b94dc8faf8fb8c59

Hosting設定

firebase.json
  "hosting": {
    "public": "build",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "/share/*",
        "function": "share"
      },
      {
        "source": "/ogp/*",
        "function": "getOgpImage"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }

rewritesにどのURLへのアクセスをFunctionsに委譲するかを記載します。

この場合、たとえば

https://thinking-generator.web.app/share/example-url

にアクセスすると、Functionsに作成した"share"関数をコールします。

Storageへ画像保存

/src/component/TweetButtonArea.tsx
    const handleClick = (container: GlobalStateContainer) => async () => {
        const currentUser = firebase.auth().currentUser;
        if (!currentUser) return;

        const storageRef = firebase.storage().ref();
        const createRef = storageRef.child(`ogp-images/${currentUser.uid}.jpg`);
        const canvas = document.getElementById("canvas") as HTMLCanvasElement;

        const imagedata = canvas.toDataURL("image/jpeg").split(",")[1];
        await createRef.putString(imagedata, "base64").then(snapshot => {
            const tweeturl = `http://twitter.com/share`
                + `?url=${container.ogpUrl}`
                + `&text=${container.state.tweetText.replace(/\r?\n/g, "%0a")}%0a%0a`

            if (window.open(tweeturl, "_blank")) {

            } else {
                window.location.href = tweeturl;
            };
        })
    }

「ツイートする!」ボタンのonClickイベントです。Storageにogp-images/{currentUser.uid}.jpgという名前でCanvasの画像を保存します。
その後、TweetShareのurlパラメータにhttps://thinking-generator.web.app/share/{currentUser.uid}を付与して画面遷移を行います。

Functions share関数

上記フェーズにてURLを含むツイートが投稿されたらTwitterクローラーはhttps://thinking-generator.web.app/share/{uid}にアクセスします。
しかし実際はfirebase.jsonの設定によってFunctionsのshare関数がコールされます。

/functions/src/share.ts
const createHtml = (uid: string) => {
    const SITEURL = `https://${appDomain}`
    const TITLE = `と思う〇〇であったジェネレーター`
    const DESCRIPTION = '「と思う〇〇であった」という画像をTwitterに投稿するサービスです。'

    return `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>と思う〇〇であったジェネレーター</title>
    <meta property="og:title" content="${TITLE}">
    <meta property="og:image" content="${SITEURL}/ogp/${uid}">
    <meta property="og:description" content="${DESCRIPTION}">
    <meta property="og:url" content="${SITEURL}">
    <meta property="og:type" content="article">
    <meta property="og:site_name" content="と思う〇〇であったジェネレーター">
    <meta name="twitter:site" content="${SITEURL}">
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:title" content="${TITLE}">
    <meta name="twitter:image" content="${SITEURL}/ogp/${uid}">
    <meta name="twitter:description" content="${DESCRIPTION}">
  </head>
  <body>
    <script type="text/javascript">window.location="/";</script>
  </body>
</html>
`
}

引数uid: stringからhtmlを作成する関数です。
ユーザー(ブラウザ)がこのURLにアクセスしてきた場合は、<script type="text/javascript">window.location="/";</script>が実行されてルートURLにアクセスしSPAを表示します。

上記関数で生成したHTML文字列をResponseとして返却します。
https://github.com/stin-dev/thinking-generator/blob/master/functions/src/share.ts#L6-L18

/functions/src/share.ts
const share = async (request: functions.https.Request, response: functions.Response) => {
    const [, , uid] = request.path.split("/");

    try {
        await admin.auth().getUser(uid); // Check if a user with the specified uid exists. If user doesn't, throw error.
        response.set("Cache-Control", "public, max-age=600, s-maxage=600");
        const html = createHtml(uid);
        response.status(200).send(html);
    }
    catch (error) {
        response.status(404).send("404 Not Found");
    }
}

先駆者の方も述べていますが、キャッシュを設定しないとFunctionsのコール回数がすごいことになります。気を付けましょう。

"og:image""twitter:image"に指定するURLは

https://thinking-generator.web.app/ogp/{uid}

という形をしています。
これにより、TwitterクローラーはOGPの画像リソース取得のためhttps://thinking-generator.web.app/ogp/{uid}にアクセスしようとします。

Functions getOgpImage関数

Twitterクローラーがhttps://thinking-generator.web.app/ogp/{uid}にアクセスすると、firebase.jsonの設定によって実際に実行されるのがFunctionsのgetOgpImage関数になります。

/functions/src/getOgpImage.ts
const getOgpImage = async (request: functions.https.Request, response: functions.Response) => {
    const [, , uid] = request.path.split("/");

    const ogpImage = storage.bucket().file(`ogp-images/${uid}.jpg`);

    if (!await ogpImage.exists()) {
        response.status(404).end("404 Not Found.");
        return;
    }

    response.set("Cache-Control", "public, max-age=600, s-maxage=600");
    response.writeHead(200, { "Content-Type": "image/jpeg" });

    ogpImage.createReadStream().pipe(response);
}

クライアントが保存してくれたogp-images/{uid}.jpgをStorageから取得してcreateReadStream()メソッドでresponseに流し込みます。
ここでもキャッシュの設定を忘れずに行いましょう。

まとめ

Firebase HostingのアクセスをFunctionsに委譲する設定、Twitterがmetaタグを収集するshare関数、Twitterが画像を取得するgetOgpImage関数の3つを正しく構築すればSPAでも動的OGP表示を行うことができます。

終わりに

Firebaseアプリの作業フォルダ、絶対にFunctionsとHostingで分けた方がいいなと開発中ずっと思っていました...。エディタが煩雑になる...。

リンク

Github: https://github.com/stin-dev
ポートフォリオ: https://stin-dev.github.io/
Twitter: https://twitter.com/stin_factory

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
12
Help us understand the problem. What are the problem?