Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ npm run dev
2. In Claude Code, follow `pipeline/prompts/01_implement.md`, `02_seo.md`, `03_translate.md` in order
3. `cd site && npm run validate && npm run build`

## Analytics: GA4 `calculate` ranking

Tool usage events (`calculate`, `copy_result`, `preset_click`) carry the params
`tool_slug`, `locale`, `category` (plus legacy `tool`). To report on them:

1. GA4 Admin > Data display > Custom definitions: register **event-scoped custom
dimensions** `tool_slug`, `locale`, `category` (event parameter names identical).
Data is only collected from the registration date onwards.
2. Grant the service account *Viewer* on the GA4 property, then:

```bash
cd site
GA4_PROPERTY_ID=123456789 GOOGLE_APPLICATION_CREDENTIALS=/path/sa.json \
npm run report:calculate -- --days 28 --limit 50 # --by category | --json
```

## Documentation

- [ARCHITECTURE.md](./docs/ARCHITECTURE.md)
Expand Down
1 change: 1 addition & 0 deletions site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"test": "vitest run",
"test:watch": "vitest",
"indexnow": "tsx scripts/indexnow.ts",
"report:calculate": "node scripts/calculate-ranking.mjs",
"postbuild": "node scripts/check-built-titles.mjs && node scripts/check-built-canonical.mjs && node scripts/check-built-descriptions.mjs"
},
"dependencies": {
Expand Down
130 changes: 130 additions & 0 deletions site/scripts/calculate-ranking.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* GA4 Data API から `calculate` イベントをツール別に集計し、直近 28 日のランキングを出す。
*
* 前提(重要): GA4 Admin > Data display > Custom definitions に
* event-scoped custom dimension "tool_slug" (event parameter: tool_slug)
* を登録しておくこと。未登録だと `customEvent:tool_slug` は "(not set)" だけになる。
* 併せて "locale" / "category" も登録すると --by locale / --by category で切れる。
* (登録前のイベントは遡って集計されない。登録日以降のデータのみ)
*
* 認証: サービスアカウント JWT(RS256) → OAuth2 token → analyticsdata v1beta runReport。
* 00_集客統合/growth/ga4-snapshot.mjs と同じパターン。秘密情報はすべて env から読む。
*
* env:
* GA4_PROPERTY_ID 数値の property ID(例 123456789)
* GOOGLE_APPLICATION_CREDENTIALS サービスアカウント JSON のパス
* または GA4_SA_JSON サービスアカウント JSON 文字列(CI 向け)
* GA4_SA_EMAIL + GA4_SA_KEY client_email / private_key を個別に渡す形式も可
*
* 使い方:
* node scripts/calculate-ranking.mjs # 28日 / tool_slug 上位 50
* node scripts/calculate-ranking.mjs --days 7 --limit 20
* node scripts/calculate-ranking.mjs --by category # tool_slug の代わりに category で集計
* node scripts/calculate-ranking.mjs --json # JSON 出力(他スクリプトへパイプ)
*/
import { createSign } from "node:crypto";
import fs from "node:fs";

const args = process.argv.slice(2);
function opt(name, def) {
const i = args.indexOf(`--${name}`);
return i >= 0 && args[i + 1] ? args[i + 1] : def;
}
const DAYS = Number(opt("days", "28"));
const LIMIT = Number(opt("limit", "50"));
const BY = opt("by", "tool_slug"); // tool_slug | locale | category
const JSON_OUT = args.includes("--json");
const EVENT = opt("event", "calculate");

const propertyId = process.env.GA4_PROPERTY_ID;
if (!propertyId) {
console.error("GA4_PROPERTY_ID が未設定です。GA4 Admin > Property settings の数値 ID を指定してください。");
process.exit(1);
}

function loadServiceAccount() {
if (process.env.GA4_SA_EMAIL && process.env.GA4_SA_KEY) {
return { client_email: process.env.GA4_SA_EMAIL, private_key: process.env.GA4_SA_KEY.replace(/\\n/g, "\n") };
}
if (process.env.GA4_SA_JSON) return JSON.parse(process.env.GA4_SA_JSON);
const p = process.env.GOOGLE_APPLICATION_CREDENTIALS;
if (p && fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, "utf8"));
console.error(
"サービスアカウント認証情報が見つかりません。GOOGLE_APPLICATION_CREDENTIALS / GA4_SA_JSON / GA4_SA_EMAIL+GA4_SA_KEY のいずれかを設定してください。",
);
process.exit(1);
}

function b64url(input) {
return Buffer.from(input).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}

