Muchos clientes nos piden mostrar un ranking de artículos, ¿verdad?
En un sitio Jamstack, lo habitual para mostrar el ranking de artículos de microCMS es crear una API dedicada.
Sin embargo, si ya no queda cuota de API y aun así quieres implementarlo, también existe el enfoque de escribir directamente el número de visitas (PV) en el contenido existente.
En esta ocasión, presentamos un ejemplo de implementación con ese enfoque en un sitio publicado con SSG usando microCMS + Cloudflare Pages.
Aunque Cloudflare se ha dado a conocer de una forma algo curiosa entre el público general, esto también es algo que probamos hace poco en nuestro propio sitio, a petición de clientes. https://www.liberogic.jp/topics/
Casos en los que este enfoque resulta adecuado
- Cuando se quiere mostrar un ranking de unos 10 elementos en la barra lateral o en el pie de página
- Cuando no se requiere información en tiempo real
- Cuando la cuota de API de microCMS no tiene margen suficiente
- Si desea implementarlo de forma sencilla mediante generación de sitios estáticos (SSG)
Paso 1: Preparación de microCMS (ampliación del esquema y control de Webhooks)
1-1. Ampliación del esquema de la API
Añada dos campos para el ranking al endpoint de artículos existente.
pageView(número): almacena el total acumulado de visualizaciones de los últimos 30 días obtenido de GA4.lastUpdatedPV(fecha y hora): registra la fecha y hora de la última actualización de las visualizaciones.
1-2. Configuración de permisos de la clave de API
En la configuración de la clave de API que va a utilizar, marque PATCH.
1-3. Configuración de Webhooks (solución para evitar compilaciones repetidas)
Para evitar que se produzcan compilaciones repetidas cada vez que se actualiza el número de visualizaciones de cada artículo, desmarque "Publicación de contenido (operación mediante API)" en los desencadenadores del Webhook.
Paso 2: Configuración de autenticación en Google Cloud Console
Para acceder desde Cloudflare Workers, prepare una cuenta de servicio y una API de datos.
2-1. Habilitar la API
En el proyecto de Google Cloud Console, habilite la Google Analytics Data API.
2-2. Crear cuenta de servicio y clave
Cree una cuenta de servicio para la integración con GA4 y copie su dirección de correo electrónico.
En la pestaña de claves, seleccione "Agregar clave" y luego "Crear nueva clave". Elija el tipo de clave JSON, cree la clave y descárguela.
2-3. Otorgar permisos de GA4
En "Administración de acceso" de la propiedad de GA4, agregue como usuario la dirección de correo electrónico anotada en el paso 2-2 y asígnele al menos el rol de "Lector".
Paso 3: Crear y configurar Cloudflare Workers (obtención de datos de GA4)
3-1. Creación del Worker y configuración de variables de entorno
Cree un nuevo Worker con un nombre como ga4-ranking-updater.
Configure las variables de entorno desde los ajustes. Registre las claves como secretos.
MICROCMS_API_KEY | Clave de 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 | Valor de private_key del JSON, eliminando |
3-2. Worker de actualización de datos de GA4
En "Editar código", reemplace el contenido de worker.js con lo siguiente.
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 del Worker:
- Obtención previa del mapa: se obtiene de antemano el mapa de
idyslugde todos los artículos de 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 los hay, las vistas de página se restablecen a
0.
3-3. Configuración de Cron (actualización de datos)
En el evento de activación de la configuración, establezca el disparador de Cron en 0 15 * * *. Se ejecuta todos los días a las 0:00 y refleja en microCMS los datos hasta el día anterior.
Paso 4: Creación y configuración de Cloudflare Workers (disparador de compilación)
4-1. Creación de Workers y configuración de variables de entorno
Para separar la actualización de datos y el despliegue, cree un Worker exclusivo para la compilación del sitio con un nombre como build-trigger.
En las variables de entorno, establezca en CLOUDFLARE_PAGES_BUILD_HOOK la URL del build hook de Cloudflare Pages.
4-2. Código del Worker (disparador de compilación)
Implementaremos el código que envía una solicitud POST al 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. Configuración de Cron (despliegue del sitio)
En la configuración del evento desencadenante, establecemos el disparador Cron como 10 15 * * *. Como queremos ejecutarlo después de que se complete el proceso del paso 3, iniciamos el despliegue con margen, a las 00:10 todos los días, 10 minutos después.
Conclusión
Es cierto que la inmediatez es menor que al preparar una API para el ranking, pero creo que un retraso de unos 10 minutos es perfectamente aceptable. Además, no consume API y puede ejecutarse dentro del plan gratuito de Cloudflare; creo que sus grandes ventajas son la rentabilidad y una arquitectura sencilla que se completa íntegramente en Cloudflare.
Pasó del DTP al mundo web y, sin darse cuenta, domina el maquetado, el frontend, la dirección de proyectos y la accesibilidad: un auténtico "maestro de todas las técnicas". Desde los inicios de Liberogic ha desempeñado múltiples funciones y hoy es una auténtica enciclopedia viviente dentro de la empresa. Últimamente se ha obsesionado con la optimización mediante prompts, preguntándose: «¿No podríamos apoyarnos más en la IA para la accesibilidad?». Su tecnología y su forma de pensar siguen evolucionando.
Ayumu Futamata
Especialista en Accesibilidad Web certificado por IAAP (WAS) / Ingeniero de marcado / Ingeniero frontend / Director web