Topics

Wie man GA4-Artikelrankings von microCMS ohne API-Verbrauch ausgibt

  • column

Kunden bitten uns häufig darum, Artikelrankings anzuzeigen, nicht wahr?
Bei einer Jamstack-Website, die Artikelrankings aus microCMS anzeigt, wird in der Regel eine eigene API erstellt.
Wenn jedoch kein API-Kontingent mehr zur Verfügung steht und die Umsetzung dennoch gewünscht ist, gibt es außerdem einen Ansatz, bei dem die Seitenaufrufe direkt in die bestehenden Artikelinhalte geschrieben werden.

In diesem Beitrag stellen wir ein Implementierungsbeispiel für eine mit microCMS und Cloudflare Pages per SSG veröffentlichte Website vor, die genau diesen Ansatz verwendet!

Cloudflare ist inzwischen zwar auf merkwürdige Weise bei einem breiteren Publikum bekannt geworden, doch auch dies ist eine Umsetzung, die wir auf Kundenwunsch vor einiger Zeit auf unserer eigenen Website getestet haben! https://www.liberogic.jp/topics/

Fälle, in denen sich dieser Ansatz eignet

  • Wenn in der Seitenleiste oder im Footer ein Ranking der Top 10 oder ähnlich angezeigt werden soll
  • Wenn keine Echtzeitaktualisierung erforderlich ist
  • Wenn das API-Kontingent von microCMS knapp ist
  • Wenn Sie eine einfache Umsetzung mit Static Site Generation (SSG) wünschen

Schritt 1: Vorbereitung in microCMS (Schema-Erweiterung und Webhook-Steuerung)

1-1. Erweiterung des API-Schemas

Dem bestehenden Artikel-Endpunkt werden zwei Felder für das Ranking hinzugefügt.

  • pageView (Zahl): Speichert die kumulierten Seitenaufrufe der letzten 30 Tage aus GA4.
  • lastUpdatedPV (Datum/Uhrzeit): Zeichnet den Zeitpunkt auf, zu dem die Seitenaufrufe zuletzt aktualisiert wurden.

1-2. Einstellung der API-Schlüssel-Berechtigungen

Aktivieren Sie in den Einstellungen des verwendeten API-Schlüssels die Option PATCH.

1-3. Einrichtung des Webhooks (Umgehung häufiger Builds)

Damit bei jeder Aktualisierung der Seitenaufrufe eines Artikels nicht ständig neue Builds ausgelöst werden, deaktivieren Sie im Webhook-Trigger das Kontrollkästchen "Veröffentlichung von Inhalten (Vorgang über API)".

Schritt 2: Authentifizierung in der Google Cloud Console einrichten

Für den Zugriff von Cloudflare Workers legen Sie ein Dienstkonto und eine Data API an.

2-1. API aktivieren

Aktivieren Sie in Ihrem Projekt in der Google Cloud Console die Google Analytics Data API.

2-2. Dienstkonto und Schlüssel erstellen

Erstellen Sie ein Dienstkonto für die GA4-Integration und kopieren Sie dessen E-Mail-Adresse.

Klicken Sie im Tab „Schlüssel“ auf „Schlüssel hinzufügen“ und anschließend auf „Neuen Schlüssel erstellen“. Wählen Sie den Schlüsseltyp JSON, erstellen Sie den Schlüssel und laden Sie ihn herunter.

2-3. GA4-Berechtigung erteilen

Fügen Sie unter „Verwaltung“ des GA4-Properties die in Schritt 2-2 kopierte E-Mail-Adresse als Nutzer hinzu und weisen Sie ihr mindestens die Berechtigung „Betrachter“ zu.

Schritt 3: Erstellung und Konfiguration von Cloudflare Workers (Abruf von GA4-Daten)

3-1. Erstellen des Workers und Festlegen der Umgebungsvariablen

Erstellen Sie einen neuen Worker mit einem Namen wie ga4-ranking-updater.

