🐼

サイトのOGP画像を自動生成する

2022/02/03に公開

最近、個人の技術ブログをリニューアルしました(パンダのプログラミングブログ)。 リニューアルの際に、念願だった OG 画像の自動生成を実装してみたところ、意外と簡単にできたのでその方法を紹介します。

OGP画像とは何か

OGP 画像の OGPとは Open Graph Protocol の略語で、ある URL のページをリッチなコンテンツで紹介できるというものです。 サイトのタイトルやページのコンテンツをイメージさせる画像などを meta タグを使って HTML に埋め込みページの内容を表現できます。

og image の説明

Twitter や Slack、Line で URL を貼り付けると URL とは別に表示される物がこれにあたります。サイトのページが増えるとページ一つ一つに OGP 画像を用意することは手間がかかります。そこでこの記事では、OGP 画像を自動生成する方法を紹介します。

OGP 画像の作成方法は2パターンある

OGP 画像の作成方法は大きく分けて Puppeteer などヘッドレスブラウザを使う方法と、 canvas を使う2つの方法があります。 この2つの方法の共通点は画像を作成すること、相違点は画像の作成方法です。具体的に見ていきます。

Puppeteer を使ってOGP画像を作成する方法

1つ目は、Puppeteer などのヘッドレスブラウザを使う方法です。 これは、Puppeteer を立ち上げてあらかじめ用意した HTML + CSS を読み込み、そのスクリーンショットを撮影して画像を生成するという手法です。

具体的な方法は、 WEB+DB Press 2021年2月号 の「VercelによるOGP画像の動的生成」や、GitHub のエンジニアリングブログ(「A framework for building Open Graph images」)に記載があります。

canvas を使ってOGP画像を作成する方法

もう1つは、canvas を使って画像を生成する方法です。この方法では元になる画像を用意して canvas に読み込んで文字を書き込んでいきます。 自分のブログではテンプレートの画像を使いまわしているため、本記事ではこちらの方法を紹介します。

node-canvas を使って OG 画像を作成する

OGP の元になる画像を作成

まず最初にテンプレートになる画像を作成します。Figma や Canva を使って PNG として出力すると良いでしょう。ページのタイトルの文字を記入するため、真ん中あたり開けておきましょう。以下は、自分のサイトで使っているテンプレートの実例です。

ogp 画像のテンプレート

次にこの画像を Node.js から読み込むので、プロジェクト配下に置いておきましょう。

画像を読み込んでタイトルを記入する

今回は OGP 画像作成に node-canvas を使います。 generateOgImage という関数を作成し、画像に記入したいタイトルを引数に受け取るようにしましょう。canvas に画像をセットするまでのコードは以下の通りです。

import fs from 'fs'
import path from 'path'
import { createCanvas, registerFont, loadImage } from 'canvas'

const size = { width: 600, height: 315 }
const current = process.cwd()

export const generateOgImage = async (title: string): Promise<Buffer> => {
  // font を登録
  const font = path.resolve(current, 'src/lib/canvas/assets/NotoSansJP-Bold.otf')
  registerFont(font, { family: 'NotoSansJP' })

  // canvas を作成
  const { width, height } = size
  const canvas = createCanvas(width, height)
  const ctx = canvas.getContext('2d')

  // 元になる画像を読み込む
  const src = path.resolve(current, 'src/lib/canvas/assets/og-image.png')
  const image = await loadImage(fs.readFileSync(src))

  // 元の画像を canvas にセットする
  ctx.drawImage(image, 0, 0, width, height)

  // ...
}

上記のコードではフォントの登録、canvas の作成、画像のセットを行っています。 今回はフォントに Noto Sans Japanese を使っています。フォントファイルは Google Fonts からダウンロードできます。

また、画像のサイズは自分の好きなように設定してください。ここでは横600 × 縦315 としていますが、Google 検索をしてみると一般的には横1200 × 縦630 とするのが良いとのことです。

画像に文字を記入する

次に、画像に文字を記入する処理を記述します。コードは以下の通りです。

export const generateOgImage = async (title: string): Promise<Buffer> => {
  // 前のコード
  
  // タイトルを元の画像にセットする
  const lines = title.replace('\\n', '\n').split('\n')
  const maxWidth = 400
  const w = width / 2
  const sum = lines.length
  const write = (text: string, h: number) => {
    ctx.fillText(text, w, h, maxWidth)
  }

  if (sum === 0 || sum > 3) {
    throw new Error(`Invalid lines: ${sum}`)
  }

  for (const [i, line] of Object.entries(lines)) {
    const currentLineNumber = Number(i) + 1
    const h = getH(sum, currentLineNumber)
    write(line, h)
  }

  return canvas.toBuffer('image/png')
}

