みんなの「作ってみた」

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

2019/01/31

serinuntius
serinuntius
#ビットコインしろ で知ったハッカソンに興味本位で参加して優勝。その後ブロックチェーンにどっぷりハマり、国内外問わず5個のハッカソンで受賞 / 海外優勝経験あり/ブロックチェーンのインフラよりアプリケーションレイヤが好き/ex-kayac

はじめに

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

宣伝

この技術を使ったサービスを実稼働2日ぐらいで作りました!

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

#嵐ありがとう

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

「ツイッターでつぶやけるボタン」を簡単に作成できるサービスをリリースしました【個人開発】

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

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

どうすれば簡単に作れるか?

こういう画像って作るの面倒そうじゃないですか。

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

そうだSVGを使おう!

構成図

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

メリット

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

デメリット

  • 特に思いつかないです

準備

 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の変数に置換します。

<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(
        }).catch(err => {
          console.error(err)
        })
      })
    }
  }
}
</script>

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

OGP表示側(CloudFunction)コード

https:///s/:id

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

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)

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

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

デプロイ

firebase deploy

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

クレジット

まとめ

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

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

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