From 6dd9a4c5b4503551806881c9265d4a33a66806b0 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 5 Jul 2026 10:49:42 +0200 Subject: [PATCH 1/3] seo: purge thin compare pages + benchmark-first title on products & compare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bing WMT audit 2026-07-05: only 2 of 5093 sitemap URLs indexed. Root cause: 4938 ad-hoc /compare/ pages emitted at ≥1 shared bench threshold, producing near-duplicate templates that starved crawl budget and hurt domain trust. Changes: - sitemap: hybrid threshold. Brand-whitelist pairs emit at ≥1 shared, others at ≥3. Drops from 4938 → ~226 ad-hoc + 21 curated = ~247 URLs. Preserves every commercial X vs Y pair users actually search for (helius-vs-mobula, alchemy-vs-moralis, chain-vs-chain, perp-vs-perp). - compare-pairs: remove jupiter-vs-raydium (raydium has 0 bench appearances so /compare/jupiter-vs-raydium 404s at render). - products/[slug]: title now leads with 'Benchmark {year}' + head-term match on ' benchmark' queries. Meta description ends with 'As of YYYY-MM-DD' for LLM citation extractability (LLM-mediated discovery drives ~80% of Bing query traffic). - compare/[slug]: title now leads with '{a} vs {b} Benchmark {year}'. Meta description is unique per pair (shared bench count + date) killing the identical duplicate signal that had Bing skipping pages. --- src/app/compare/[slug]/page.tsx | 28 +++++++++++++++++-- src/app/products/[slug]/page.tsx | 21 ++++++++------ src/app/sitemap.ts | 26 +++++++++++------ src/data/compare-pairs.ts | 10 +++---- src/lib/compare/brand-whitelist.ts | 45 ++++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 25 deletions(-) create mode 100644 src/lib/compare/brand-whitelist.ts diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index 0a74ae36..51c212cd 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -178,9 +178,33 @@ export async function generateMetadata({ if (!hasSharedBenches(pair, a, b)) notFound(); const url = `${SITE.url}/compare/${pair.slug}`; - const title = `${a.name} vs ${b.name}: live benchmarks`; + + // SEO title carries the head-term shape ("X vs Y benchmark") plus + // current year (LLM extractability). Format leads with both provider + // names so Google's ~60-char SERP truncation keeps the intent-matching + // portion. The suffix "· OpenChainBench" is added by Next's title + // template so we don't spend chars on it here. + const currentYear = new Date().getUTCFullYear(); + const title = `${a.name} vs ${b.name} Benchmark ${currentYear}`; + + // Compute shared bench count from appearances (already loaded via + // hasSharedBenches above — cheap recomputation, avoids another Prom hit). + const aSlugs = new Set(a.appearances.map((x) => x.benchmark.slug)); + const bSlugs = new Set(b.appearances.map((x) => x.benchmark.slug)); + const excluded = new Set(pair.excludeBenchmarks ?? []); + const sharedSlugsForMeta = pair.benchmarks + ? pair.benchmarks.filter((s) => aSlugs.has(s) && bSlugs.has(s)) + : Array.from(aSlugs).filter((s) => bSlugs.has(s)); + const sharedCount = sharedSlugsForMeta.filter((s) => !excluded.has(s)).length; + const benchWord = sharedCount === 1 ? "benchmark" : "benchmarks"; + + // 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 + // LLM citations. + const isoDate = new Date().toISOString().split("T")[0]; const description = capDescription( - `${a.name} vs ${b.name} side by side on every shared OpenChainBench benchmark. Live measurements, identical layout, no verdict.`, + `${a.name} vs ${b.name} on ${sharedCount} shared OpenChainBench ${benchWord}. Live measurements, reproducible methodology. As of ${isoDate}.`, 158, ); diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index c51e8c60..0502ec5c 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -75,10 +75,13 @@ export async function generateMetadata({ const reg = getProviderRegistry(p.slug); // Meta title carries the head-term shape people search for when - // evaluating a provider ("helius review", "is dRPC reliable"). Kept - // short so Google's ~60-char SERP truncation never cuts the brand - // suffix that Next's title template appends (" · OpenChainBench"). - const title = `${p.name}: live benchmarks`; + // evaluating a provider. Format leads with the provider name + head-term + // "Benchmark" + current year (LLM extractability signal — dated content + // is cited more by ChatGPT/Perplexity/Copilot). Kept short so Google's + // ~60-char SERP truncation never cuts the brand suffix that Next's + // title template appends (" · OpenChainBench"). + const currentYear = new Date().getUTCFullYear(); + const title = `${p.name} Benchmark ${currentYear} — Live Performance Data`; // Description prefers the registry's curated one-liner, then falls back // to a numeric one summarizing competitive footprint. Either way the @@ -107,10 +110,12 @@ export async function generateMetadata({ ? `${stripInlineMarkdown(reg.description).replace(/[.!?]?$/, ".")} Live performance across ${benchCount} OpenChainBench ${benchWord}${winSuffix}.` : fallbackDescription; // Google truncates meta description at ~155 chars in the SERP snippet. - // Long registry descriptions plus the appended "Live performance ..." - // sentence routinely blew past 200 chars (Ahref flagged 130+ pages). - // Cap at 155 with word-boundary truncation. - const description = capDescription(rawDescription, 155); + // Reserve ~22 chars for the ISO date suffix so the concatenated string + // stays inside the cap even after appending "As of YYYY-MM-DD." + // (LLM extractability: dated content is cited more by ChatGPT / + // Perplexity / Copilot, which drive most of our Bing query traffic). + const isoDate = new Date().toISOString().split("T")[0]; + const description = `${capDescription(rawDescription, 130)} As of ${isoDate}.`; // When the resolved provider slug is actually a chain (e.g. /products/eth-usd // aliases to /products/ethereum which 308s to /chains/ethereum), point diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 59880391..cf0f7b6e 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { MetadataRoute } from "next"; import { getBenchmarks } from "@/data/benchmarks"; import { COMPARE_PAIRS } from "@/data/compare-pairs"; +import { BRAND_WHITELIST } from "@/lib/compare/brand-whitelist"; import { loadAllAlternatives } from "@/lib/alternatives"; import { loadAllAnswers } from "@/lib/answers"; import { CHAIN_BY_SLUG, CHAINS, getBenchmarksForChain } from "@/lib/chains"; @@ -313,14 +314,19 @@ async function buildFullSitemap(): Promise { priorityByPairSlug.set(pair.slug, 0.7); } - // Gate: a provider is "sitemap-eligible" as soon as it has ≥1 bench - // appearance. The initial pass required 2 live benches with p50>0 — - // way too strict because most cohort providers (HL builders, wallets, - // trading terminals) participate in a single bench (`hyperliquid-frontends` - // or a product-catalog listing) with rank data but no p50 latency. - // That filter dropped ~1500 valid pages Ahref had already indexed via - // internal cross-links, leaving them in "Indexed but not in sitemap" - // limbo. Relaxed: emit any pair with ≥1 shared bench appearance. + // Ad-hoc pair generation with hybrid threshold (SEO audit 2026-07-05): + // + // - Both providers in BRAND_WHITELIST → emit at ≥ 1 shared bench. + // - Otherwise → emit only at ≥ 3 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. const profiles = await safeLoad("providers", () => getProviders(), []); const benchesBySlug = new Map>(); for (const p of profiles) { @@ -339,7 +345,9 @@ async function buildFullSitemap(): Promise { const bBenches = benchesBySlug.get(bSlug)!; let shared = 0; for (const s of aBenches) if (bBenches.has(s)) shared += 1; - if (shared < 1) continue; + const bothBrand = BRAND_WHITELIST.has(aSlug) && BRAND_WHITELIST.has(bSlug); + const threshold = bothBrand ? 1 : 3; + if (shared < threshold) continue; const pairSlug = `${aSlug}-vs-${bSlug}`; if (emittedPairSlugs.has(pairSlug)) continue; emittedPairSlugs.add(pairSlug); diff --git a/src/data/compare-pairs.ts b/src/data/compare-pairs.ts index 786994d1..5fcf0bfe 100644 --- a/src/data/compare-pairs.ts +++ b/src/data/compare-pairs.ts @@ -169,12 +169,10 @@ export const COMPARE_PAIRS: ComparePair[] = [ providerB: "mobula", publishedAt: "2026-06-17", }, - { - slug: "jupiter-vs-raydium", - providerA: "jupiter", - providerB: "raydium", - publishedAt: "2026-06-17", - }, + // Removed 2026-07-05: raydium has zero bench appearances so the compare + // page 404s on hasSharedBenches. Re-add when raydium is measured in any + // OCB benchmark (Solana DEX aggregator or similar). + // { slug: "jupiter-vs-raydium", providerA: "jupiter", providerB: "raydium", publishedAt: "2026-06-17" }, { slug: "lifi-vs-mobula", providerA: "lifi", diff --git a/src/lib/compare/brand-whitelist.ts b/src/lib/compare/brand-whitelist.ts new file mode 100644 index 00000000..95272dcf --- /dev/null +++ b/src/lib/compare/brand-whitelist.ts @@ -0,0 +1,45 @@ +/** + * Providers with real search demand as brand-vs-brand comparisons. + * + * Sitemap emission rule (src/app/sitemap.ts): + * - Curated pairs (src/data/compare-pairs.ts): always emit. + * - Both providers in this whitelist: emit at ≥ 1 shared benchmark. + * - Otherwise: emit only at ≥ 3 shared benchmarks. + * + * Rationale: the old ≥ 1 threshold produced 4938 ad-hoc URLs, 97% of which + * were near-duplicate templates over obscure providers with no search + * intent. Bing indexed 2 pages out of 5093, penalising the whole domain. + * This whitelist keeps every commercial "X vs Y" comparison a user might + * actually search for (helius-vs-mobula, alchemy-vs-moralis, chain-vs-chain, + * perp-vs-perp) while dropping the templated garbage. + * + * Adding a provider here is cheap. Only add ones that (a) have a + * dedicated /products/ or /chains/ page, and (b) are named + * targets in provider outreach or already show up in query data. + */ +export const BRAND_WHITELIST: ReadonlySet = new Set([ + // Aggregators data + "mobula", "codex", "geckoterminal", "jupiter", "dune", "moralis", + "alchemy", "birdeye", + // RPC providers benched by OCB + "publicnode", "drpc", "1rpc", "tenderly", "helius", "nodies", + "lava", "meowrpc", "flashbots", "cloudflare", + // Perp DEXes + "hyperliquid", "lighter", "dydx", "aster", "paradex", "gmx", "vertex", + "ostium", "pacifica", "grvt", "extended", "edgex", + // Bridges + "debridge", "lifi", "relay", "across", "cctp", "near-intents", + // Prediction market venues + "polymarket", "kalshi", "manifold", "myriad", "limitless", + // CEX / centralized venues (perp funding, oracle deviation) + "binance", "coinbase", "okx", "bybit", + // Stablecoins + "usdc", "usdt", "dai", "usde", "fdusd", + // NFT & explorers + "opensea", "blockscout", + // HL frontends flagged as curated targets + "axiom", "phantom-perps", + // Chains (compare pages already work; whitelist keeps chain-vs-chain live) + "ethereum", "solana", "base", "arbitrum", "optimism", "polygon", + "avalanche", "sui", "monero", "ton", "bnb", "zksync", +]); From 46be62e342fad28930658db911bc724fbbb429bd Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 5 Jul 2026 11:03:04 +0200 Subject: [PATCH 2/3] seo: prose summary above the fold on /compare + /products Replaces the identical templated intro paragraph on both pages with a data-driven prose summary derived from live measurements. Kills the last piece of duplicate above-the-fold text so Bing sees genuinely unique substantive content per URL. /compare/[slug]: Before: 'Side by side OpenChainBench measurements. Identical layout, no editorial verdict...' (same on 247 pages) After: 'Codex leads on 2 of 4 shared benchmarks, Mobula on 2. Codex wins on aggregator-head-lag (128ms vs 195ms), wallet-labels- coverage (42.8% vs 38.1%). Mobula wins on metadata-coverage (96.9% vs 89.1%), network-coverage (80 vs 42 chains).' /products/[slug]: Before: 'Moralis performance benchmarks, live across 2 categories. Reproducible measurements, open methodology.' (same on 104 pages) After: 'Moralis is measured across 2 live OpenChainBench benchmarks, with 1 #1 finish: NFT collection metadata (ranks #1, 96.9% p50), Wallet labels coverage (ranks #2 of 5, 42.4% p50).' Falls back to a neutral sentence when the provider's p50 data is missing (cold ISR, harness restart) so cold pages don't render a lie. --- src/app/compare/[slug]/page.tsx | 66 +++++++++++++++++++++++++++++--- src/app/products/[slug]/page.tsx | 32 ++++++++++++++-- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index 51c212cd..223a8377 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -290,6 +290,63 @@ function decideWinner( return aP50 < bP50 ? "a" : "b"; } +/** Build a data-driven prose summary of the head-to-head. Emitted above + * the fold so Google/Bing get substantive, unique text per pair instead + * of the identical template paragraph that used to sit here (which was + * a big contributor to Bing indexing only 2 of ~5000 URLs — SEO audit + * 2026-07-05). Every sentence is derived from live measurements, no + * editorial claim. Falls back to a minimal statement when p50 data is + * missing (cold ISR, harness restart) so we never emit a lie. */ +function buildComparisonProse( + shared: SharedBench[], + aName: string, + bName: string, +): string { + if (shared.length === 0) return ""; + const aWinTitles: string[] = []; + const bWinTitles: string[] = []; + const aWinLines: string[] = []; + const bWinLines: string[] = []; + let ties = 0; + + for (const s of shared) { + const aP50 = s.aResult.p50; + const bP50 = s.bResult.p50; + if (aP50 <= 0 || bP50 <= 0) continue; + const aVal = fmtUnit(aP50, s.unit); + const bVal = fmtUnit(bP50, s.unit); + if (s.aggregateWinner === "a") { + aWinTitles.push(s.title); + aWinLines.push(`${s.title} (${aVal} vs ${bVal})`); + } else if (s.aggregateWinner === "b") { + bWinTitles.push(s.title); + bWinLines.push(`${s.title} (${bVal} vs ${aVal})`); + } else { + ties += 1; + } + } + + const total = aWinTitles.length + bWinTitles.length + ties; + if (total === 0) { + // No live data yet — return a neutral sentence rather than the old + // templated intro so the meta description + title remain the only + // duplicate-adjacent text on cold-cache pages. + return `${aName} vs ${bName} on ${shared.length} shared OpenChainBench ${shared.length === 1 ? "benchmark" : "benchmarks"}, awaiting live measurements.`; + } + + const parts: string[] = []; + parts.push( + `${aName} leads on ${aWinTitles.length} of ${total} shared benchmarks, ${bName} on ${bWinTitles.length}${ties > 0 ? ` (${ties} tied)` : ""}.`, + ); + if (aWinLines.length > 0) { + parts.push(`${aName} wins on ${aWinLines.slice(0, 4).join(", ")}.`); + } + if (bWinLines.length > 0) { + parts.push(`${bName} wins on ${bWinLines.slice(0, 4).join(", ")}.`); + } + return parts.join(" "); +} + /** Load the per-dimension breakdown for one shared bench against one * axis. Resolves each dimension value to a filtered Benchmark via * loadBenchmark, then picks both providers' results. Drops rows where @@ -639,6 +696,8 @@ export default async function ComparePage({ ]), }; + const comparisonProse = buildComparisonProse(shared, a.name, b.name); + return (