• #cloudflare-workers
  • #workers-routes
  • #ogp
  • #seo
  • #nuxt
未分類

Cloudflare Workers Routes でOGPシェアURLのSEO問題を解決

背景:もともと何をやろうとしていたか

SNSで日本語作文クイズの結果をシェアする際、動的にOGP画像を生成したかった。

ユーザーがクイズ完了 → 「5/12問正解!」のOGP画像を動的生成 → SNSでシェア

課題

  1. Nuxt は静的サイト生成(SSG)を使用 - OOM問題により cloudflare-pages-static プリセットを使用
  2. 静的サイトではクエリパラメータを動的に処理できない - ビルド時に固定される
  3. OGP画像生成に workers-og を使いたい - Nuxt ビルドに組み込むとWASM関連でOOMが発生

初期の解決策:Workerを分離

OGP画像生成を別の Cloudflare Worker(og-image-worker)として分離してデプロイ。

apps/workers/og/
├── src/index.ts    # OGP画像生成 + シェアページHTML
├── wrangler.toml
└── package.json

Worker URL: https://og-image-worker.number55number55.workers.dev


発生した問題:SEO効果がメインドメインに帰属しない

問題のある構成

シェアURL: https://og-image-worker.number55number55.workers.dev/share/japanese-quiz?...
                    ↑ Workers.dev ドメイン

workers.dev をSNSで拡散されるリンクが指しているため:

項目状態
OGPカード表示正常
ユーザートラフィックリダイレクトで得られる
被リンク評価(SEO)workers.dev に帰属
ブランドURL認知低い

Nuxt側にシェアページを作成してみたが

pages/share/japanese-quiz.vue を作成し、シェアURLを log.eurekapu.com/share/... に変更。

結果:失敗

route.query がビルド時に空になる静的サイト生成では、OGPメタタグが固定値(0/10問正解)になってしまった。


解決策:Cloudflare Workers Routes

Workers Routes を使って、メインドメインのURLでWorkerを実行する。