テンプレートとなる画像があるため、文字を記入する位置を細かく指定する必要があります。 1行の幅が max-width が 400 だったり、幅が width(= 600) / 2 だったりしますが、この辺りは事情に応じて変更していきましょう。

処理を簡略化すると、 ctx.fillText を使って記入する文字とその位置、最大幅を指定しているだけです。1行目、2行目、3行目で位置を変えて文字が被らないようにし、4行目以上になる場合はエラーにします。

この処理の一番最初で lines と配列にしていますが、改行の仕方は個々それぞれです。ただし、canvas を使って文字を記入すると自動で改行ができないようです。ここが Puppeteer を使うやり方と異なるところです。

ただし、OGP画像のテンプレートを HTML + CSS ではなく画像を元にするならこの方法しかないのかなと考えています(もし他に方法があるならコメント欄で教えていただけると嬉しいです)。

上記で使用したgetHは以下のように別ファイルで定義しています。行間や文字の高さを調節するのであれば、以下の数字を変更すると調整ができます。

export const size = { width: 600, height: 315 }

const getBase = (sum: number) => {
  switch (sum) {
    case 1:
      return { rate: 2.6, additionalHeight: 38 }
    case 2:
      return { rate: 2.4, additionalHeight: 36 }
    case 3:
      return { rate: 2.0, additionalHeight: 34 }
    default:
      return { rate: 2.4, additionalHeight: 36 }
  }
}

export const getH = (sum: number, current: number) => {
  const { rate, additionalHeight } = getBase(sum)
  const base = (size.height * rate) / 7

  return base + additionalHeight * current
}

これでテンプレートを元にした OGP 画像の作成ができました(コード全文は最後に掲載します)。

デプロイ先の選定

ここまででOGP 画像を作成する処理が完成しました。しかし、まだやるべきことが残っています。それは関数のデプロイです。

OGP 画像の URL は HTML で以下のように記述するのですが、今回はリクエストに応じて画像を動的に生成することが目的であるため、URL は画像ファイルではありません。そこで、content 属性の URL をエンドポイント(画像を返す API)にする必要があります。

<meta property="og:image" content="https://og-image-generator.panda-program.com/posts/from-gatsby-to-nextjs/image">

現代では関数を動かすためにクラウドを活用できるため、あえてサーバーを用意する必要はありません。

このため、デプロイ先として Cloud Functions や AWS Lambda、Vercel などの FaaS を活用できます。正直なところ関数を動かすだけなので Cloud Functions や AWS Lambda でもいいのですが、 自分はブログを Vercel にデプロイしているためこの記事でも Vercel を活用します。

Vercel に OGP 画像を作成する処理をデプロイする

Next.js には API Route という機能があり、これを使うとAPI を簡単に作成できます。ただ、API を作るためだけに Next.js を導入する必要はないと思ったため、 今回はレスポンスの速度が速いと評判の Fastify を使ってみました。

機能としては、/posts/:slug/image というURL にリクエストがきた時、slug を抜き出して API 経由でブログ本体にその slug の記事のタイトルを問い合わせ、返ってきた文字を画像に記入するというものです。

// src/blog.ts
import { RequestGenericInterface, FastifyInstance, FastifyServerOptions, FastifyRequest, FastifyReply } from 'fastify'
import { generateOgImage } from "./lib/canvas";
import fs from 'fs'
import path from 'path'
import fetch from 'isomorphic-unfetch'

const blogDomain = process.env.BLOG_DOMAIN || 'http://localhost:3000'
const cwd = process.cwd()

interface RequestGeneric extends RequestGenericInterface {
  Params: {
    slug: string
  }
}

export default async function (instance: FastifyInstance, opts: FastifyServerOptions, done: any) {
  instance.get('/posts/:slug/image', async (req: FastifyRequest<RequestGeneric>, reply: FastifyReply) => {
    const slug = req.params.slug

    reply.header('Content-Type', 'image/png')

    try {
      // ブログサイトの API に記事のタイトルを問い合わせる
      const res = await fetch(`${blogDomain}/api/posts/${slug}?filter=og-image-title`)
      const json = await res.json() as { ogImageTitle: string }
      const img = await generateOgImage(json.ogImageTitle)

      reply.send(img)
    } catch (e: any) {
      console.error(e.message);
      // サイトの各ページで共通の画像
      const file = path.resolve(cwd, 'src/assets/blog/site-image.png')
      const siteImg = fs.readFileSync(file)

      reply.send(siteImg)
    }
  })

  done()
}

なお、ブログからのレスポンスの ogImageTitle は以下のようなものです。ogImageTitle は、markdown の frontmatter で改行位置を指定したOGP 画像に記入するタイトルです。比較のために併せて title も記載しています。

---
title: "Next.js + Tailwind UI を使うとたった6時間で技術ブログのプロトタイプを作れる"
ogImageTitle: "Next.js + Tailwind UI を使うと\nたった6時間で技術ブログの\nプロトタイプを作れる"
---

