表示言語の切り替え

Topics

GA4からmicroCMSの記事ランキングをAPIを消費せずに出力する方法

  • column

記事のランキングを表示したいって、お客さんからよく頼まれますよね。
JamstackサイトでmicorCMSの記事ランキングを表示する場合、専用のAPIを作成するのが一般的です。
でもAPIの枠がもうないけど実装したいという場合には、既存の記事コンテンツに直接PV数を書き込むというアプローチもあります。

今回はそのアプローチでmicroCMS + Cloudflare PagesでSSGで公開しているサイトでの実装例を紹介します!

しかしまぁ変な感じで一般のみなさんに知れ渡ってしまったCloudflareですが、本当にこれもお客様からのご要望でちょっと前に弊社サイトで試している内容です! https://www.liberogic.jp/topics/

このアプローチが向いているケース

  • サイドバーやフッターにTOP10程度のランキングを表示したい場合
  • リアルタイム性はそこまで求めない場合
  • microCMSのAPI枠に余裕がない状況
  • 静的サイト生成(SSG)でシンプルに実装したい場合

Step 1: microCMS の準備(スキーマ拡張と Webhook 制御)

1-1. APIスキーマの拡張

既存の記事エンドポイントに、ランキング用のフィールドを2つ追加します。

  • pageView (数字): GA4から取得した過去30日間の累積PVを格納します。
  • lastUpdatedPV (日時): PVが最後に更新された日時を記録します。

1-2. APIキー権限の設定

使用するAPIキーの設定で PATCH にチェックを入れます。

1-3. Webhook の設定(ビルド連発の回避策)

各記事のPV数の更新時にビルドが連発するのを防ぐため、Webhookのトリガーから、「コンテンツの公開(APIによる操作)」のチェックを外します。

Step 2: Google Cloud Consoleでの認証設定

Cloudflare Workersからアクセスするため、サービスアカウントデータ APIを用意します。

2-1. APIの有効化

Google Cloud Consoleのプロジェクトで、Google Analytics Data APIを有効化します。

2-2. サービスアカウントとキー作成

GA4連携用のサービスアカウントを作成し、メールアドレスをコピーします。

鍵タブの「キーを追加」から「新しい鍵を作成」で、キータイプJSONで作成しダウンロードします。

2-3. GA4権限の付与

GA4のプロパティの「アクセス管理」で、2-2で控えたメールアドレスをユーザーとして追加し、「閲覧者」以上の権限を付与します。

Step 3: Cloudflare Workers の作成と設定(GA4データ取得)

3-1. Workersの作成と環境変数の設定

Workersをga4-ranking-updaterのような名前で新規作成します。

設定から環境変数を設定します。キーはシークレットとして登録します。

MICROCMS_API_KEY

microCMSのAPIキー

MICROCMS_API_URL

https://[ID].microcms.io/api/v1

GA4_PROPERTY_ID

GA4のプロパティID

GA4_SERVICE_ACCOUNT_CREDENTIALS

JSONキーファイルの内容全体

GA4_PRIVATE_KEY_BASE64

JSONのprivate_key値から-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----\\n を削除したもの

3-2. GA4データ更新 Worker

「コードを編集する」からworker.jsの内容を以下のようにします。

const MICROCMS_ENDPOINT_NAME = '[記事のエンドポイント名]'; 

let contentIdToSlugMap = {};

