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 |
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:
- Obtención previa de mapa: se obtiene previamente un mapa de los
idyslugde todos los artículos en microCMS. - Obtención de datos de GA4: se obtienen las vistas de página acumuladas de los últimos 30 días.
- 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.
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