Legen Sie die Umgebungsvariablen in den Einstellungen fest. Registrieren Sie die Schlüssel als Secrets.

MICROCMS_API_KEY

microCMS-API-Schlüssel

MICROCMS_API_URL

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

GA4_PROPERTY_ID

GA4-Property-ID

GA4_SERVICE_ACCOUNT_CREDENTIALS

Gesamter Inhalt der JSON-Schlüsseldatei

GA4_PRIVATE_KEY_BASE64

Der Wert private_key aus dem JSON ohne -----BEGIN PRIVATE KEY-----, -----END PRIVATE KEY----- und \

3-2. GA4-Datenaktualisierungs-Worker

Ersetzen Sie über „Code bearbeiten“ den Inhalt von worker.js wie folgt.

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);
        }
    }
};

Zentrale Logik des Worker-Codes:

  1. Vorab-Abruf der Zuordnung: Wir rufen vorab die Zuordnung von id und slug aller Artikel in microCMS ab.
  2. Abruf der GA4-Daten: Wir rufen die kumulierten Seitenaufrufe der letzten 30 Tage ab.
  3. Aktualisierungslogik: Liegen in GA4 Daten vor, wird mit den neuen Seitenaufrufen aktualisiert. Andernfalls werden die Seitenaufrufe auf 0 zurückgesetzt.

3-3. Cron-Konfiguration (Datenaktualisierung)

Konfigurieren Sie den Cron-Trigger in den Trigger-Ereignissen der Einstellungen mit 0 15 * * *. Er wird täglich um 0:00 Uhr ausgeführt und überträgt die Daten bis zum Vortag nach microCMS.

Schritt 4: Erstellen und Konfigurieren von Cloudflare Workers (Build-Trigger)

4-1. Erstellen von Workers und Einrichten der Umgebungsvariablen

Um Datenaktualisierung und Deployment zu trennen, erstellen Sie einen Worker speziell für den Site-Build, z. B. mit dem Namen build-trigger.

Legen Sie in den Umgebungsvariablen unter CLOUDFLARE_PAGES_BUILD_HOOK die Build-Hook-URL von Cloudflare Pages fest.

4-2. Worker-Code (Build-Trigger)

Wir implementieren Code, der einen POST-Request an den Build-Hook der Pages sendet.

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-Konfiguration (Site-Deployment)

Bei den Trigger-Ereignissen legen wir den Cron-Trigger auf 10 15 * * * fest. Da die Ausführung erst nach Abschluss der Verarbeitung in Schritt 3 erfolgen soll, starten wir das Deployment mit etwas Puffer täglich um 0:10 Uhr, also zehn Minuten später.

Fazit

Im Vergleich zu einer eigenen API für das Ranking ist die Aktualität zwar geringer, aber eine Verzögerung von etwa 10 Minuten ist meines Erachtens völlig akzeptabel. Ein großer Vorteil ist die Kosteneffizienz: Es werden keine API-Kontingente verbraucht, und die Ausführung läuft im kostenlosen Kontingent von Cloudflare. Zudem ist die Architektur schlank und vollständig in Cloudflare abgebildet!

Über den Autor

Vom DTP in die Webwelt: Heute beherrscht er Markup, Frontend, Projektleitung und Barrierefreiheit gleichermaßen – ein wahrer "Techniker-Guru". Seit den Anfängen von Liberogic vielseitig im Einsatz und heute eine lebende Enzyklopädie im Team. Derzeit tüftelt er mit großer Begeisterung an effizienteren Abläufen durch gezielte Prompts – etwa mit der Frage: "Können wir bei der Barrierefreiheit nicht mehr auf KI setzen?" Technik und Denkweise entwickeln sich weiter.

Ayumu Futamata

IAAP-zertifizierter Web Accessibility Specialist (WAS) / Markup-Entwickler / Frontend-Entwickler / Webdirektor

Artikel dieses Teammitglieds ansehen

Fallstudien