• #Cloudflare Workers
  • #OGP
  • #implementation
  • #workers-og
開発完了

動的OGP画像生成 Worker 実装

概要

日本語作文クイズの結果シェア機能で、動的にOGP画像を生成するための Cloudflare Worker を実装した。

背景: Nuxt ビルドに workers-og を組み込むと WASM 関連で OOM が発生するため、Worker を分離して独立デプロイする方針に変更。

デプロイ情報

項目
Worker URLhttps://og-image-worker.number55number55.workers.dev
画像エンドポイント/og/japanese-quiz
シェアエンドポイント/share/japanese-quiz
パッケージ[email protected]

テストURL:

# 画像生成
https://og-image-worker.number55number55.workers.dev/og/japanese-quiz?category=punctuation&correct=8&total=10

# シェアページ(OGタグ付きHTMLを返し、実際のページにリダイレクト)
https://og-image-worker.number55number55.workers.dev/share/japanese-quiz?category=punctuation&correct=8&total=10

シェアページの仕組み

URLのクエリパラメータに応じてog:imageを動的に変えることが静的サイト(Nuxt generate)ではできない。そのためWorkerに「シェア用の中継ページ」を実装した。

シェアボタン押下
    ↓
Worker の /share/japanese-quiz?... がシェアされる
    ↓
SNSクローラーがアクセス → OGメタタグ付きHTMLを取得 → og:image から画像取得
    ↓
ユーザーがリンクをクリック → meta refresh で実際のNuxtページにリダイレクト

ResultSummary.vue のシェアURL:

const shareUrl = computed(() => {
  return `https://og-image-worker.number55number55.workers.dev/share/japanese-quiz?category=${props.category}&correct=${props.result.correctAnswers}&total=${props.result.totalQuestions}`
})

アーキテクチャ

┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│   og-image-worker.*.workers.dev ──► Cloudflare Worker (OGP生成) │
│                                     └─ workers-og で画像生成    │
│                                                                 │
│   log.eurekapu.com ─────────────► Cloudflare Pages (Nuxt静的)   │
│     └─ og:image で Worker URL を参照                            │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

ファイル構成

apps/workers/og/
├── src/
│   └── index.ts          # メインエントリ
├── wrangler.toml         # Cloudflare設定
├── package.json
└── tsconfig.json

実装コード

package.json

{
  "name": "og-image-worker",
  "private": true,
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy"
  },
  "dependencies": {
    "workers-og": "^0.0.27"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20241230.0",
    "wrangler": "^4.0.0",
    "typescript": "^5.7.0"
  }
}

wrangler.toml

name = "og-image-worker"
main = "src/index.ts"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]

src/index.ts

import { ImageResponse, loadGoogleFont } from 'workers-og'

interface Env {}

const CATEGORY_NAMES: Record<string, string> = {
  'modification-order': '修飾語の順序',
  'punctuation': '句読点の打ち方',
  'particles': '助詞の使い方',
  'all': '全問チャレンジ',
}

// フォントをキャッシュ(サブセット化して読み込む)
let fontDataPromise: Promise<ArrayBuffer> | null = null
async function getFontData(): Promise<ArrayBuffer> {
  if (!fontDataPromise) {
    const text =
      '0123456789/ -abcdefghijklmnopqrstuvwxyz.-クイズ修飾語の順序句読点の打ち方助詞の使い方全問チャレンジ素晴らしいよくできましたもう少し復習しましょう日本語作文問正解!'
    fontDataPromise = loadGoogleFont({ family: 'Noto Sans JP', weight: 700, text })
  }
  return fontDataPromise
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url)

    if (url.pathname === '/og/japanese-quiz') {
      return handleJapaneseQuiz(url)
    }

    return new Response('Not Found', { status: 404 })
  },
}

async function handleJapaneseQuiz(url: URL): Promise<Response> {
  const category = url.searchParams.get('category') || 'all'
  const correct = parseInt(url.searchParams.get('correct') || '0')
  const total = parseInt(url.searchParams.get('total') || '10')

  const categoryName = CATEGORY_NAMES[category] || 'クイズ'
  const percentage = Math.round((correct / total) * 100)

  let gradeText = ''
  if (percentage >= 90) {
    gradeText = '素晴らしい!'
  } else if (percentage >= 70) {
    gradeText = 'よくできました'
  } else if (percentage >= 50) {
    gradeText = 'もう少し'
  } else {
    gradeText = '復習しましょう'
  }

  // Satori用HTML(全てのdivにdisplay: flexが必要)
  const html = `
<div style="display: flex; width: 1200px; height: 630px; align-items: center; justify-content: center; background: linear-gradient(135deg, #f7efe8 0%, #f6d7cf 45%, #f2b8b5 100%); font-family: Noto Sans JP, sans-serif; color: #3a2b2b;">
  <div style="display: flex; flex-direction: column; width: 900px; padding: 48px; background: rgba(255, 255, 255, 0.75); border-radius: 32px;">
    <div style="display: flex; font-size: 26px; opacity: 0.7;">日本語作文</div>
    <div style="display: flex; font-size: 52px; font-weight: 700; margin-top: 8px;">${categoryName}</div>
    <div style="display: flex; width: 160px; height: 3px; background: rgba(58, 43, 43, 0.25); margin-top: 16px;"></div>
    <div style="display: flex; font-size: 80px; font-weight: 700; margin-top: 24px;">${correct} / ${total} 問正解</div>
    <div style="display: flex; font-size: 28px; margin-top: 8px; opacity: 0.75;">${gradeText}</div>
    <div style="display: flex; font-size: 20px; opacity: 0.5; margin-top: 24px; justify-content: flex-end;">log.eurekapu.com</div>
  </div>
</div>
  `

  const fontData = await getFontData()

  return new ImageResponse(html, {
    width: 1200,
    height: 630,
    format: 'svg',
    fonts: [
      {
        name: 'Noto Sans JP',
        data: fontData,
        weight: 700,
        style: 'normal',
      },
    ],
  })
}

