• #OGP
  • #Cloudflare Workers
  • #R2
  • #設計
  • #完了

OGP画像 Worker + R2キャッシュ 実装完了

仕組みの概要

OGP画像はビルド時に生成しない。アクセス時にオンデマンド生成し、R2にキャッシュする。

1. ユーザーがTwitterでブログURLをシェア
2. Twitterクローラーが og:image URL(Worker経由)にアクセス
3. Worker が画像を生成 → R2に保存 → 返却
4. 次回以降はR2から即座に返却(キャッシュHIT)

これにより、ビルド時の画像生成が不要になり、ビルド時間が大幅に短縮される。

背景・課題

現状の問題

  • ビルド時間の増加: 記事数に比例してビルド時間が増加する
    • 471記事 → 総ビルド時間7分(うちOGP生成に約3分)
    • 1000記事 → 推定14分
  • 無駄な再生成: 変更のない記事のOGP画像も毎回再生成される

目標

  • ビルド時間を記事数に関係なく一定にする(4分以下)
  • 変更があった記事のOGPのみ再生成
  • 新記事は再ビルド不要で即OGP利用可能

アーキテクチャ

Before(現状)

Nuxt (静的ビルド)
  └─ nuxt-og-image
     └─ ビルド時に全記事のOGP画像をプリレンダリング(471枚 → 約3分)

After(改善後)

Nuxt (静的ビルド)                    OG Worker
  │                                    │
  │ og:image URL を Worker に向ける     │
  └────────────────────────────────────►│
                                        │
                                        ▼
                              ┌─────────────────┐
                              │ R2にキャッシュ? │
                              └────────┬────────┘
                                       │
                        ┌──────────────┴──────────────┐
                        │                             │
                        ▼                             ▼
                   [HIT] R2から返す           [MISS] 生成→R2保存→返す

実装コード

Worker (src/index.ts)

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

interface Env {
  OG_IMAGES: R2Bucket
  OG_SECRET: string
}

// 正規化(Nuxt側と同じロジック)
function normalizeText(text: string): string {
  return text.trim().replace(/\s+/g, ' ')
}

function normalizePath(path: string): string {
  return path.replace(/^\/+/, '')
}

// 署名検証
async function verifySignature(
  params: { path: string; version: string; title: string; author: string },
  signature: string,
  secret: string
): Promise<boolean> {
  const encoder = new TextEncoder()
  const payload = [
    normalizePath(params.path),
    params.version,
    normalizeText(params.title),
    normalizeText(params.author)
  ].join('|')

  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  )
  const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(payload))
  const expected = Array.from(new Uint8Array(sig))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')
    .slice(0, 16)

  return expected === signature
}

// サニタイズ
function sanitize(text: string, maxLen: number): string {
  const escaped = text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
  return escaped.length > maxLen ? escaped.slice(0, maxLen - 3) + '...' : escaped
}

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

    // 既存: 日本語クイズOGP
    if (url.pathname === '/og/japanese-quiz') {
      return handleJapaneseQuizImage(url)
    }
    if (url.pathname === '/share/japanese-quiz') {
      return handleSharePage(url)
    }

    // 新規: ブログ記事OGP
    if (url.pathname.startsWith('/og/blog/')) {
      return handleBlogOgImage(url, env)
    }

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

async function handleBlogOgImage(url: URL, env: Env): Promise<Response> {
  const match = url.pathname.match(/^\/og\/blog\/(.+)\.png$/)
  if (!match) {
    return new Response('Invalid path', { status: 400 })
  }

  const articlePath = match[1]
  const signature = url.searchParams.get('sig')
  const version = url.searchParams.get('v') || 'v1'
  const title = url.searchParams.get('title') || articlePath
  const author = url.searchParams.get('author') || 'Kei Komatsu'

  // 署名検証
  if (!signature || !(await verifySignature({ path: articlePath, version, title, author }, signature, env.OG_SECRET))) {
    return new Response('Forbidden', { status: 403 })
  }

  // R2キャッシュ確認
  const cacheKey = `blog/${articlePath}/${version}.png`
  const cached = await env.OG_IMAGES.get(cacheKey)
  if (cached) {
    return new Response(cached.body, {
      headers: {
        'Content-Type': 'image/png',
        'Cache-Control': 'public, max-age=31536000, immutable',
        'X-OG-Cache': 'HIT'
      }
    })
  }

  // 生成
  const safeTitle = sanitize(title, 80)
  const safeAuthor = sanitize(author, 30)
  const imageResponse = await generateBlogOgImage(safeTitle, safeAuthor)
  const imageBuffer = await imageResponse.arrayBuffer()

  // R2保存
  await env.OG_IMAGES.put(cacheKey, imageBuffer, {
    httpMetadata: { contentType: 'image/png' }
  })

  return new Response(imageBuffer, {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=31536000, immutable',
      'X-OG-Cache': 'MISS'
    }
  })
}

