Topics

Cómo generar rankings de artículos de microCMS desde GA4 sin consumir API

  • column

Es común que los clientes nos pidan mostrar rankings de artículos.
En sitios Jamstack que usan microCMS, lo habitual es crear un API especializado para mostrar el ranking de artículos.
Pero si no tienes más espacio de API y aún así quieres implementarlo, también puedes escribir datos de vistas directamente en el contenido de artículos existentes como alternativa.

Esta vez te presentamos un ejemplo de implementación con este enfoque en un sitio publicado con microCMS + Cloudflare Pages mediante SSG.

Cloudflare se ha hecho conocido de una manera inusual entre el público general, pero la verdad es que es algo que nuestros clientes pidieron y que estamos probando en nuestro propio sitio hace poco. https://www.liberogic.jp/topics/

Cuándo es adecuado este enfoque

  • Cuando quieres mostrar un ranking de alrededor de TOP 10 en la barra lateral o pie de página
  • Cuando no se requiere actualizaciones en tiempo real
  • Cuando no tienes espacio disponible en tu cuota de API de microCMS
  • Si deseas implementar de forma simple con generación de sitios estáticos (SSG)

Paso 1: Preparación de microCMS (extensión de esquema y control de Webhook)

1-1. Extensión del esquema de API

Agregaremos dos campos para el ranking al endpoint de artículos existente.

  • pageView (número): almacena el total de vistas acumuladas en los últimos 30 días obtenidas de GA4.
  • lastUpdatedPV (fecha y hora): registra la fecha y hora en que se actualizaron por última vez las vistas.

1-2. Configuración de permisos de clave de API

Marca la casilla PATCH en la configuración de la clave de API que utilizarás.

1-3. Configuración de Webhook (estrategia para evitar múltiples compilaciones)

Para evitar que se desencadenen múltiples compilaciones cada vez que se actualiza el recuento de vistas de cada artículo, desmarca la casilla "Publicación de contenido (operaciones mediante API)" en los disparadores del Webhook.

Paso 2: Configuración de autenticación en Google Cloud Console

Para acceder desde Cloudflare Workers, prepara una cuenta de servicio y una API de datos.

2-1. Habilitación de la API

En el proyecto de Google Cloud Console, habilita la Google Analytics Data API.

2-2. Creación de cuenta de servicio y clave

Crea una cuenta de servicio para la integración de GA4 y copia la dirección de correo electrónico.

En la pestaña de claves, desde "Agregar clave" selecciona "Crear clave nueva", créala con tipo JSON y descárgala.

2-3. Asignación de permisos de GA4

En "Gestión de acceso" de la propiedad de GA4, agrega la dirección de correo electrónico anotada en 2-2 como usuario y asigna permisos de "Lector" o superior.

Paso 3: Creación y configuración de Cloudflare Workers (obtención de datos de GA4)

3-1. Creación de Workers y configuración de variables de entorno

Crea un nuevo Worker con un nombre como ga4-ranking-updater.

Configura las variables de entorno desde la configuración. Registra las claves como secretos.

MICROCMS_API_KEY

Clave API de microCMS

MICROCMS_API_URL

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

GA4_PROPERTY_ID

ID de propiedad de GA4

GA4_SERVICE_ACCOUNT_CREDENTIALS

Contenido completo del archivo de clave JSON

GA4_PRIVATE_KEY_BASE64

El valor private_key del JSON con -----BEGIN PRIVATE KEY-----, -----END PRIVATE KEY----- y eliminados

3-2. Worker de actualización de datos de GA4

Edita el código y actualiza el contenido de worker.js de la siguiente manera.

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

Lógica principal del código Worker:

  1. Obtención previa de mapa: se obtiene previamente un mapa de los id y slug de todos los artículos en microCMS.
  2. Obtención de datos de GA4: se obtienen las vistas de página acumuladas de los últimos 30 días.
  3. Lógica de actualización: si hay datos en GA4, se actualiza con las nuevas vistas de página; si no, se restablecen las vistas de página a 0.

3-3. Configuración de Cron (actualización de datos)

En el evento desencadenador de la configuración, establece el desencadenador de Cron en 0 15 * * *. Se ejecuta todos los días a las 0:00 y refleja los datos hasta el día anterior en microCMS.

Paso 4: Creación y configuración de Cloudflare Workers (desencadenador de compilación)

4-1. Creación de Workers y configuración de variables de entorno

Para separar la actualización de datos del despliegue, crea un Workers dedicado para la compilación del sitio con un nombre como build-trigger.

En las variables de entorno, configura CLOUDFLARE_PAGES_BUILD_HOOK con la URL del gancho de compilación de Cloudflare Pages.

4-2. Código de Worker (desencadenador de compilación)

Implementaremos código que envía una solicitud POST al webhook de compilación 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. Configuración de Cron (despliegue del sitio)

En el evento desencadenador de la configuración, estableceremos el desencadenador de Cron en 10 15 * * *. Como queremos que se ejecute después de que se complete el procesamiento del paso 3, lo haremos con cierto margen: despliegue a las 00:10 de cada día (10 minutos después).

Conclusión

Aunque carece de inmediatez en comparación con preparar una API dedicada para el ranking, un retraso de alrededor de 10 minutos es completamente aceptable. El beneficio de no consumir cuota de API, poder ejecutarse dentro del plan gratuito de Cloudflare, y contar con una estructura simple que se resuelve completamente dentro de Cloudflare son grandes ventajas.

Autor de este artículo

Desde que saltó del mundo del DTP a la web, ha dominado markups, frontend, dirección y accesibilidad, convirtiéndose en el "sabio técnico" de la empresa. Ha sido un pilar multifacético desde los inicios de Liberogic y es ahora una referencia indispensable dentro de la organización. Últimamente está explorando eficiencias basadas en prompts, preguntándose «¿podríamos delegar más trabajo de accesibilidad a la IA?». Tanto su tecnología como su pensamiento siguen evolucionando.

Futsan

Especialista en web accesibilidad certificado por IAAP (WAS) / Ingeniero de markups / Ingeniero frontend / Director web

Ver artículos de este staff

Casos de Estudio