// ----------------------------------------------------------------------
// 1. microCMSから全記事のIDとスラッグを取得し、マップを作成する
// ----------------------------------------------------------------------
async function fetchContentMap(env) {
    const apiEndpoint = `${env.MICROCMS_API_URL}/${MICROCMS_ENDPOINT_NAME}`;
    const slugToIdMap = {};
    let offset = 0;
    const limit = 100;

    while (true) {
        const url = `${apiEndpoint}?fields=id,slug&limit=${limit}&offset=${offset}`;
        const response = await fetch(url, {
            headers: { 'X-MICROCMS-API-KEY': env.MICROCMS_API_KEY },
        });
        if (!response.ok) {
            throw new Error(`microCMS Map Fetch Error: ${response.status} ${await response.text()}`);
        }
        const data = await response.json();
        
        data.contents.forEach(item => {
            if (item.slug && item.id) {
                slugToIdMap[item.slug] = item.id;
            }
        });
        if (data.contents.length < limit || data.totalCount <= (offset + limit)) {
            break;
        }
        offset += limit;
    }
    
    contentIdToSlugMap = slugToIdMap; 
    console.log(`microCMSから合計 ${Object.keys(slugToIdMap).length} 件の記事IDマップを取得しました。`);
}

// ----------------------------------------------------------------------
// 2. JWT 認証ヘルパー関数群 (GA4 アクセストークン取得用)
// ----------------------------------------------------------------------
// Base64Url エンコード/デコード
const base64UrlEncode = (data) => btoa(String.fromCharCode(...new Uint8Array(data))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
const base64UrlDecode = (data) => Uint8Array.from(atob(data.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));

async function importPrivateKey(keyBase64) {
    // 秘密鍵本体をデコードし、DER形式のバイナリにする
    const binaryDer = base64UrlDecode(keyBase64);

    return crypto.subtle.importKey(
        'pkcs8',
        binaryDer,
        { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
        false,
        ['sign']
    );
}

// GA4 Service Accountの認証情報からAccess Tokenを取得するメイン関数
async function getAccessToken(credentialsString, privateKeyBodyBase64) {
    // 認証情報JSONをパースし、client_emailを取得
    const creds = JSON.parse(credentialsString);
    const serviceAccountEmail = creds.client_email;

    const now = Math.floor(Date.now() / 1000);
    const expiry = now + 3600; // 有効期限: 1時間後
    const header = { alg: 'RS256', typ: 'JWT' };
    const payload = {
        iss: serviceAccountEmail,
        scope: '<https://www.googleapis.com/auth/analytics.readonly>',
        aud: '<https://oauth2.googleapis.com/token>',
        exp: expiry,
        iat: now,
    }

    // 2. 署名するデータ(Header.Payload)を作成
    const encodedHeader = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header)));
    const encodedPayload = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload)));
    const signatureInput = `${encodedHeader}.${encodedPayload}`;

    // 3. 秘密鍵をインポートし、JWTに署名
    const key = await importPrivateKey(privateKeyBodyBase64);
    const signature = await crypto.subtle.sign(
        { name: 'RSASSA-PKCS1-v1_5' },
        key,
        new TextEncoder().encode(signatureInput)
    );
    const encodedSignature = base64UrlEncode(new Uint8Array(signature));

    // 4. JWTを完成させる
    const jwt = `${signatureInput}.${encodedSignature}`;

    // 5. Googleトークンエンドポイントへリクエスト
    const tokenResponse = await fetch('<https://oauth2.googleapis.com/token>', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: new URLSearchParams({
            grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
            assertion: jwt,
        }),
    });

    if (!tokenResponse.ok) {
        throw new Error(`Token request failed: ${tokenResponse.status} ${await tokenResponse.text()}`);
    }

    const tokenData = await tokenResponse.json();
    return tokenData.access_token; // アクセストークンを返す
}

