静的サイト(Astro)のブログ一覧に「よく読まれている記事」ランキングを出したくなりました。クライアントサイドからGA4を直接叩く方式は認証情報の扱いも表示速度も厳しいので、ビルド前にNodeスクリプトでランキングJSONを生成し、リポジトリにコミットして、Astroがビルド時にimportする方式にしています。実際に運用している構成をまとめます。
全体の流れ
GA4 Data API(過去28日のページ別PV)
→ scripts/popular-posts.mjs が集計・正規化
→ src/data/popular-posts.json に書き出し(コミット対象)
→ src/pages/blog/index.astro がビルド時にimportして表示
静的サイトなのでランキングは自動更新されません。うちは週次レビューのタイミングでスクリプトを再実行し、JSONに差分があればコミット&プッシュして反映しています。「リアルタイム性を捨てて、実装と運用を軽くする」割り切りです。
1. サービスアカウントの準備
- Google Cloudでサービスアカウントを作成し、JSONキーを取得(リポジトリ内ならgitignore済みディレクトリに置く)
- GA4の管理画面 →「プロパティのアクセス管理」で、サービスアカウントのメールアドレスを閲覧者として追加
ここを忘れると403になります。エラーメッセージが不親切なので、スクリプト側で「サービスアカウントをGA4に閲覧者として追加したか確認」というヒントを出すようにしています。
2. runReportでページ別PVを取る
googleapis パッケージで Analytics Data API の runReport を叩きます。
import { google } from 'googleapis';
const auth = new google.auth.GoogleAuth({
keyFile: '.secrets/service-account.json',
scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
});
const data = google.analyticsdata({ version: 'v1beta', auth });
const res = await data.properties.runReport({
property: `properties/${propertyId}`,
requestBody: {
dateRanges: [{ startDate: '27daysAgo', endDate: 'today' }],
dimensions: [{ name: 'pagePath' }],
metrics: [{ name: 'screenPageViews' }],
dimensionFilter: {
filter: {
fieldName: 'pagePath',
stringFilter: { matchType: 'BEGINS_WITH', value: '/blog/' },
},
},
limit: 500,
orderBys: [{ desc: true, metric: { metricName: 'screenPageViews' } }],
},
});
小ネタ: プロパティIDを環境変数で渡し忘れたときのために、Admin APIの accountSummaries.list でアクセス可能なプロパティを自動解決するフォールバックを入れておくと運用が楽です。
3. パスの正規化がいちばんの本体
pagePath には記事以外(一覧・タグページ)や末尾スラッシュの揺れが混ざります。正規表現でslugを抽出し、同一slugをMapで合算します。
const bySlug = new Map();
for (const row of res.data.rows ?? []) {
const m = row.dimensionValues[0].value.match(/^\/blog\/([^/]+)\/?$/);
if (!m || m[1] === 'tag') continue; // 一覧・タグページを除外
const slug = m[1];
const pv = Number(row.metricValues[0].value);
bySlug.set(slug, (bySlug.get(slug) ?? 0) + pv);
}
const posts = [...bySlug.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([slug, pageviews]) => ({ slug, pageviews }));
書き出すJSONには生成日と集計期間も入れておくと、「このランキングいつのだっけ」が防げます。
{
"generatedAt": "2026-07-03",
"days": 28,
"posts": [{ "slug": "example-article", "pageviews": 123 }]
}
4. Astro側はimportするだけ
---
import popularData from '../../data/popular-posts.json';
import { getCollection } from 'astro:content';
const allPosts = await getCollection('blog');
const popularPosts = popularData.posts
.map((p) => allPosts.find((post) => post.slug === p.slug))
.filter(Boolean); // 削除済み記事のslugが残っていても落ちないように
---
JSONのslugと記事コレクションを突合して、実在する記事だけ表示するのがポイントです。記事を削除・リネームした後にJSONが古いままでもビルドが壊れません。
ハマりどころまとめ
- サービスアカウントをGA4側で閲覧者に追加し忘れて403
-
pagePathの揺れ(末尾スラッシュ・タグページ・クエリ付き)を正規化しないと同一記事が分裂する - 静的サイトでは再生成→コミットしない限りランキングは古いまま。定例作業(うちは週次レビュー)に組み込む
このサイトの運営は、こうした定型作業を手順書(スキル)としてClaude Codeに渡して回しています。全体像はブログに書いています: Claude Code Skillsの作り方|このサイト運営は11個の"手順書"で動いている