開発完了
動的OGP画像生成 Worker 実装
概要
日本語作文クイズの結果シェア機能で、動的にOGP画像を生成するための Cloudflare Worker を実装した。
背景: Nuxt ビルドに workers-og を組み込むと WASM 関連で OOM が発生するため、Worker を分離して独立デプロイする方針に変更。
デプロイ情報
| 項目 | 値 |
|---|---|
| Worker URL | https://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 で自動デプロイするには以下の設定が必要:
- Cloudflare API Token の作成
- Cloudflare Dashboard → My Profile → API Tokens
- 「Create Token」→「Edit Cloudflare Workers」テンプレートを使用
- または Custom Token で以下の権限を付与:
- Account: Workers Scripts: Edit
- Zone: Workers Routes: Edit(カスタムドメイン使用時)
- 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.toml で nodejs_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 名を変更する可能性がある場合や、ブランディングを重視する場合にのみ検討すればよい。
設定する場合の手順(参考)
- Cloudflare DNS で
og.eurekapu.comを追加 wrangler.tomlに routes を設定:routes = [ { pattern = "og.eurekapu.com/*", zone_name = "eurekapu.com" } ]- Worker を再デプロイ
- Nuxt 側の ogImage URL を
https://og.eurekapu.com/og/japanese-quiz?...に変更
完了チェックリスト
- Worker プロジェクト作成 (
apps/workers/og/) -
pnpm-workspace.yamlに追加 -
src/index.ts実装(画像生成 + シェアページ) - フォントサブセット化
- Cloudflare へデプロイ
- Nuxt 側の
og:imageURL 更新 - ResultSummary.vue のシェアURL を Worker に変更
- GitHub Actions ワークフロー作成
- GitHub Secrets に CLOUDFLARE_API_TOKEN を設定(上記「GitHub Actions 設定TODO」参照)
- カスタムドメイン設定(オプション・必須ではない)
関連ドキュメント
- CI OOM 調査メモ - Worker 分離に至った経緯
- Nitro プリセット変更 - cloudflare-pages-static への変更理由