async function generateBlogOgImage(title: string, authorName: string): Promise<ImageResponse> {
  const fontData = await loadGoogleFont({
    family: 'Noto Sans JP',
    weight: 700,
    text: title + authorName + 'Eurekapu.com'
  })

  const html = `
    <div style="display: flex; width: 1200px; height: 630px; justify-content: center; align-items: center; padding: 40px; background: linear-gradient(135deg, #ff0080 0%, #0066ff 100%); font-family: Noto Sans JP, sans-serif;">
      <div style="display: flex; flex-direction: column; justify-content: space-between; width: 1100px; height: 530px; background: white; border-radius: 24px; padding: 50px 60px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);">
        <div style="display: flex; flex: 1; align-items: center; justify-content: center; font-size: 52px; font-weight: 700; color: #1a1a1a; line-height: 1.4; text-align: center;">
          ${title}
        </div>
        <div style="display: flex; justify-content: space-between; align-items: center;">
          <div style="display: flex; font-size: 24px; color: #444; font-weight: 500;">
            ${authorName}
          </div>
          <div style="display: flex; font-size: 24px; color: #888; font-weight: 500;">
            Eurekapu.com
          </div>
        </div>
      </div>
    </div>
  `

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

wrangler.toml

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

routes = [
  { pattern = "log.eurekapu.com/share/*", zone_name = "eurekapu.com" },
  { pattern = "log.eurekapu.com/og/blog/*", zone_name = "eurekapu.com" }
]

[[r2_buckets]]
binding = "OG_IMAGES"
bucket_name = "og-images"

# OG_SECRET は wrangler secret put で設定

Nuxt側 署名生成 (server/utils/og-signature.ts)

import { createHmac } from 'crypto'

interface OgSignatureParams {
  path: string
  version: string
  title: string
  author: string
}

function normalizeText(text: string): string {
  return text.trim().replace(/\s+/g, ' ')
}

function normalizePath(path: string): string {
  return path.replace(/^\/+/, '')
}

export function generateOgSignature(params: OgSignatureParams): string {
  const secret = process.env.OG_SECRET
  if (!secret) {
    throw new Error('OG_SECRET is not set. OGP signing is disabled.')
  }

  const payload = [
    normalizePath(params.path),
    params.version,
    normalizeText(params.title),
    normalizeText(params.author)
  ].join('|')

  return createHmac('sha256', secret).update(payload).digest('hex').slice(0, 16)
}

...slug.vue での使用

import { generateOgSignature } from '~/server/utils/og-signature'

const authorName = 'Kei Komatsu'
const signature = generateOgSignature({
  path,
  version: updatedAt,
  title,
  author: authorName
})

const ogImageUrl = `https://log.eurekapu.com/og/blog${path}.png?v=${updatedAt}&title=${encodeURIComponent(title)}&author=${encodeURIComponent(authorName)}&sig=${signature}`

useHead({
  meta: [
    { property: 'og:image', content: ogImageUrl },
    { property: 'og:image:width', content: '1200' },
    { property: 'og:image:height', content: '630' },
    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:image', content: ogImageUrl },
  ]
})

実装手順(完了)

  1. R2バケット作成(wrangler r2 bucket create og-images
  2. wrangler.toml にR2バインディングとルート追加
  3. wrangler secret put OG_SECRET でシークレット設定
  4. Worker実装(ブログOGP生成 + R2キャッシュ + 署名検証)
  5. ローカルテスト(wrangler dev --remote
  6. Workerデプロイ
  7. Nuxt側に OG_SECRET 環境変数を設定(.env
  8. app/composables/useOgSignature.ts 作成(サーバーユーティリティではなくcomposableに変更)
  9. nuxt-og-imageは維持(ブログ以外の44ページで使用)
  10. [...slug].vue のog:image URL変更(useAsyncDataで非同期生成)
  11. ビルドテスト → 2分12秒で成功
  12. 本番デプロイ・動作確認
  13. GitHub Secretsに OG_SECRET を設定(CI用)

実装結果

項目BeforeAfter改善率
ビルド時間7分(471記事)2分12秒68%削減
新記事のOGP再ビルド必要即座に利用可能-
記事更新時全OGP再生成該当記事のみ再生成-

ビルド内訳(2分12秒)

  • Client build: 37.3秒
  • Server build: 24.7秒
  • Prerender (1226 routes): 56.3秒

注意点

  • nuxt-og-imageはブログ記事以外の44ページで引き続き使用
  • ブログ記事([...slug].vue)のみWorker経由でOGP生成

コードレビュー対応

対応済み

指摘重要度対応内容
クライアント遷移時OG上書きuseAsyncDataserver: trueオプション追加。クライアント側での再実行を抑止
非ASCIIパス未エンコードパスをencodeURIComponentでエンコード
settings.local.jsonコミットから除外

対応不要と判断

指摘重要度理由
フォント毎回取得R2キャッシュがあるため、フォント取得は記事ごとに初回のみ。2回目以降はR2から画像を返すためgenerateBlogOgImage()自体が呼ばれない

設計ポイント

ポイント内容
キャッシュキーblog/${articlePath}/${version}.png - バージョンを含めて自動無効化
署名対象path|v|title|author - 全パラメータを署名してURL改ざん防止
正規化パス: 先頭スラッシュ除去 / テキスト: trim + 連続空白→単一スペース
サニタイズHTMLエスケープ + 長さ制限(title: 80文字, author: 30文字)
OGメタタグog:imagetwitter:image 両方を明示的に設定
運用前提コンテンツ変更時は updatedAt を必ず更新(CI検知推奨)

リスク・注意点

  1. R2コスト: 無料枠10GB/月。OGP画像1枚約50KB、1000記事で約50MB。十分収まる。
  2. 初回遅延: キャッシュなし初回は1-2秒かかる。SNSクローラーには影響なし。
  3. フォント: 初回にGoogleフォント読み込みでコールドスタート遅延あり。

関連ドキュメント