タイトルを取得するやり方自体は他にも様々な方法があると思いますが、今回はブログ本体に問い合わせる方法を採用しました。

次に上記のハンドラを fastify に登録します。これで URL アクセスした時、 OGP 画像を返すことができます。

// src/serverless.ts
import * as dotenv from "dotenv";
import fastify from 'fastify'

dotenv.config();

const app = fastify()

app.register(import('./blog'), {
  prefix: '/'
})

// document
// https://www.fastify.io/docs/latest/Guides/Serverless/#vercel
export default async (req: any, res: any) => {
  await app.ready();
  app.server.emit('request', req, res);
}

// ローカルで開発するときはこれを使う
const isDev = process.env.NODE_ENV === 'development'
if (isDev) {
  app.listen(8080, (err, address) => {
    if (err) {
      console.error(err)
      process.exit(1)
    }
    console.log(`Server listening at ${address}`)
  })
}

なお、tsc でファイルを出力先を指定する tsconfig.json の outDir には dist ではなく api を設定しています。それは vercel.json で以下のような記述をしているからです。

// vercel.json
{
  "rewrites": [
    {
      "source": "/(.*)",
      "destination": "/api/serverless.js"
    }
  ]
}

実際、 https://og-image-generator.panda-program.com/posts/from-gatsby-to-nextjs/imagehttps://og-image-generator.panda-program.com/posts/development-summary-2021/image にアクセスすると自動で OGP 画像が生成できていることがわかります。

ローカルでの開発に関して、package.json の scripts には "dev": "yarn build && ENV=development node api/serverless.js" を記述します。これでローカルでも yarn dev で画像の確認ができるようになりました。

また、ローカルで動作確認するもう一つの方法として、$ vercel devを実行するというものがあります。

なお、node-canvas を Vercel で動かすためには「Vercel Now(旧ZEIT Now)上でnode-canvasを動かす」 で紹介されている設定が必要です。また、Vercel にデプロイするためには node-canvas のバージョンは "canvas": "2.6.1" である必要があります。

終わりに

OGP画像の生成を自動化する方法は一つではない上にデプロイ先も複数あります。 ちょうどこの記事を書いている間に OGP 画像作成にFirebaseをフル活用する 記事が公開されました。インフラは本記事の構成と異なるのでとても参考になると思います。

そしてこれは裏話なのですが、OGP画像の作成程度であればブログ本体でできると思い、最初はブログで使っている Next.js の API Route で機能を実装していました。この場合、わざわざ API 経由でブログのタイトルを取得する必要もありません。

しかし、ブログ本体にインストールした Vitest が node-canvas をうまく読み込めず、ローカルや CI のテストが落ちてしまいました。原因を調べてみるとnode-canvas は実行環境に応じてライブラリを導入する必要があるなど、なかなか一筋縄ではいかないようです。結局対処法はわからずじまいです。

自分のブログで「記事ごとの OG 画像のデザインと自動生成プログラムの作成に1人日かかっている」 と書木、OGP画像の生成処理作成に時間がかかったと表現したのはまさにこれが理由でした。このため、Vercel に専用の関数をデプロイすれば問題は一応対処できると考えた次第です。

作成に時間はかかったものの、OGP 画像の自動作成はとても楽です。 ブログのリニューアル前はスクリプトを書いてテンプレートを元に OGP 画像を生成していたのですが、毎回記事執筆後に画像を作成するのは面倒でした。

これが frontmatter にタイトルを1行追加するだけで画像作成ができるようになったのはとても楽で嬉しいことです。 具体的には、記事のタイトルを変更した時に markdown を書き換えるだけで済むということがとてもシンプルに感じています。

正直なところこの記事で紹介したコードをそのまま使うことはできないかもしれないですが、記事を読んでくださった方が何かしら参考にできるところがあれば幸いです。

Happy Hacking 🎉

コードの全文

以下でコードの全部を紹介します。

// package.json
{
  "name": "og-image-generator",
  "version": "1.0.0",
  "scripts": {
    "dev": "yarn build && ENV=development node api/serverless.js",
    "build": "tsc -p tsconfig.json",
    "start": "node api/serverless.js",
    "vercel-build": "yum install libuuid-devel libmount-devel && cp /lib64/{libuuid,libmount,libblkid}.so.1 node_modules/canvas/build/Release/ && yarn build"
  },
  "dependencies": {
    "canvas": "2.6.1",
    "fastify": "^3.27.0",
    "isomorphic-unfetch": "^3.1.0"
  },
  "devDependencies": {
    "@types/node": "^17.0.13",
    "dotenv": "^14.3.2",
    "typescript": "^4.5.5"
  }
}
// src/lib/generateOgImage.ts
import fs from 'fs'
import path from 'path'
import { createCanvas, registerFont, loadImage } from 'canvas'
import { size, getH } from './fn'

