Topics

Comment afficher le classement des articles de microCMS depuis GA4 sans consommer l'API

  • column

On nous demande souvent d'afficher un classement des articles, n'est-ce pas ?
Pour un site Jamstack qui affiche un classement d'articles microCMS, la pratique courante consiste à créer une API dédiée.
Mais si le quota d'API est déjà épuisé et que la fonctionnalité doit tout de même être mise en place, une approche consiste à écrire directement les nombres de vues (PV) dans le contenu existant des articles.

Dans cet article, nous présentons un exemple de mise en œuvre de cette approche sur un site publié en SSG avec microCMS et Cloudflare Pages !

Cloudflare est devenu connu du grand public d'une manière un peu inattendue, mais cette mise en œuvre est en fait une demande de clients que nous avons testée sur notre propre site il y a quelque temps ! https://www.liberogic.jp/topics/

Cas où cette approche est adaptée

  • Lorsque vous souhaitez afficher un classement d'environ 10 articles dans une barre latérale ou un pied de page
  • Lorsque le temps réel n'est pas indispensable
  • Lorsque le quota d'API de microCMS est insuffisant
  • Si vous souhaitez une implémentation simple avec la génération de sites statiques (SSG)

Étape 1 : préparation de microCMS (extension du schéma et contrôle des Webhooks)

1-1. Extension du schéma API

Ajoutez deux champs destinés au classement à l'endpoint existant des articles.

  • pageView (nombre) : stocke le nombre cumulé de pages vues des 30 derniers jours, récupéré depuis GA4.
  • lastUpdatedPV (date et heure) : enregistre la date et l'heure de la dernière mise à jour des pages vues.

1-2. Configuration des droits de la clé API

Dans les paramètres de la clé API utilisée, cochez PATCH.

1-3. Configuration du Webhook (contournement des builds répétés)

Pour éviter que des builds se déclenchent en rafale à chaque mise à jour du nombre de pages vues de chaque article, décochez « Publication du contenu (opération via l'API) » dans les déclencheurs du Webhook.

Étape 2 : Configuration de l'authentification dans Google Cloud Console

Pour accéder depuis Cloudflare Workers, préparez un compte de service et une API de données.

2-1. Activation de l'API

Dans votre projet Google Cloud Console, activez l'API Google Analytics Data.

2-2. Création du compte de service et de la clé

Créez un compte de service pour la connexion à GA4, puis copiez son adresse e-mail.

Dans l'onglet Clés, cliquez sur « Ajouter une clé », puis sur « Créer une nouvelle clé ». Sélectionnez le type de clé JSON pour la créer et téléchargez-la.

2-3. Attribution des droits GA4

Dans « Gestion des accès » de la propriété GA4, ajoutez l'adresse e-mail notée à l'étape 2-2 en tant qu'utilisateur, avec le rôle « Lecteur » ou un rôle supérieur.

Étape 3 : Création et configuration de Cloudflare Workers (récupération des données GA4)

3-1. Création du Worker et configuration des variables d'environnement

Créez un nouveau Worker nommé ga4-ranking-updater ou un nom similaire.

Définissez les variables d'environnement depuis les paramètres. Enregistrez les clés en tant que secrets.

MICROCMS_API_KEY

Clé API microCMS

MICROCMS_API_URL

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

GA4_PROPERTY_ID

ID de propriété GA4

GA4_SERVICE_ACCOUNT_CREDENTIALS

Contenu complet du fichier de clé JSON

GA4_PRIVATE_KEY_BASE64

Valeur private_key du JSON, après suppression de -----BEGIN PRIVATE KEY-----, -----END PRIVATE KEY----- et \

3-2. Worker de mise à jour des données GA4

Depuis « Modifier le code », remplacez le contenu de worker.js par ce qui suit.

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

Logique principale du code du Worker :

  1. Récupération de la correspondance préalable : récupère à l'avance la correspondance entre les id et les slug de tous les articles de microCMS.
  2. Récupération des données GA4 : récupère le total cumulé des pages vues des 30 derniers jours.
  3. Logique de mise à jour : si des données sont disponibles dans GA4, met à jour avec les nouvelles pages vues ; sinon, réinitialise les pages vues à 0.

3-3. Configuration du Cron (mise à jour des données)

Dans les paramètres d'événements déclencheurs, configurez le déclencheur Cron sur 0 15 * * *. Il s'exécute chaque jour à minuit et reflète les données jusqu'à la veille dans microCMS.

Étape 4 : création et configuration de Cloudflare Workers (déclencheur de build)

4-1. Création du Worker et configuration des variables d'environnement

Afin de séparer la mise à jour des données du déploiement, créez un Worker dédié au build du site, nommé par exemple build-trigger.

Dans les variables d'environnement, définissez CLOUDFLARE_PAGES_BUILD_HOOK avec l'URL du build hook de Cloudflare Pages.

4-2. Code du Worker (déclencheur de build)

Implémentez le code qui envoie une requête POST au build hook de Pages.

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. Configuration Cron (déploiement du site)

Dans les événements déclencheurs de la configuration, définissez le déclencheur Cron sur 10 15 * * *. Comme nous voulons démarrer l'exécution après la fin du traitement de l'étape 3, nous lançons le déploiement avec une marge de 10 minutes, soit tous les jours à 00h10.

Conclusion

Bien qu'il soit moins réactif que la mise en place d'une API dédiée au classement, un délai d'environ 10 minutes reste, à mon avis, tout à fait acceptable. Les principaux avantages sont le rapport coût-efficacité, puisque aucune API n'est consommée et l'exécution reste dans le plan gratuit de Cloudflare, ainsi qu'une architecture simple entièrement gérée sur Cloudflare !

Auteur de cet article

Passé du DTP au Web, il maîtrise aussi bien le balisage que le frontend, la direction de projet et l'accessibilité : un véritable « sage de la technique ». Actif chez Liberogic depuis ses débuts, il est aujourd'hui une référence interne incontournable. Ces derniers temps, il explore avec passion l'optimisation par l'IA : « Ne pourrait-on pas davantage s'appuyer sur l'IA pour l'accessibilité ? » Sa technique comme sa réflexion continuent d'évoluer.

Ayumu Futamata

Spécialiste certifié en accessibilité web IAAP (WAS) / Intégrateur balisage / Ingénieur frontend / Directeur web

Voir les articles de cet auteur

Études de cas