Nuxt 側の設定

各クイズページで og:image を Worker URL に設定:

// apps/web/app/pages/japanese-writing-quiz/punctuation.vue
useSeoMeta({
  ogTitle: () => hasResult.value
    ? `句読点の打ち方クイズ - ${correct.value}/${total.value}問正解!`
    : '句読点の打ち方 - 日本語作文クイズ',
  ogDescription: '日本語作文クイズに挑戦しよう',
  ogImage: () => hasResult.value
    ? `https://og-image-worker.number55number55.workers.dev/og/japanese-quiz?category=punctuation&correct=${correct.value}&total=${total.value}`
    : 'https://log.eurekapu.com/og-image.png',
  twitterCard: 'summary_large_image',
})

対応ページ:

  • punctuation.vue - 句読点の打ち方
  • particles.vue - 助詞の使い方
  • modification-order.vue - 修飾語の順序
  • all.vue - 全問チャレンジ

CI/CD

GitHub Actions

# .github/workflows/deploy-og-worker.yml
name: Deploy OG Worker

on:
  push:
    branches: [master]
    paths:
      - 'apps/workers/og/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 10
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'
      - run: pnpm install
      - run: pnpm --filter og-image-worker run deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

GitHub Actions 設定TODO

現在、ローカルで wrangler deploy を実行してデプロイしている。GitHub Actions で自動デプロイするには以下の設定が必要:

  1. Cloudflare API Token の作成
    • Cloudflare Dashboard → My Profile → API Tokens
    • 「Create Token」→「Edit Cloudflare Workers」テンプレートを使用
    • または Custom Token で以下の権限を付与:
      • Account: Workers Scripts: Edit
      • Zone: Workers Routes: Edit(カスタムドメイン使用時)
  2. GitHub Secrets に追加
    • リポジトリ → Settings → Secrets and variables → Actions
    • 「New repository secret」
    • Name: CLOUDFLARE_API_TOKEN
    • Value: 作成したAPIトークン

開発コマンド

# ローカル開発
pnpm --filter og-image-worker dev

# デプロイ
pnpm --filter og-image-worker deploy
# または
cd apps/workers/og && wrangler deploy

技術的なポイント

Satori の制限

  • 全ての <div>display: flex が必要
  • flex-direction: column で縦並びを明示
  • position: absolute は親要素に position: relative が必要

フォント最適化

loadGoogleFont で必要な文字だけをサブセット化:

const text = '0123456789/ -クイズ修飾語の順序句読点の打ち方助詞の使い方...'
fontDataPromise = loadGoogleFont({ family: 'Noto Sans JP', weight: 700, text })

これにより、フルフォント(約5MB)ではなく、必要な文字のみ(数KB)をダウンロード。

nodejs_compat フラグ

wrangler.tomlnodejs_compat フラグを有効化:

compatibility_flags = ["nodejs_compat"]

workers-og が Node.js の一部 API を使用するため必要。

カスタムドメインについて

現状

og-image-worker.number55number55.workers.devでデプロイされているWorkerは、これで問題なく動作する。

カスタムドメイン(og.eurekapu.com)を設定するメリット

メリット説明
URL の永続性Worker 名を変更しても URL が変わらない
ブランディングeurekapu.com ドメイン配下で統一感がある

設定しなくても良い理由

理由説明
ユーザーには見えないOGP画像のURLはSNSがクロールするだけで、ユーザーが直接見ることはない
現状で動作しているworkers.dev ドメインでも機能的には問題ない
追加設定が必要DNS設定、Cloudflare Workers Route の設定が必要

結論

カスタムドメインは必須ではない。将来的に Worker 名を変更する可能性がある場合や、ブランディングを重視する場合にのみ検討すればよい。

設定する場合の手順(参考)

  1. Cloudflare DNS で og.eurekapu.com を追加
  2. wrangler.toml に routes を設定:
    routes = [
      { pattern = "og.eurekapu.com/*", zone_name = "eurekapu.com" }
    ]
    
  3. Worker を再デプロイ
  4. Nuxt 側の ogImage URL を https://og.eurekapu.com/og/japanese-quiz?... に変更

完了チェックリスト

  • Worker プロジェクト作成 (apps/workers/og/)
  • pnpm-workspace.yaml に追加
  • src/index.ts 実装(画像生成 + シェアページ)
  • フォントサブセット化
  • Cloudflare へデプロイ
  • Nuxt 側の og:image URL 更新
  • ResultSummary.vue のシェアURL を Worker に変更
  • GitHub Actions ワークフロー作成
  • GitHub Secrets に CLOUDFLARE_API_TOKEN を設定(上記「GitHub Actions 設定TODO」参照)
  • カスタムドメイン設定(オプション・必須ではない)

関連ドキュメント