設定前:
log.eurekapu.com/share/* → Nuxt静的ページ(クエリパラメータが空)

設定後:
log.eurekapu.com/share/* → Worker(クエリパラメータを動的に処理)

仕組み

Step 1: ユーザーがSNSにシェア

クイズ完了後、「結果をシェアする」ボタンを押すと、以下のURLがSNSに投稿される:

https://log.eurekapu.com/share/japanese-quiz?category=punctuation&correct=5&total=12

Nuxt側の実装(ResultSummary.vue):

// apps/web/app/components/japanese-quiz/ResultSummary.vue

// シェアURLはメインドメインのシェアページを使用(SEO効果を最大化)
const shareUrl = computed(() => {
  return `https://log.eurekapu.com/share/japanese-quiz?category=${props.category}&correct=${props.result.correctAnswers}&total=${props.result.totalQuestions}`
})

const shareText = computed(() => {
  const categoryName = getCategoryLabel(props.category)
  return `${categoryName}」クイズで${props.result.correctAnswers}/${props.result.totalQuestions}問正解しました!\n\nあなたも挑戦してみませんか?`
})

// シェアボタン押下時の処理
const shareResult = async () => {
  if (navigator.share) {
    // Web Share API が使える場合(主にモバイル)
    await navigator.share({
      title: `${getCategoryLabel(props.category)}クイズ結果`,
      text: shareText.value,
      url: shareUrl.value,  // ← このURLがSNSに投稿される
    })
  } else {
    // Web Share APIが使えない場合はTwitterにシェア
    shareToTwitter()
  }
}

const shareToTwitter = () => {
  const tweetText = encodeURIComponent(shareText.value)
  const tweetUrl = encodeURIComponent(shareUrl.value)
  window.open(
    `https://twitter.com/intent/tweet?text=${tweetText}&url=${tweetUrl}`,
    '_blank'
  )
}

ポイント:log.eurekapu.com(メインドメイン)をシェアURLは指している。Workers Routes によって、このURLへのアクセスは Worker に転送される。

Step 2: SNSクローラーがOGP情報を取得

SNS(Twitter/X、Facebook等)は投稿されたURLに対してクローラーを送り、OGPメタタグを読み取る:

SNSクローラーがURLにアクセス
        ↓
https://log.eurekapu.com/share/japanese-quiz?category=punctuation&correct=5&total=12
        ↓
Cloudflare がルートをチェック → 「/share/*」にマッチ
        ↓
og-image-worker に転送
        ↓
Worker がクエリパラメータを読んで動的にHTML生成
        ↓
OGPメタタグ付きHTMLを返す:
  - og:title = "句読点の打ち方クイズ - 5/12問正解!"
  - og:image = "https://og-image-worker.../og/japanese-quiz?category=punctuation&correct=5&total=12"
        ↓
SNSクローラーが og:image のURLにもアクセス
        ↓
Worker が動的にOGP画像を生成して返す(「5/12問正解」の画像)
        ↓
SNSがOGPカードを表示(タイトル + 説明 + 画像)

Worker側の実装(シェアページHTML生成):

// apps/workers/og/src/index.ts

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

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

    // OGP画像を返す(og:image用)
    if (url.pathname === '/og/japanese-quiz') {
      return handleJapaneseQuizImage(url)
    }

    // シェアページ(OGタグ付きHTMLを返し、実際のページにリダイレクト)
    if (url.pathname === '/share/japanese-quiz') {
      return handleSharePage(url)
    }

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

// シェアページ: OGタグ付きHTMLを返す
function handleSharePage(url: URL): Response {
  // クエリパラメータから値を取得(動的に処理できる!)
  const category = url.searchParams.get('category') || 'all'
  const correct = url.searchParams.get('correct') || '0'
  const total = url.searchParams.get('total') || '10'

  const categoryName = CATEGORY_NAMES[category] || 'クイズ'
  const ogImageUrl = `https://og-image-worker.number55number55.workers.dev/og/japanese-quiz?category=${category}&correct=${correct}&total=${total}`
  const redirectUrl = `https://log.eurekapu.com/japanese-writing-quiz/${category}`

  // OGPメタタグを含むHTMLを動的に生成
  const html = `<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>${categoryName} - ${correct}/${total}問正解! | 日本語作文クイズ</title>

  <!-- OGPメタタグ(SNSクローラーが読み取る) -->
  <meta property="og:title" content="${categoryName}クイズ - ${correct}/${total}問正解!">
  <meta property="og:description" content="日本語作文クイズに挑戦しよう">
  <meta property="og:image" content="${ogImageUrl}">
  <meta property="og:type" content="website">

  <!-- Twitter Card -->
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="${categoryName}クイズ - ${correct}/${total}問正解!">
  <meta name="twitter:image" content="${ogImageUrl}">

  <!-- 人間のユーザー用:即座にリダイレクト -->
  <meta http-equiv="refresh" content="0;url=${redirectUrl}">
</head>
<body>
  <p>リダイレクト中... <a href="${redirectUrl}">こちらをクリック</a></p>
</body>
</html>`

  return new Response(html, {
    headers: { 'Content-Type': 'text/html; charset=utf-8' },
  })
}

ポイント:url.searchParams.get() でクエリパラメータを実行時に取得できる。静的サイトと違い、リクエストごとに Worker は動的に処理できる。

Worker側の実装(OGP画像生成):

// apps/workers/og/src/index.ts(続き)

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

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

async function handleJapaneseQuizImage(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(OGP画像のレイアウト)
  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; 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()

  // workers-og が HTML を PNG 画像に変換
  return new ImageResponse(html, {
    width: 1200,
    height: 630,
    fonts: [{
      name: 'Noto Sans JP',
      data: fontData,
      weight: 400,
      style: 'normal',
    }],
  })
}

ポイント:workers-og パッケージを使用。HTMLテンプレートを渡すと、Satori + Resvg で PNG 画像を動的に生成。フォントは必要な文字だけ Google Fonts からサブセット化してダウンロード。

Step 3: 他のユーザーがOGPカードをクリック

SNSのタイムラインでOGPカードを見た人がクリックすると:

OGPカードをクリック
        ↓
https://log.eurekapu.com/share/japanese-quiz?category=punctuation&correct=5&total=12
        ↓
Cloudflare → og-image-worker に転送
        ↓
Worker がHTMLを返す(meta refresh 付き)
        ↓
ブラウザが meta refresh を実行
        ↓
https://log.eurekapu.com/japanese-writing-quiz/punctuation にリダイレクト
        ↓
実際のクイズページが表示される

リダイレクトの仕組み(Worker側):

Step 2 で示した handleSharePage 関数の中で、以下の部分がリダイレクトを実現している:

<!-- 人間のユーザー用:即座にリダイレクト -->
<meta http-equiv="refresh" content="0;url=${redirectUrl}">
  • content="0;url=..."0 は「0秒後にリダイレクト」を意味する
  • SNSクローラーは <head> 内のOGPメタタグを読み取って終了する(リダイレクトしない)
  • 人間のブラウザは meta refresh を実行してリダイレクトする

これにより、同じHTMLでクローラーと人間の両方に対応できる。

ポイント

  • SNSクローラー人間のユーザーが同じURLにアクセスする
  • どちらもWorkerが処理するが、クローラーはOGPメタタグを読み、人間はリダイレクトされる
  • OGP画像のURLは workers.dev でも問題ない(画像ホスティング先はSEOに影響しない)

設定手順

1. Cloudflare ダッシュボードで設定

  1. Cloudflare ダッシュボード → Workers & Pages → og-image-worker
  2. 「設定」タブ → 「ドメインとルート」セクション
  3. 「+ 追加」をクリック
  4. 「ルート」を選択
  5. 以下を入力:
    • ゾーン: eurekapu.com
    • ルート: log.eurekapu.com/share/*
    • 失敗モード: 失敗クローズ(ブロック)
  6. 「ルートを追加」をクリック alt text

2. wrangler.toml に追記(推奨)

ダッシュボードで設定した内容をコードにも反映しておくと、次回デプロイ時も設定が維持される。

# apps/workers/og/wrangler.toml
name = "og-image-worker"
main = "src/index.ts"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
account_id = "your-account-id"  # GitHub Actions で /memberships API 呼び出しをスキップするため

# workers.dev ドメインを有効にする(OGP画像のURLで使用)
workers_dev = true

# メインドメインでWorkerを実行するためのルート設定
routes = [
  { pattern = "log.eurekapu.com/share/*", zone_name = "eurekapu.com" }
]

重要な設定:

設定説明
account_idGitHub Actions でデプロイする際、/memberships API の呼び出しをスキップできる。これがないと API トークンに追加の権限が必要になる
workers_dev = trueroutes を設定するとデフォルトで workers.dev ドメインが無効になる。OGP画像は workers.dev ドメインで提供しているので、明示的に true にする必要がある

なぜ workers_dev = true が必要か:

Worker は2つのエンドポイントを持っている:

  • /share/*log.eurekapu.com/share/*(routes で設定)
  • /og/*og-image-worker.workers.dev/og/*(workers.dev ドメイン)

routes を追加すると wrangler がデフォルトで workers.dev を無効にするため、OGP画像にアクセスできなくなる。両方のドメインを有効にするために workers_dev = true が必要。

3. 不要になったNuxtシェアページを削除

リクエストを Worker が処理するため、Nuxt側のシェアページは不要。

# 削除
rm apps/web/app/pages/share/japanese-quiz.vue

達成されたこと

項目改善前改善後
シェアURLworkers.dev/share/...log.eurekapu.com/share/...
被リンク評価の帰属先workers.devlog.eurekapu.com
ドメインオーソリティへの寄与なしあり
ブランドURL認知低い高い
OGPカード表示正常正常(変化なし)
動的OGP生成WorkerWorker(変化なし)

確認方法

OGPデバッガーで以下のURLをテスト:

https://log.eurekapu.com/share/japanese-quiz?category=punctuation&correct=5&total=12

alt text

  • OGP画像: 「句読点の打ち方 - 5/12問正解」と表示される
  • タイトル: 「句読点の打ち方クイズ - 5/12問正解!」

まとめ

なぜこの方法が最適か

  1. コード変更が最小限 - ResultSummary.vue のシェアURLはメインドメインのまま
  2. 既存のWorkerをそのまま使える - OGP画像生成ロジックの変更不要
  3. SEO効果がメインドメインに集約 - 被リンク評価がlog.eurekapu.comに帰属
  4. 設定が簡単 - ダッシュボードで数クリック

Workers Routes の活用ポイント

  • 静的サイト + 動的処理が必要な部分だけWorkerで処理
  • メインドメインのURL体系を維持しつつ、裏側でWorkerが動く
  • 必要なパスだけをWorkerにルーティングできる柔軟性

関連ドキュメント