と思う〇〇であったジェネレーター
アプリのURL: https://thinking-generator.web.app/
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設定
"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へ画像保存
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
関数がコールされます。
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
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
関数になります。
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