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
37 changes: 37 additions & 0 deletions src/app/compare/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
25 changes: 20 additions & 5 deletions src/app/compare/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand All @@ -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}`,
})),
};
Expand Down Expand Up @@ -101,8 +110,14 @@ export default async function ComparePage() {
</p>
<ul className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-3">
{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 (
<li key={pair.slug}>
<Link
Expand Down
24 changes: 13 additions & 11 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,19 +371,21 @@ async function buildFullSitemap(): Promise<MetadataRoute.Sitemap> {
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).
Expand Down
3 changes: 2 additions & 1 deletion src/lib/compare-pairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ async function buildCompareGraphRaw(): Promise<CompareGraph> {
* 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"] },
);

Expand Down
46 changes: 33 additions & 13 deletions src/lib/compare/adhoc-pairs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Set<string>>();
// 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<string, Set<string>>();
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<string>();
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}` });
}
}
Expand Down
60 changes: 57 additions & 3 deletions src/lib/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,31 @@ const CANONICAL_NAMES: Record<string, string> = {
// 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 {
Expand All @@ -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,
Expand Down Expand Up @@ -329,6 +373,13 @@ async function buildProviders(): Promise<ProviderProfile[]> {
}
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) {
Expand All @@ -338,7 +389,7 @@ async function buildProviders(): Promise<ProviderProfile[]> {
} else {
byKey.set(key, {
slug: canon.slug,
name: canon.name,
name: profileDisplayName(canon.slug, r),
type: r.type,
appearances: [appearance],
wins: winsEarned,
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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/<slug>
// 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"] },
);

Expand Down
Loading