From 46eac298e211c4fcc5708f3ce17a9ddcb9244a3e Mon Sep 17 00:00:00 2001
From: Florent Tapponnier
Date: Sat, 11 Jul 2026 22:02:58 +0200
Subject: [PATCH 1/2] compare SEO: live-data pair gate, noindex thin pairs,
brand name casing
---
src/app/compare/[slug]/page.tsx | 31 +++++++++++++++++
src/app/compare/page.tsx | 25 +++++++++++---
src/app/sitemap.ts | 24 +++++++------
src/lib/compare-pairing.ts | 3 +-
src/lib/compare/adhoc-pairs.ts | 46 ++++++++++++++++++-------
src/lib/providers.ts | 60 +++++++++++++++++++++++++++++++--
6 files changed, 156 insertions(+), 33 deletions(-)
diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx
index fccc0a52..44e13966 100644
--- a/src/app/compare/[slug]/page.tsx
+++ b/src/app/compare/[slug]/page.tsx
@@ -198,6 +198,34 @@ 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;
+ const thin = 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 +239,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() {
{pairs.map((pair) => {
- const a = canonicalize(pair.providerA);
- const b = canonicalize(pair.providerB);
+ const a = {
+ slug: canonicalize(pair.providerA).slug,
+ name: displayName(pair.providerA),
+ };
+ const b = {
+ slug: canonicalize(pair.providerB).slug,
+ name: displayName(pair.providerB),
+ };
return (
-
{
lastModByPairSlug.set(pair.slug, pairLastMod(pair.providerA, pair.providerB));
}
- // Ad-hoc pair generation with hybrid threshold (SEO audit 2026-07-05):
+ // Ad-hoc pair generation with hybrid threshold (SEO audit 2026-07-08,
+ // tightened from 2026-07-05):
//
- // - Both providers in BRAND_WHITELIST → emit at ≥ 1 shared bench.
- // - Otherwise → emit only at ≥ 3 shared benches.
+ // - Both providers in BRAND_WHITELIST → emit at ≥ 2 shared benches
+ // with live data for both.
+ // - Otherwise → emit only at ≥ 3 live shared benches.
//
- // Previous rule (≥ 1 for any pair) generated 4938 ad-hoc URLs, 97% of
- // which were near-duplicate templates over obscure providers. Bing
- // indexed 2 pages out of 5093 and penalised the whole domain via
- // thin-content signal. The hybrid keeps every commercial "X vs Y"
- // pair a user might actually search for (helius-vs-mobula,
- // alchemy-vs-moralis, chain-vs-chain, perp-vs-perp — 223 pairs) and
- // adds only genuinely rich non-brand pairs (3 with ≥ 3 shared).
- // Total: 226 ad-hoc + 21 curated ≈ 247 URLs.
+ // History: the original ≥ 1 for any pair generated 4938 ad-hoc URLs,
+ // 97% near-duplicate templates; Bing indexed 2 of 5093 and penalised
+ // the domain. The 07-05 hybrid (brand ≥ 1 structural) still emitted
+ // ~70 single-shared-bench pairs, many rendering "awaiting live
+ // measurements" (~2 KB). The ≥ 2 live floor matches the render-time
+ // noindex gate on /compare/[slug], so no sitemap URL is noindexed.
+ // Dropped pairs still render via the ad-hoc resolver (no 404s from
+ // bench-page or product-page cross links).
// Enumeration shared with /compare (src/lib/compare/adhoc-pairs.ts)
// so every advertised pair URL is also internally linked — sitemap-
// only pairs were flagged as orphan pages (Ahrefs, 2026-07-08).
diff --git a/src/lib/compare-pairing.ts b/src/lib/compare-pairing.ts
index f6e8d031..35b4d0a1 100644
--- a/src/lib/compare-pairing.ts
+++ b/src/lib/compare-pairing.ts
@@ -83,7 +83,8 @@ async function buildCompareGraphRaw(): Promise {
* new provider or appearance flows in within minutes of being live. */
const buildCompareGraphCached = unstable_cache(
buildCompareGraphRaw,
- ["compare-graph-v1"],
+ // v2: provider names switched to spec brand casing (providers v6).
+ ["compare-graph-v2"],
{ revalidate: 300, tags: ["benchmarks"] },
);
diff --git a/src/lib/compare/adhoc-pairs.ts b/src/lib/compare/adhoc-pairs.ts
index 6da14fb2..067b55dd 100644
--- a/src/lib/compare/adhoc-pairs.ts
+++ b/src/lib/compare/adhoc-pairs.ts
@@ -11,34 +11,54 @@ const HEX_SLUG_RE = /^0x[0-9a-f]{4,}$/i;
* sitemap-only pairs (e.g. binance-vs-tenderly) as orphan pages when
* the two generators drifted.
*
- * Hybrid threshold (SEO audit 2026-07-05): both providers in
- * BRAND_WHITELIST → emit at >= 1 shared bench; otherwise >= 3. See the
- * sitemap for the thin-content rationale (Bing penalty, 2026-07-05).
+ * Hybrid threshold (SEO audit 2026-07-08, tightened from 2026-07-05):
+ * both providers in BRAND_WHITELIST → emit at >= 2 shared benches with
+ * LIVE data for both; otherwise >= 3. "Live" mirrors liveResults():
+ * availability !== "unavailable" and p50 > 0, read straight off the
+ * already-loaded appearance results (no extra Prom round trip).
+ *
+ * Rationale for the bump from >= 1 structural to >= 2 live: the old
+ * rule emitted ~70 pairs whose single shared bench often had no live
+ * data ("awaiting live measurements", ~2 KB body). The >= 2 live floor
+ * also matches the render-time noindex gate on /compare/[slug] (pairs
+ * resolving < 2 benches with values are noindexed), so the sitemap
+ * never advertises a URL the page itself marks noindex. Tradeoff: a
+ * brand pair with exactly one live shared bench (real data, one card)
+ * leaves the sitemap and internal link lists too. It still renders at
+ * its URL via the ad-hoc resolver, so nothing 404s; it just stops
+ * being advertised until a second shared bench goes live.
*/
export type AdHocPair = { a: string; b: string; slug: string };
export function adHocPairs(profiles: ProviderProfile[]): AdHocPair[] {
- const benchesBySlug = new Map>();
+ // bench slug → live flag, per provider. A shared bench only counts
+ // toward the threshold when it is live for BOTH providers.
+ const liveBenchesBySlug = new Map>();
for (const p of profiles) {
if (HEX_SLUG_RE.test(p.slug)) continue;
if (CHAIN_BY_SLUG.has(p.slug)) continue;
- const benches = new Set(p.appearances.map((a) => a.benchmark.slug));
- if (benches.size >= 1) benchesBySlug.set(p.slug, benches);
+ const live = new Set();
+ for (const a of p.appearances) {
+ if (a.result.availability === "unavailable") continue;
+ if (a.result.ms.p50 <= 0) continue;
+ live.add(a.benchmark.slug);
+ }
+ if (live.size >= 1) liveBenchesBySlug.set(p.slug, live);
}
const out: AdHocPair[] = [];
- const slugList = [...benchesBySlug.keys()].sort();
+ const slugList = [...liveBenchesBySlug.keys()].sort();
for (let i = 0; i < slugList.length; i += 1) {
const aSlug = slugList[i];
- const aBenches = benchesBySlug.get(aSlug)!;
+ const aBenches = liveBenchesBySlug.get(aSlug)!;
for (let j = i + 1; j < slugList.length; j += 1) {
const bSlug = slugList[j];
- const bBenches = benchesBySlug.get(bSlug)!;
- let shared = 0;
- for (const s of aBenches) if (bBenches.has(s)) shared += 1;
+ const bBenches = liveBenchesBySlug.get(bSlug)!;
+ let sharedLive = 0;
+ for (const s of aBenches) if (bBenches.has(s)) sharedLive += 1;
const bothBrand = BRAND_WHITELIST.has(aSlug) && BRAND_WHITELIST.has(bSlug);
- const threshold = bothBrand ? 1 : 3;
- if (shared < threshold) continue;
+ const threshold = bothBrand ? 2 : 3;
+ if (sharedLive < threshold) continue;
out.push({ a: aSlug, b: bSlug, slug: `${aSlug}-vs-${bSlug}` });
}
}
diff --git a/src/lib/providers.ts b/src/lib/providers.ts
index a908d794..cd65780e 100644
--- a/src/lib/providers.ts
+++ b/src/lib/providers.ts
@@ -87,6 +87,31 @@ const CANONICAL_NAMES: Record = {
// brand casing.
gram: "Gram",
tonapi: "TonAPI",
+ // Brand casings the title-case fallback butchers ("Drpc", "Usdc",
+ // "Meowrpc" — SEO audit 2026-07-08). Profiles built from bench specs
+ // now inherit the spec's provider `name` field, but canonicalize() is
+ // also called with a bare slug (compare hub labels, OG images, search
+ // index) where no spec name is in scope, so the map stays the source
+ // of truth for these. Casing matches the bench spec `name` fields.
+ drpc: "dRPC",
+ "1rpc": "1RPC",
+ meowrpc: "MeowRPC",
+ usdc: "USDC",
+ usdt: "USDT",
+ dai: "DAI",
+ usde: "USDe",
+ fdusd: "FDUSD",
+ dydx: "dYdX",
+ okx: "OKX",
+ gmx: "GMX",
+ grvt: "GRVT",
+ cctp: "CCTP",
+ lifi: "LI.FI",
+ debridge: "deBridge",
+ zksync: "zkSync Era",
+ "near-intents": "NEAR Intents",
+ edgex: "edgeX",
+ sui: "Sui",
};
function titleCaseSlug(s: string): string {
@@ -103,6 +128,25 @@ export function canonicalize(slug: string): { slug: string; name: string } {
return { slug: canon, name: CANONICAL_NAMES[canon] ?? titleCaseSlug(canon) };
}
+/** Display name for a profile, in priority order:
+ * 1. CANONICAL_NAMES override (editorial, wins over everything).
+ * 2. The bench spec's provider `name` field — specs carry proper
+ * brand casing (dRPC, 1RPC, USDC, dYdX v4) while the old
+ * title-case fallback rendered "Drpc" / "Usdc" in every title,
+ * H1, breadcrumb, and OG surface (SEO audit 2026-07-08). Only
+ * trusted when the result's own slug IS the canonical slug; an
+ * aliased sub-product (helius-sender) must not rename its parent.
+ * 3. Title-cased slug, last resort. */
+function profileDisplayName(
+ canonSlug: string,
+ r: ProviderResult,
+): string {
+ const override = CANONICAL_NAMES[canonSlug];
+ if (override) return override;
+ if (r.slug.toLowerCase() === canonSlug && r.name) return r.name;
+ return titleCaseSlug(canonSlug);
+}
+
export type ProviderAppearance = {
benchmark: Pick<
Benchmark,
@@ -329,6 +373,13 @@ async function buildProviders(): Promise {
}
existing.wins += winsEarned;
if (!existing.type && r.type) existing.type = r.type;
+ // Upgrade a title-case fallback name the moment any appearance
+ // supplies a proper spec name (first appearance can come from
+ // an alias, which never carries the canonical brand casing).
+ if (existing.name === titleCaseSlug(key)) {
+ const better = profileDisplayName(key, r);
+ if (better !== existing.name) existing.name = better;
+ }
if (chainsLed.length > 0) {
existing.chainWins = existing.chainWins ?? {};
for (const c of chainsLed) {
@@ -338,7 +389,7 @@ async function buildProviders(): Promise {
} else {
byKey.set(key, {
slug: canon.slug,
- name: canon.name,
+ name: profileDisplayName(canon.slug, r),
type: r.type,
appearances: [appearance],
wins: winsEarned,
@@ -448,7 +499,7 @@ function isBlacklistedSlug(slug: string): boolean {
const PERP_VENUE_SEED = [
{ slug: "drift", name: "Drift" },
{ slug: "vertex", name: "Vertex" },
- { slug: "edgex", name: "EdgeX" },
+ { slug: "edgex", name: "edgeX" },
{ slug: "extended", name: "Extended" },
{ slug: "aevo", name: "Aevo" },
{ slug: "pacifica", name: "Pacifica" },
@@ -482,7 +533,10 @@ const buildProvidersCached = unstable_cache(
// newly identified HL frontends (invo, bitget-wallet, defi-saver,
// unitywallet, marsgo, metamask-alt). Without the bump, /products/
// 404s for them even after the YAML + builders.json updates landed.
- ["providers-v5"],
+ // v6: profile names now inherit bench spec casing (dRPC, USDC, dYdX)
+ // instead of title-cased slugs. Bump flushes stale "Drpc"/"Usdc"
+ // names from every title/H1/breadcrumb surface.
+ ["providers-v6"],
{ revalidate: 60, tags: ["benchmarks"] },
);
From 04b9edcd600f8521ad6d58a9e74fbd45765f2728 Mon Sep 17 00:00:00 2001
From: Florent Tapponnier
Date: Sat, 11 Jul 2026 22:25:56 +0200
Subject: [PATCH 2/2] compare: curated pairs exempt from thin noindex gate
---
src/app/compare/[slug]/page.tsx | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx
index 44e13966..a74c8b1d 100644
--- a/src/app/compare/[slug]/page.tsx
+++ b/src/app/compare/[slug]/page.tsx
@@ -224,7 +224,13 @@ export async function generateMetadata({
const liveSharedCount = sharedSlugsForMeta.filter(
(s) => !excluded.has(s) && aLive.has(s) && bLive.has(s),
).length;
- const thin = liveSharedCount < 2;
+ // 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