0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GA4 Data APIで「よく読まれている記事」をJSON化して、Astroの静的サイトに出す

0
Posted at

静的サイト(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. サービスアカウントの準備

  1. Google Cloudでサービスアカウントを作成し、JSONキーを取得(リポジトリ内ならgitignore済みディレクトリに置く)
  2. 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個の"手順書"で動いている

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?