Vue.jsとFirebaseでOGP画像生成系のサービスを爆速で作ろう

## はじめに
質問箱や、ボタンメーカー、診断メーカー等を始めとする **「OGP画像生成系」** を ２個以上作ってそれのベストプラクティスがわかってきたので、共有したいと思います。

## 宣伝
この技術を使ったサービスを実稼働２日ぐらいで作りました！

使い方は簡単です！
メッセージカードを書いて、Twitterにシェアするだけ。

[#嵐ありがとう](https://a-ra-shi.fan/share)

![Screenshot from 2019-01-31 23-22-53.png](https://qiita-image-store.s3.amazonaws.com/0/82779/e3bfc1f4-8125-4026-a006-904e4048f53e.png)

![Screenshot from 2019-01-31 23-23-31.png](https://qiita-image-store.s3.amazonaws.com/0/82779/3dbdb3d0-df20-4bc0-ee95-4b09f6b44ffc.png)

## OGP画像生成系サービスとは？

- 質問箱
![Screenshot from 2019-01-31 22-17-37.png](https://qiita-image-store.s3.amazonaws.com/0/82779/ef90616b-58a4-8ec4-282b-8374a8559392.png)

- みんなのボタンメーカー
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">（｡･ρ･）ｵｼﾃﾐﾃ<br><br>キズナアイな気分のときに押すボタンを作りました！みんな押してね！<a href="https://twitter.com/hashtag/%E3%82%AD%E3%82%BA%E3%83%8A%E3%82%A2%E3%82%A4%E3%81%AA%E6%B0%97%E5%88%86%E3%81%AE%E3%81%A8%E3%81%8D%E3%81%AB%E6%8A%BC%E3%81%99%E3%83%9C%E3%82%BF%E3%83%B3?src=hash&amp;ref_src=twsrc%5Etfw">#キズナアイな気分のときに押すボタン</a><a href="https://twitter.com/hashtag/%E3%81%BF%E3%82%93%E3%81%AA%E3%81%AE%E3%83%9C%E3%82%BF%E3%83%B3%E3%83%A1%E3%83%BC%E3%82%AB%E3%83%BC?src=hash&amp;ref_src=twsrc%5Etfw">#みんなのボタンメーカー</a><br> <a href="https://t.co/2mzJUE48zj">https://t.co/2mzJUE48zj</a></p>&mdash; Kizuna AI@hello,2019‼︎ (@aichan_nel) <a href="https://twitter.com/aichan_nel/status/1090172600569843712?ref_src=twsrc%5Etfw">2019年1月29日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

[「ツイッターでつぶやけるボタン」を簡単に作成できるサービスをリリースしました【個人開発】](https://qiita.com/retoruto_carry/items/d6a173701c8e40f0c294)

すごくいいサービスですよね！

- 診断メーカー
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">【ブチ切れた時の沙希】<br>これヤバいなwww<a href="https://t.co/uJfHj1fzLi">https://t.co/uJfHj1fzLi</a><a href="https://twitter.com/hashtag/%E3%81%82%E3%81%AA%E3%81%9F%E3%81%8C%E3%83%96%E3%83%81%E5%88%87%E3%82%8C%E3%81%9F%E3%82%89?src=hash&amp;ref_src=twsrc%5Etfw">#あなたがブチ切れたら</a><a href="https://t.co/8Je16QvMX3">https://t.co/8Je16QvMX3</a></p>&mdash; 沙希@オリジン済み (@A1to839alo) <a href="https://twitter.com/A1to839alo/status/1090965252894711808?ref_src=twsrc%5Etfw">2019年1月31日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

こういう系のリンクを共有したときに、画像が生成されて共有されるサービスのことを指します。

## どうすれば簡単に作れるか？
こういう画像って作るの面倒そうじゃないですか。

僕も昔はImageMagickで頑張って合成したりして作ってたのですが、もっと簡単な方法を思いつきました。

そうだ**SVGを使おう！**

## 構成図
1. IllustratorでSVGのデザインをする(デザイナーに丸投げ)
2. Vue.jsでSVGの中をテンプレートでいい感じにする
3. Canvasに書き出してPNGに変換する
4. それをFirebaseのCloud Storageにアップロードする

![Web 1920 – 2.png](https://qiita-image-store.s3.amazonaws.com/0/82779/9c88bb9d-77ac-c4e7-e8c2-ab93283a90ae.png)

## メリット
- OGPのデザインに無限の可能性が広がる
- わざわざ画像生成用のサーバを用意しなくていい
- ユーザがリアルタイムでプレビューできる
- フロントで生成しているので、コストが安い
- 絵文字が使える！（これはヤバい！

## デメリット
- 特に思いつかないです

## 準備
```bash

 npm install -g @vue/cli
 npm install -g firebase-tools
 vue create my-project # ここお好きなプロジェクト名
 cd my-project
 npm i
 npm i --save firebase
 firebase init
```

## OGP生成のフロントのコード(雰囲気)
イラレ等で生成されたSVGをおもむろにぶちこんでください。
サンプルで適当にメッセージを入れて検索をかけるのがおすすめです。
サンプルメッセージを`{{msg}}`等Vueの変数に置換します。



```html

<template>
  <div class="hello">
    <svg ref="svgCard">
      <text transform="translate(103.29 347.281)" fill="#e51f4e" font-size="29" font-family="HiraginoSans-W5, Hiragino Sans" letter-spacing="-0.002em">
        <tspan x="0" y="26">{{ msg }}</tspan>
      </text>
    </svg>
    <input v-model="msg" type="text">
    <button @click="create">create</button>
  </div>
</template>

<script>
import firebase from 'firebase'

// Webコンソールから取得したコンフィグをペースト
const config = {
    apiKey: "",
    authDomain: "hogefuga.firebaseapp.com",
    databaseURL: "https://hogefuga.firebaseio.com",
    projectId: "hogefuga",
    storageBucket: "hogefuga.appspot.com",
    messagingSenderId: "323003240989"
  };
firebase.initializeApp(config)
const db = firebase.firestore()

// svgをpngに変換する関数
function svg2imageData (svgElement, successCallback, errorCallback) {
  var canvas = document.createElement('canvas')
  canvas.width = 1200
  canvas.height = 630
  var ctx = canvas.getContext('2d')
  var image = new Image()
  image.onload = () => {
    ctx.drawImage(image, 0, 0, 1200, 630)
    successCallback(canvas.toDataURL())
  }
  image.onerror = (e) => {
    errorCallback(e)
  }
  var svgData = new XMLSerializer().serializeToString(svgElement)
  image.src = 'data:image/svg+xml;charset=utf-8;base64,' + btoa(unescape(encodeURIComponent(svgData)))
}


export default {
  name: 'hello',
  data () {
    return {
      msg: 'Welcome to Your Vue.js PWA',
      uuid: '' // 適当に採番する
    }
  },
  methods: {
    create() {
      // refでsvgCardをsvgに設定しているのでthis.$refs.svgCardで要素を取れます
      svg2imageData(this.$refs.svgCard, (data) => {
        const sRef = firebase.storage().ref()
        const fileRef = sRef.child(`${this.uuid}.png`)

        // Cloud Storageにアップロード
        fileRef.putString(data, 'data_url').then((snapshot) => {
          // Firestoreに保存しておく
          const card = db.collection('cards').doc(this.uuid)

          return card.set({
            message: this.description
          }, { merge: false })
        }).then(docRef => {
          console.log(docRef)
        }).catch(err => {
          console.error(err)
        })
      })
    }
  }
}
</script>
```

本当はもうちょっとちゃんといろいろした方がいいですけど、サンプルなので良しとします。

## OGP表示側(CloudFunction)コード
`https://<domain>/s/:id`

というURLにアクセスしたときに、OGPのメタタグが出るようにFirestoreから取得し、Cloud Storageから画像を取得するやつです。

```js
const functions = require('firebase-functions')
const express = require('express')
const app = express()
const admin = require('firebase-admin')

admin.initializeApp(functions.config().firebase)

const db = admin.firestore()

let projectId, keyFilename, bucketName

// Firebaseのproject ID
projectId = '<FILL ME>'
keyFilename = 'privateKey.json'

// OGPが保存されてるCloudStorageのバケット
bucketName = '<FILL ME>'

async function generateSignedUrl (bucketName, filename) {
  // [START storage_generate_signed_url]
  // Imports the Google Cloud client library
  const { Storage } = require('@google-cloud/storage')

  // Creates a client
  const storage = new Storage({
    projectId,
    keyFilename
  })

  /**
   * TODO(developer): Uncomment the following lines before running the sample.
   */
  // const bucketName = 'Name of a bucket, e.g. my-bucket';
  // const filename = 'File to access, e.g. file.txt';

  // These options will allow temporary read access to the file
  const options = {
    action: 'read',
    expires: Date.now() + 1000 * 60 * 60 * 24 * 30 // 1month
  }

  // Get a signed URL for the file
  const [url] = await storage
    .bucket(bucketName)
    .file(filename)
    .getSignedUrl(options)

  console.log(`The signed url for ${filename} is ${url}.`)
  // [END storage_generate_signed_url]
  return url
}

const url = 'https://qiita.com/'
const site_name = 'Qiita'
const title = 'Qiita'
const meta_description = 'プログラミング情報共有サイトです。'
const meta_keywords = ['プログラミング']
const og_description = 'プログラミング情報共有サイトです。'
const og_image_width = 1200
const og_image_height = 630
const fb_appid = ''
const tw_description = 'プログラミング情報共有サイトです。'
const tw_site = ''
const tw_creator = ''

const genHtml = (url) => `
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>${title}</title>
    <meta name="description" content=${meta_description}>
    <meta name="keywords" content=${meta_keywords.join(',')}>
    <meta property="og:locale" content="ja_JP">
    <meta property="og:type" content="website">
    <meta property="og:url" content=${url}>
    <meta property="og:title" content=${title}>
    <meta property="og:site_name" content=${site_name}>
    <meta property="og:description" content=${og_description}>
    <meta property="og:image" content=${url}>
    <meta property="og:image:width" content=${og_image_width}>
    <meta property="og:image:height" content=${og_image_height}>
    <meta property="fb:app_id" content=${fb_appid}>
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:title" content=${title}>
    <meta name="twitter:description" content=${tw_description}>
    <meta name="twitter:image" content=${url}>
    <meta name="twitter:site" content=${tw_site}>
    <meta name="twitter:creator" content=${tw_creator}>
  </head>
  <body>
    <script>
      // クローラーにはメタタグを解釈させて、人間は任意のページに飛ばす
      location.href = '/share';
    </script>
  </body>
</html>
`

app.get('/:id', async (req, res) => {
  const doc = await db.collection('cards').doc(req.params.id).get()
  if (!doc.exists) {
    console.log(`${req.params.id} not exist`)
    res.status(404).send('404 Not Exist')
  } else {
    const url = await generateSignedUrl(bucketName, `${req.params.id}.png`)
    const html = genHtml(url)
    res.set('cache-control', 'public, max-age=3600');
    res.send(html)
  }
})
exports.s = functions.https.onRequest(app)
```

あと<domain>/s/:idでCloudFunctionにアクセスできるように設定を書きます。

```js
// firebase.json
{
  "hosting": {
    "public": "dist",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        // 大事
        "source": "/s/**", "function": "s"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}
```

## デプロイ
```bash
firebase deploy
```

簡単ですね！VueもFirebaseも素晴らしいです。



### クレジット
- フロントエンド、バックエンド[@_serinuntius](https://twitter.com/_serinuntius)
- デザイン[@skinnybrian_tw](https://twitter.com/skinnybrian_tw)
- 企画, 立案[@okamu_ro](https://twitter.com/okamu_ro)

## まとめ
Vue.jsとFirebaseを使えば２日あれば、OGP画像生成系サービスを作れるようになります。

１度作ってしまうとだいたいコピペで量産できるので、コードが資産になります。

皆さんもぜひこの組み合わせでサービスを作ってみてください！