async function getAccessToken(sa) {
const now = Math.floor(Date.now() / 1000);
const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const payload = b64url(
JSON.stringify({
iss: sa.client_email,
scope: "https://www.googleapis.com/auth/analytics.readonly",
aud: "https://oauth2.googleapis.com/token",
iat: now,
exp: now + 3600,
}),
);
const signer = createSign("RSA-SHA256");
signer.update(`${header}.${payload}`);
const signature = signer.sign(sa.private_key, "base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
const jwt = `${header}.${payload}.${signature}`;
const res = 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 (!res.ok) throw new Error(`token: ${res.status} ${await res.text()}`);
return (await res.json()).access_token;
}

async function runReport(token, body) {
const res = await fetch(`https://analyticsdata.googleapis.com/v1beta/properties/${propertyId}:runReport`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`runReport: ${res.status} ${await res.text()}`);
return res.json();
}

const token = await getAccessToken(loadServiceAccount());
const report = await runReport(token, {
dateRanges: [{ startDate: `${DAYS}daysAgo`, endDate: "today" }],
dimensions: [{ name: `customEvent:${BY}` }],
metrics: [{ name: "eventCount" }, { name: "totalUsers" }],
dimensionFilter: { filter: { fieldName: "eventName", stringFilter: { matchType: "EXACT", value: EVENT } } },
orderBys: [{ metric: { metricName: "eventCount" }, desc: true }],
limit: LIMIT,
});

const rows = (report.rows ?? []).map((r) => ({
[BY]: r.dimensionValues[0].value,
events: Number(r.metricValues[0].value),
users: Number(r.metricValues[1].value),
}));

if (JSON_OUT) {
console.log(JSON.stringify({ event: EVENT, days: DAYS, by: BY, rows }, null, 2));
} else {
console.log(`# ${EVENT} by ${BY} — last ${DAYS} days (top ${LIMIT})`);
if (rows.length === 0) {
console.log("(no rows) — custom dimension が未登録か、登録後にまだイベントが溜まっていない可能性があります。");
}
const w = Math.max(8, ...rows.map((r) => String(r[BY]).length));
console.log(`${"#".padStart(3)} ${BY.padEnd(w)} ${"events".padStart(8)} ${"users".padStart(8)}`);
rows.forEach((r, i) => {
console.log(`${String(i + 1).padStart(3)} ${String(r[BY]).padEnd(w)} ${String(r.events).padStart(8)} ${String(r.users).padStart(8)}`);
});
const notSet = rows.find((r) => r[BY] === "(not set)");
if (notSet && rows.length <= 1) {
console.log("\n注意: すべて (not set) です。GA4 Admin で custom dimension を登録してください。");
}
}
9 changes: 5 additions & 4 deletions site/src/components/tools/ToolFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Breadcrumbs } from "@/components/layout/Breadcrumbs";
import { RelatedServices } from "@/components/affiliates/RelatedServices";
import { SisterSiteCta } from "@/components/cross/SisterSiteCta";
import { ToolInteractionTracker } from "./ToolInteractionTracker";
import { TrustBox } from "./TrustBox";
import { ShareBar } from "@/components/tools/ShareBar";
import { FavoriteButton } from "@/components/tools/FavoriteButton";
import { siteConfig } from "@/lib/config";
Expand Down Expand Up @@ -53,7 +54,9 @@ export function ToolFrame({ meta, title, description, related, children, article
押し出されていた(個別ツール着地の engaged≒0% の構造要因)。
まず道具を見せ、広告は結果の下に置く(2026-08-22)。 */}
<section className="mt-6 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<ToolInteractionTracker slug={meta.slug}>{children}</ToolInteractionTracker>
<ToolInteractionTracker slug={meta.slug} locale={locale} category={meta.category}>
{children}
</ToolInteractionTracker>
</section>

<AdBelowResult />
Expand Down Expand Up @@ -130,9 +133,7 @@ export function ToolFrame({ meta, title, description, related, children, article
{meta.category === "finance" && <SisterSiteCta />}

{(meta.category === "finance" || meta.category === "health") && (
<p className="mt-8 rounded-md border-l-2 border-slate-300 bg-slate-50 px-3 py-2 text-xs text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
{t(meta.category === "finance" ? "tool.financeDisclaimer" : "tool.medicalDisclaimer")}
</p>
<TrustBox category={meta.category} sources={meta.sources} updatedAt={meta.updatedAt} />
)}

</div>
Expand Down
22 changes: 17 additions & 5 deletions site/src/components/tools/ToolInteractionTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,19 @@ import { trackCalculate, trackCopyResult, trackPresetClick } from "@/lib/analyti
* 送出は1マウントにつき各1回まで。ツールは1キーストロークごとに再計算する
* ため、間引かないと1着地で数十イベントになり engagement 指標が壊れる。
*/
export function ToolInteractionTracker({ slug, children }: { slug: string; children: ReactNode }) {
export function ToolInteractionTracker({
slug,
locale,
category,
children,
}: {
slug: string;
/** ページのロケール。GA4 custom dimension `locale` に載せる。 */
locale?: string;
/** ToolMeta.category。GA4 custom dimension `category` に載せる。 */
category?: string;
children: ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const sent = useRef({ calculate: false, copy: false, preset: false });

Expand All @@ -32,7 +44,7 @@ export function ToolInteractionTracker({ slug, children }: { slug: string; child
function onInput() {
if (sent.current.calculate) return;
sent.current.calculate = true;
trackCalculate({ tool: slug });
trackCalculate({ tool: slug, locale, category });
}

function onClick(e: Event) {
Expand All @@ -46,7 +58,7 @@ export function ToolInteractionTracker({ slug, children }: { slug: string; child
if (/copy|コピー|copiar|kopieren|copier|копи|复制|複製|복사/.test(label)) {
if (sent.current.copy) return;
sent.current.copy = true;
trackCopyResult({ tool: slug });
trackCopyResult({ tool: slug, locale, category });
return;
}

Expand All @@ -56,7 +68,7 @@ export function ToolInteractionTracker({ slug, children }: { slug: string; child
if (/preset|sample|example|プリセット|サンプル|例|ejemplo|beispiel|exemple|пример|示例|範例|예시/.test(label)) {
if (sent.current.preset) return;
sent.current.preset = true;
trackPresetClick({ tool: slug });
trackPresetClick({ tool: slug, locale, category });
}
}

Expand All @@ -68,7 +80,7 @@ export function ToolInteractionTracker({ slug, children }: { slug: string; child
el.removeEventListener("change", onInput);
el.removeEventListener("click", onClick);
};
}, [slug]);
}, [slug, locale, category]);

return <div ref={ref}>{children}</div>;
}
73 changes: 73 additions & 0 deletions site/src/components/tools/TrustBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useLocale, useTranslations } from "next-intl";
import { Link } from "@/lib/i18n/navigation";
import type { ToolCategory, ToolMeta } from "@/lib/tools/types";

/**
* 健康 / 金融ツール向けの信頼性ボックス(YMYL 対応)。
*
* 「このツールについて / 医療・金融助言ではない / 出典 / 最終レビュー日」を
* 1つのカードにまとめて表示する。ToolFrame から category が health | finance の
* ときだけ描画されるため、223 本の各ツールを触らずに一括適用できる。
* 文言は messages/<locale>.json の tool.trust.* と既存の
* tool.medicalDisclaimer / tool.financeDisclaimer を使う。
*/
export function TrustBox({
category,
sources,
updatedAt,
}: {
category: ToolCategory;
sources?: ToolMeta["sources"];
updatedAt: string;
}) {
const t = useTranslations("tool");
const locale = useLocale();
const isFinance = category === "finance";
const reviewed = new Date(updatedAt);
const reviewedLabel = Number.isNaN(reviewed.getTime())
? updatedAt
: new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "numeric" }).format(reviewed);

return (
<section
aria-labelledby="trust-box-title"
className="mt-8 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400"
>
<h2 id="trust-box-title" className="text-base font-semibold text-slate-800 dark:text-slate-200">
{t("trust.title")}
</h2>
<p className="mt-2">{t(isFinance ? "trust.aboutFinance" : "trust.aboutHealth")}</p>
<p className="mt-2 rounded-md border-l-2 border-amber-400 bg-amber-50 px-3 py-2 text-xs text-slate-700 dark:border-amber-500 dark:bg-amber-950/30 dark:text-slate-300">
<strong className="font-semibold">{t("trust.notAdvice")}: </strong>
{t(isFinance ? "financeDisclaimer" : "medicalDisclaimer")}
</p>
<dl className="mt-3 grid gap-2 text-xs sm:grid-cols-[auto_1fr] sm:gap-x-4">
<dt className="font-semibold text-slate-700 dark:text-slate-300">{t("trust.sources")}</dt>
<dd>
{sources && sources.length > 0 ? (
<ul className="list-disc space-y-1 pl-4">
{sources.map((s) => (
<li key={s.url}>
<a href={s.url} rel="nofollow noopener" target="_blank" className="text-brand-600 hover:underline">
{s.label}
</a>
</li>
))}
</ul>
) : (
<span>{t("trust.noSources")}</span>
)}
</dd>
<dt className="font-semibold text-slate-700 dark:text-slate-300">{t("trust.lastReviewed")}</dt>
<dd>
<time dateTime={updatedAt}>{reviewedLabel}</time>
</dd>
</dl>
<p className="mt-3 text-xs">
<Link href="/about" className="text-brand-600 hover:underline">
{t("methodologyLink")}
</Link>
</p>
</section>
);
}
28 changes: 24 additions & 4 deletions site/src/lib/analytics/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,19 @@ import { siteConfig } from "@/lib/config";
* ガードは PageViewTracker と同一: gaId 未設定 / SSR / gtag 未ロードでは黙って no-op。
* Consent Mode v2 は gtag.tsx 側で既定 denied(EEA) を設定済みのため、
* ここで同意状態を再判定する必要はない(gtag 側が保留・破棄を行う)。
*
* パラメータ(2026-09-07): `tool_slug` / `locale` / `category` を追加。
* GA4 で集計するには Admin > Custom definitions に event-scoped custom dimension
* として登録が必要(未登録だと Data API の customEvent:tool_slug が空になる)。
* 旧 `tool` パラメータは既存レポート互換のため当面併送する。
*/
export type ToolEventParams = {
/** ツールの slug。GA4 では custom dimension として使う。 */
/** ツールの slug。GA4 では custom dimension `tool_slug` として使う。 */
tool: string;
/** ページのロケール(en / ja / ar ...)。custom dimension `locale`。 */
locale?: string;
/** ToolMeta.category(health / finance / ...)。custom dimension `category`。 */
category?: string;
/** 任意の補足(モード名・プリセット名など)。カーディナリティを抑えるため短い識別子のみ。 */
label?: string;
};
Expand All @@ -27,17 +36,28 @@ function emit(name: string, params: Record<string, unknown>): void {
window.gtag("event", name, params);
}

/** ToolEventParams を GA4 送出用の params に正規化する(undefined は落とす)。 */
function toParams(p: ToolEventParams): Record<string, unknown> {
return {
tool: p.tool,
tool_slug: p.tool,
...(p.locale ? { locale: p.locale } : {}),
...(p.category ? { category: p.category } : {}),
...(p.label ? { label: p.label } : {}),
};
}

/** ツールが有効な入力で結果を算出したとき。ツール1着地につき最大1回に間引くこと。 */
export function trackCalculate(p: ToolEventParams): void {
emit("calculate", { tool: p.tool, ...(p.label ? { label: p.label } : {}) });
emit("calculate", toParams(p));
}

/** 結果(またはコード/URL)をクリップボードへコピーしたとき。 */
export function trackCopyResult(p: ToolEventParams): void {
emit("copy_result", { tool: p.tool, ...(p.label ? { label: p.label } : {}) });
emit("copy_result", toParams(p));
}

/** プリセット/サンプル/例示ボタンを押したとき。 */
export function trackPresetClick(p: ToolEventParams): void {
emit("preset_click", { tool: p.tool, ...(p.label ? { label: p.label } : {}) });
emit("preset_click", toParams(p));
}
11 changes: 10 additions & 1 deletion site/src/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,16 @@
"search": "ابحث في {n} أداة…",
"searchNoResults": "لم يتم العثور على أدوات. جرّب كلمة مفتاحية أخرى.",
"searchResults": "تم العثور على {n} أداة",
"copyResultLink": "نسخ رابط النتيجة"
"copyResultLink": "نسخ رابط النتيجة",
"trust": {
"title": "حول هذه الحاسبة",
"aboutHealth": "تستخدم هذه الحاسبة معادلات وقيمًا مرجعية منشورة من المصادر المذكورة أدناه. النتائج تقديرية وتعتمد على دقة مدخلاتك، ولا تأخذ في الاعتبار تاريخك الطبي الفردي.",
"aboutFinance": "تستخدم هذه الحاسبة معادلات مالية قياسية. النتائج تقديرية فقط؛ وتختلف الأسعار والرسوم والضرائب وقواعد التقريب الفعلية حسب مزود الخدمة والبلد.",
"notAdvice": "تنبيه مهم",
"sources": "المصادر",
"noSources": "تستند إلى معادلات قياسية شائعة الاستخدام. راجع صفحة المنهجية لمعرفة كيفية بناء الأدوات ومراجعتها.",
"lastReviewed": "آخر مراجعة"
}
},
"consent": {
"title": "ملفات تعريف الارتباط والإعلانات",
Expand Down
Loading
Loading