diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index fccc0a52..a74c8b1d 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -198,6 +198,40 @@ export async function generateMetadata({ const sharedCount = sharedSlugsForMeta.filter((s) => !excluded.has(s)).length; const benchWord = sharedCount === 1 ? "benchmark" : "benchmarks"; + // Thin-content gate (SEO audit 2026-07-08): a pair whose shared + // benches carry live data for both providers on fewer than 2 of them + // renders either "awaiting live measurements" or a single card. + // Those pages stay reachable (internal links + stale index entries + // must not 404) but are marked noindex so direct hits stop counting + // against the domain. Mirrors the >= 2 live-shared emission floor in + // src/lib/compare/adhoc-pairs.ts so the sitemap never advertises a + // noindexed URL. Live rule matches liveResults(): not "unavailable" + // and p50 > 0, read off the already-loaded appearances. + const aLive = new Set( + a.appearances + .filter( + (x) => x.result.availability !== "unavailable" && x.result.ms.p50 > 0, + ) + .map((x) => x.benchmark.slug), + ); + const bLive = new Set( + b.appearances + .filter( + (x) => x.result.availability !== "unavailable" && x.result.ms.p50 > 0, + ) + .map((x) => x.benchmark.slug), + ); + const liveSharedCount = sharedSlugsForMeta.filter( + (s) => !excluded.has(s) && aLive.has(s) && bLive.has(s), + ).length; + // Curated pairs (explicit benchmarks list in COMPARE_PAIRS) are + // hand-picked head-term targets like usdc-vs-usdt and carry editorial + // framing beyond the ledger, so they stay indexable even with a + // single live shared bench. The gate only applies to combinatorial + // ad hoc pairs. + const isCurated = !!pair.benchmarks; + const thin = !isCurated && liveSharedCount < 2; + // Meta description: unique per pair via the shared-count + provider // names + date. Kills the identical duplicate-content signal that had // Bing indexing 2 of 4938 compare pages. Also cites "as of DATE" for @@ -211,6 +245,9 @@ export async function generateMetadata({ return { title, description, + // follow stays on so PageRank keeps flowing through the body links + // (both provider pages, parent benches) even while deindexed. + ...(thin ? { robots: { index: false, follow: true } } : {}), alternates: { canonical: url }, openGraph: { title, diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx index 7b955085..80f3c5c4 100644 --- a/src/app/compare/page.tsx +++ b/src/app/compare/page.tsx @@ -49,11 +49,20 @@ export default async function ComparePage() { // enumeration as sitemap.ts (shared lib), minus the curated pairs // already rendered above. const curatedSlugs = new Set(pairs.map((p) => p.slug)); - const morePairs = adHocPairs(await getProviders()) + const profiles = await getProviders(); + // Prefer the profile display name (carries the bench spec's brand + // casing: dRPC, 1RPC, USDC) over the canonicalize() title-case + // fallback, which rendered "Drpc vs Tenderly" style labels here. + const nameBySlug = new Map(profiles.map((p) => [p.slug, p.name])); + const displayName = (slug: string): string => { + const canon = canonicalize(slug); + return nameBySlug.get(canon.slug) ?? canon.name; + }; + const morePairs = adHocPairs(profiles) .filter((p) => !curatedSlugs.has(p.slug)) .map((p) => ({ ...p, - label: `${canonicalize(p.a).name} vs ${canonicalize(p.b).name}`, + label: `${displayName(p.a)} vs ${displayName(p.b)}`, })) .sort((a, b) => a.label.localeCompare(b.label)); @@ -64,7 +73,7 @@ export default async function ComparePage() { itemListElement: pairs.map((pair, i) => ({ "@type": "ListItem", position: i + 1, - name: `${canonicalize(pair.providerA).name} vs ${canonicalize(pair.providerB).name}`, + name: `${displayName(pair.providerA)} vs ${displayName(pair.providerB)}`, url: `${SITE.url}/compare/${pair.slug}`, })), }; @@ -101,8 +110,14 @@ export default async function ComparePage() {