const current = process.cwd()

export const generateOgImage = async (title: string): Promise<Buffer> => {
  // font を登録
  const font = path.resolve(current, 'src/lib/canvas/assets/NotoSansJP-Bold.otf')
  registerFont(font, { family: 'NotoSansJP' })

  // canvas を作成
  const { width, height } = size
  const canvas = createCanvas(width, height)
  const ctx = canvas.getContext('2d')

  // 元になる画像を読み込む
  const src = path.resolve(current, 'src/lib/canvas/assets/og-image.png')
  const image = await loadImage(fs.readFileSync(src))

  // 元の画像を canvas にセットする
  ctx.drawImage(image, 0, 0, width, height)

  // タイトルの style
  ctx.font = '28px "NotoSansJP"'
  ctx.textAlign = 'center'

  // タイトルを元の画像にセットする
  const lines = title.replace('\\n', '\n').split('\n')
  const maxWidth = 400
  const w = width / 2
  const sum = lines.length
  const write = (text: string, h: number) => {
    ctx.fillText(text, w, h, maxWidth)
  }

  if (sum === 0 || sum > 3) {
    throw new Error(`Invalid lines: ${sum}`)
  }

  for (const [i, line] of Object.entries(lines)) {
    const currentLineNumber = Number(i) + 1
    const h = getH(sum, currentLineNumber)
    write(line, h)
  }

  return canvas.toBuffer('image/png')
}
// src/lib/fn.ts
export const size = { width: 600, height: 315 }

const getBase = (sum: number) => {
  switch (sum) {
    case 1:
      return { rate: 2.6, additionalHeight: 38 }
    case 2:
      return { rate: 2.4, additionalHeight: 36 }
    case 3:
      return { rate: 2.0, additionalHeight: 34 }
    default:
      return { rate: 2.4, additionalHeight: 36 }
  }
}

export const getH = (sum: number, current: number) => {
  const { rate, additionalHeight } = getBase(sum)
  const base = (size.height * rate) / 7

  return base + additionalHeight * current
}
// src/blog.ts

import {
  RequestGenericInterface,
  FastifyInstance,
  FastifyServerOptions,
  FastifyRequest,
  FastifyReply
} from 'fastify'
import {generateOgImage} from "./lib/canvas";
import fs from 'fs'
import path from 'path'
import fetch from 'isomorphic-unfetch'

const blogDomain = process.env.BLOG_DOMAIN || 'http://localhost:3000'
const cwd = process.cwd()

interface RequestGeneric extends RequestGenericInterface {
  Params: {
    slug: string
  }
}

export default async function (instance: FastifyInstance, opts: FastifyServerOptions, done: any) {
  instance.get('/', async (req: FastifyRequest, reply: FastifyReply) => {
    reply.status(200).send('ok')
  })

  instance.get('/check', async (req: FastifyRequest<RequestGeneric>, reply: FastifyReply) => {
    try {
      // first-post で疎通をチェックする
      const slug = 'first-post'
      const res = await fetch(`${blogDomain}/api/posts/${slug}?filter=og-image-title`)
      const json = await res.json() as { ogImageTitle: string }

      reply.send({ title: json.ogImageTitle })
    } catch (e: any) {
      console.error(e.message);

      reply.send({ error: e.message })
    }
  })

  instance.get('/posts/:slug/image', async (req: FastifyRequest<RequestGeneric>, reply: FastifyReply) => {
    const slug = req.params.slug

    reply.header('Content-Type', 'image/png')

    try {
      const res = await fetch(`${blogDomain}/api/posts/${slug}?filter=og-image-title`)
      const json = await res.json() as { ogImageTitle: string }
      const img = await generateOgImage(json.ogImageTitle)

      reply.send(img)
    } catch (e: any) {
      console.error(e.message);
      const file = path.resolve(cwd, 'src/assets/blog/site-image.png')
      const siteImg = fs.readFileSync(file)

      reply.send(siteImg)
    }
  })

  done()
}
// src/serverless.ts

import * as dotenv from "dotenv";
import fastify from 'fastify'

dotenv.config();

const app = fastify()

app.register(import('./blog'), {
  prefix: '/'
})

// document
// https://www.fastify.io/docs/latest/Guides/Serverless/#vercel
export default async (req: any, res: any) => {
  await app.ready();
  app.server.emit('request', req, res);
}

// ローカルで開発するときはこれを使う
const isDev = process.env.ENV === 'development'
if (isDev) {
  app.listen(8080, (err, address) => {
    if (err) {
      console.error(err)
      process.exit(1)
    }
    console.log(`Server listening at ${address}`)
  })
}

参考

https://github.com/juddbaguio/vercel-fastify-serverless
https://www.fastify.io/docs/latest/Guides/Serverless/#vercel

Discussion