// ----------------------------------------------------------------------
// 3. GA4 データ取得関数
// ----------------------------------------------------------------------
async function fetchGa4Data(accessToken, propertyId) {
    const apiEndpoint = `https://analyticsdata.googleapis.com/v1beta/properties/${propertyId}:runReport`;
    
    const response = await fetch(apiEndpoint, {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${accessToken}`,
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            dateRanges: [{ startDate: '30daysAgo', endDate: 'yesterday' }], // 昨日から30日間で取得する(必要に合わせて変更)
            dimensions: [{ name: 'pagePath' }],
            metrics: [{ name: 'screenPageViews' }],
            orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
            limit: 100, // 100件以降の記事はPV0として処理する(必要に合わせて変更)
        }),
    });

    if (!response.ok) {
        throw new Error(`GA4 API Error: ${response.status} ${await response.text()}`);
    }

    const data = await response.json();
    
    const pvData = data.rows?.map(row => ({
        pagePath: row.dimensionValues[0].value, 
        pageView: parseInt(row.metricValues[0].value, 10), 
    })) || [];
    
    return pvData;
}

// ----------------------------------------------------------------------
// 4. microCMS コンテンツID解決関数
// ----------------------------------------------------------------------
// `/blog/記事スラッグ/`という構造の場合(必要に合わせて変更)
function resolveContentIdFromPath(pagePath) {
    try {
        const cleanPath = new URL('<https://dummy.com>' + pagePath).pathname;
        const parts = cleanPath.split('/').filter(p => p.length > 0); 

        // 1. '/blog/ID' の構造であるか確認
        if (parts.length < 2 || parts[0] !== 'blog') {
            return null; 
        }
        
        const slug = parts[1];
        
        // マップを使って、スラッグからコンテンツIDを取得
        const actualContentId = contentIdToSlugMap[slug];

        if (!actualContentId) {
             // マップに存在しない(microCMSに記事が存在しない)場合は無視
             console.log(`[IGNORE] Slug ${slug} not found in microCMS map.`);
             return null;
        }

        // microCMSのコンテンツIDを返す
        console.log(`[RESOLVED] Slug ${slug} -> ID: ${actualContentId}`);
        return actualContentId;

    } catch (e) {
        console.error(`Path parsing error for ${pagePath}: ${e}`);
        return null;
    }
}

// ----------------------------------------------------------------------
// 5. microCMS コンテンツ更新関数
// ----------------------------------------------------------------------
async function updateMicroCMS(pvData, env) {
    let updatedCount = 0;
    const errors = [];
    
    // 1. GA4データをスラッグをキーとするマップに変換
    const ga4SlugPvMap = {};
    pvData.forEach(item => {
        const slug = item.pagePath.split('/').filter(p => p.length > 0)[1];
        if (slug) {
            ga4SlugPvMap[slug] = item.pageView;
        }
    });

    // 2. microCMSの全記事マップ (contentIdToSlugMap) をループ
    for (const slug in contentIdToSlugMap) {
        if (contentIdToSlugMap.hasOwnProperty(slug)) {
            
            const actualContentId = contentIdToSlugMap[slug];
            
            // 2で取得したGA4のデータにあればその値、なければ 0 を設定 (リセット)
            const newPageView = ga4SlugPvMap[slug] || 0; 

            // PATCHリクエストのURLを構築
            const microcmsUrl = `${env.MICROCMS_API_URL}/${MICROCMS_ENDPOINT_NAME}/${actualContentId}`;
            
            const updatePayload = {
                pageView: newPageView, 
                lastUpdatedPV: new Date().toISOString()
            };
            
            const response = await fetch(microcmsUrl, {
                method: 'PATCH', 
                headers: {
                    'Content-Type': 'application/json',
                    'X-MICROCMS-API-KEY': env.MICROCMS_API_KEY,
                },
                body: JSON.stringify(updatePayload),
            });

            if (response.ok) {
                updatedCount++;
            } else {
                errors.push({ contentId: actualContentId, status: response.status, body: await response.text() });
            }
        }
    }
    
    if (errors.length > 0) {
        console.error(`microCMS Update Errors: ${JSON.stringify(errors)}`);
    }

    console.log(`microCMSのコンテンツ ${updatedCount} 件を更新しました。`);
    return updatedCount;
}

// ----------------------------------------------------------------------
// 6. Workerのメインハンドラー (Cron Triggers用)
// ----------------------------------------------------------------------
export default {
    async scheduled(controller, env, ctx) {
        try {
            console.log('--- GA4 Ranking Updater Started ---');
            
            // 1. microCMSからIDマップを事前取得
            await fetchContentMap(env);

            // 2. GA4 Access Tokenの取得
            const accessToken = await getAccessToken(
                env.GA4_SERVICE_ACCOUNT_CREDENTIALS,
                env.GA4_PRIVATE_KEY_BASE64
            );

            // 3. GA4データ取得
            const pvData = await fetchGa4Data(accessToken, env.GA4_PROPERTY_ID);
            console.log(`GA4から ${pvData.length} 件のデータを取得しました。`);
            
            // 4. microCMSコンテンツの更新
            const updatedCount = await updateMicroCMS(pvData, env);
            
            console.log(`microCMSのコンテンツ ${updatedCount} 件を更新しました。`);
            console.log('--- GA4 Ranking Updater Finished ---');

        } catch (error) {
            console.error('致命的なエラーが発生しました:', error);
        }
    }
};

Workerコードの主要なロジック

  1. 事前マップ取得: microCMSの全記事の idslug のマップを事前取得します。
  2. GA4データ取得: 過去30日間の累積PVを取得します。
  3. 更新ロジック: GA4にデータがあれば新しいPVで更新、なければPVを 0 にリセットします。

3-3. Cron設定(データ更新)

設定のトリガーイベントでCronトリガーを0 15 * * * で設定します。 毎日午前0時に実行し、前日までのデータをmicroCMSに反映させます。

Step 4: Cloudflare Workers の作成と設定(ビルドトリガー)

4-1. Workersの作成と環境変数の設定

データ更新とデプロイを分離するため、サイトビルド専用のWorkersをbuild-triggerのような名前で作成します。

環境変数で CLOUDFLARE_PAGES_BUILD_HOOK にCloudflare PagesのビルドフックURLを設定します。

4-2. Workerコード(ビルドトリガー)

Pagesのビルドフックに POST リクエストを送るコードを実装します。

export default {
  async scheduled(controller, env, ctx) {
      // 環境変数からビルドフックURLを取得
      const buildHookUrl = env.CLOUDFLARE_PAGES_BUILD_HOOK;

      if (!buildHookUrl) {
          console.error("FATAL: CLOUDFLARE_PAGES_BUILD_HOOK environment variable is not set.");
          return;
      }

      // 2. ビルドフックURLにPOSTリクエストを送信
      const response = await fetch(buildHookUrl, {
          method: 'POST',
          headers: {
              'Content-Type': 'application/json',
          },
      });

      // 3. 結果の確認
      if (response.ok) {
          console.log("✅ Cloudflare Pages Build Triggered Successfully.");
      } else {
          console.error(`❌ Build Trigger Failed! Status: ${response.status} ${response.statusText}`);
      }
  },
};

4-3. Cron設定(サイトデプロイ)

設定のトリガーイベントでCronトリガーを10 15 * * * で設定します。 3の処理の完了を待ってから実行したいため、余裕を持って10分後の毎日午前0時10分にデプロイを開始します。

まとめ

ランキング用APIを用意するのに比べて即時性は劣りますが、10分程度であれば十分許容範囲だと思います。 APIも消費せず、Cloudflareの無料枠で実行可能というコスト効率と、Cloudflareで完結するシンプルな構成が大きなメリットと思います!

この記事を書いた人

DTPからWebの世界へ飛び込み、気づけばマークアップもフロントエンドもディレクションもアクセシビリティもこなす"技の仙人"。リベロジック創業期からマルチに活躍し、今や社内の生き字引的存在。最近は「アクセシビリティ対応、もっとAIに頼れないかな?」と、プロンプトを駆使した効率化の探究にハマり中。技術も思考も、まだまだ進化中

フタさん

IAAP認定ウェブアクセシビリティスペシャリスト(WAS) / マークアップエンジニア / フロントエンドエンジニア / ウェブディレクター

このスタッフの記事を見る

ケーススタディ