From 13b27ed5a0b4f495a3d41e4b8b083a71266912c7 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 26 Jun 2026 15:16:52 +0200 Subject: [PATCH 1/3] fix(seo): drop dead /alternatives links from perp/PM venue footers + dead compare pair Two SEO regressions feeding 404s into Google's crawl: 1. /products/ for perp/PM venues without an alternatives YAML (lighter, manifold, vertex, grvt, ostium, variational, pacifica, aevo, edgex, paradex, extended, myriad, limitless, aster) hardcoded a /alternatives/ footer link in PmVenueSection and PerpVenueSection. Now gated on loadAlternativeSlugs() membership so only venues with an actual YAML emit the link. 2. /compare hub rendered jupiter-vs-raydium even though raydium has no bench appearances, so /compare/jupiter-vs-raydium 404s downstream. Pre-filter COMPARE_PAIRS against getProvider() so dead pairs disappear from the list and the JSON-LD ItemList until both sides resolve. Plus a defensive guard in buildAlternativesReverseMap() so the emitted alt.slug is always a known live YAML, even if the alt list source ever changes shape. --- src/app/compare/page.tsx | 21 +++++++++++++++---- src/components/perp-venue-section.tsx | 30 ++++++++++++++++++++------- src/components/pm-venue-section.tsx | 27 +++++++++++++++++------- src/lib/related-providers.ts | 9 ++++++++ 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx index 017116bc..5ce1cbbc 100644 --- a/src/app/compare/page.tsx +++ b/src/app/compare/page.tsx @@ -1,8 +1,8 @@ import type { Metadata } from "next"; import Link from "next/link"; import { ArrowUpRight } from "lucide-react"; -import { COMPARE_PAIRS } from "@/data/compare-pairs"; -import { canonicalize } from "@/lib/providers"; +import { COMPARE_PAIRS, type ComparePair } from "@/data/compare-pairs"; +import { canonicalize, getProvider } from "@/lib/providers"; import { buildCompareGraph } from "@/lib/compare-pairing"; import { ProviderLogo } from "@/components/provider-logo"; import { CompareSelector } from "@/components/compare-selector"; @@ -24,9 +24,22 @@ export const metadata: Metadata = pageMetadata({ }); export default async function ComparePage() { - const pairs = [...COMPARE_PAIRS].sort((a, b) => - a.slug.localeCompare(b.slug), + // Filter out pairs whose providers don't resolve in the live registry + // (e.g. raydium has no bench appearances yet, so /compare/jupiter-vs-raydium + // 404s downstream). Without this, the hub leaks dead links into both + // the rendered list and the JSON-LD ItemList that Google ingests. + const resolved = await Promise.all( + COMPARE_PAIRS.map(async (pair) => { + const [a, b] = await Promise.all([ + getProvider(pair.providerA), + getProvider(pair.providerB), + ]); + return a && b ? pair : null; + }), ); + const pairs = resolved + .filter((p): p is ComparePair => p !== null) + .sort((a, b) => a.slug.localeCompare(b.slug)); const graph = await buildCompareGraph(); const jsonld = { diff --git a/src/components/perp-venue-section.tsx b/src/components/perp-venue-section.tsx index 9cfc3292..1cb8623f 100644 --- a/src/components/perp-venue-section.tsx +++ b/src/components/perp-venue-section.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { loadAlternativeSlugs } from "@/lib/alternatives"; import { fetchPerpVenueKpis } from "@/lib/perp-venue-data"; import { PerpVenueKpiStrip } from "@/components/perp-venue-kpi-strip"; import { @@ -50,8 +51,17 @@ export async function PerpVenueSection({ cohortSlug, benchRows, }: PerpVenueSectionProps) { - const kpis = await fetchPerpVenueKpis(cohortSlug); + const [kpis, altSlugs] = await Promise.all([ + fetchPerpVenueKpis(cohortSlug), + loadAlternativeSlugs(), + ]); const externalHost = safeHost(externalUrl); + // Only emit /alternatives/ when the YAML actually exists for + // this venue. Most perp cohort venues (lighter, vertex, grvt, ostium, + // variational, pacifica, aevo, edgex, paradex, extended, aster, ...) + // ship without an alternatives page and the hardcoded link was + // leaking 404s into Google's crawl from /products/. + const hasAlternativesPage = altSlugs.includes(slug); const measured = benchRows.filter( (r) => r.value !== null && r.rank !== null, @@ -121,13 +131,17 @@ export async function PerpVenueSection({ All perp venues · - - {name} alternatives - - · + {hasAlternativesPage && ( + <> + + {name} alternatives + + · + + )} when the YAML actually exists for + // this venue. PM venues without an alternatives page (lighter, + // manifold, myriad, limitless, ...) would otherwise leak a 404 link + // into Google's crawl from /products/. + const hasAlternativesPage = altSlugs.includes(slug); const categoryShares = deriveCategoryShares(topMarkets); const externalHost = safeHost(externalUrl); @@ -157,13 +164,17 @@ export async function PmVenueSection({ {name} product page · - - {name} alternatives - - · + {hasAlternativesPage && ( + <> + + {name} alternatives + + · + + )} > { const alternatives = await loadAllAlternatives(); + // Defensive: only ever emit /alternatives/ for a slug we + // actually have a live YAML for. loadAllAlternatives() already + // filters to status=live, so today this is equivalent to the + // alternatives array, but the explicit Set makes the invariant + // local and survives any future refactor where alt-like records + // start flowing in from a different source (bench metadata, + // materialize worker, ...). + const liveSlugs = new Set(alternatives.map((a) => a.slug)); const benches = await Promise.all( alternatives.map((alt) => loadBenchmark(alt.benchmark, { chain: alt.chain }).then((bench) => ({ @@ -135,6 +143,7 @@ async function buildAlternativesReverseMap(): Promise< const map = new Map(); for (const { alt, bench } of benches) { if (!bench) continue; + if (!liveSlugs.has(alt.slug)) continue; const altTargetSlug = alt.target_product .toLowerCase() .replace(/\s+/g, "-"); From dd92f3596fe00ad6a15b61a99a98253e6f8f965c Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 26 Jun 2026 15:16:53 +0200 Subject: [PATCH 2/3] fix(seo): blacklist hex builder slugs and shared composite filter Drop 15 anonymous 0x... HL builder slugs and 5 dead composite slugs from /products listing, sitemap and individual /products/ routes. DEAD_COMPOSITE_SLUGS now lives in providers.ts as the single source of truth; related-providers.ts imports it. Bumps providers cache key v3->v4. --- src/app/hyperliquid/page.tsx | 12 +++++++-- src/lib/providers.ts | 48 ++++++++++++++++++++++++++++++++---- src/lib/related-providers.ts | 22 +++-------------- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/src/app/hyperliquid/page.tsx b/src/app/hyperliquid/page.tsx index a827d8ec..215c60f3 100644 --- a/src/app/hyperliquid/page.tsx +++ b/src/app/hyperliquid/page.tsx @@ -62,6 +62,14 @@ export default async function HyperliquidHubPage() { // are referenced in the page's plain prose and stayed out of this list // intentionally; mixing two ItemLists with overlapping naming would // confuse Search Console's rich-results validator more than it helps. + // Drop anonymous builder addresses (raw 0x... slugs with no human + // brand) from the ItemList. Those /products/ URLs now 404 per the + // SEO blacklist in @/lib/providers (thin auto-generated titles, no + // editorial body, no inbound brand demand) so emitting them in + // schema.org ItemList would point Search Console at known 404s. + const linkableFrontends = frontends + ? frontends.rows.filter((r) => !/^0x[a-f0-9]+$/.test(r.slug.toLowerCase())) + : []; const itemListLd = frontends ? { "@context": "https://schema.org", @@ -69,8 +77,8 @@ export default async function HyperliquidHubPage() { name: "Hyperliquid frontends leaderboard", description: "Hyperliquid frontends tracked by OpenChainBench, ranked by 30-day builder revenue.", - numberOfItems: frontends.rows.length, - itemListElement: frontends.rows.slice(0, 100).map((r, i) => ({ + numberOfItems: linkableFrontends.length, + itemListElement: linkableFrontends.slice(0, 100).map((r, i) => ({ "@type": "ListItem", position: i + 1, url: `https://openchainbench.com/products/${r.slug}`, diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 202879eb..1e1b3084 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -379,7 +379,7 @@ async function buildProviders(): Promise { } const profiles = Array.from(byKey.values()).filter( - (p) => !DEAD_COMPOSITE_SLUGS.has(p.slug), + (p) => !isBlacklistedSlug(p.slug), ); profiles.sort((a, b) => { if (a.wins !== b.wins) return b.wins - a.wins; @@ -395,9 +395,14 @@ async function buildProviders(): Promise { // now ranks the parent provider only (codex, predexon), but stale // materialize-worker snapshots can recreate ghost /products/ // pages with no data. Hard-block so getProvider() returns undefined and -// the route 404s cleanly. Keep in sync with the same set in -// related-providers.ts. -const DEAD_COMPOSITE_SLUGS = new Set([ +// the route 404s cleanly. +// +// Exported as the single source of truth. related-providers.ts imports +// this set instead of duplicating the literal — PR #728 originally +// shipped two copies and they immediately drifted (composite URLs kept +// leaking into sitemap.xml + /products listing even after the page +// itself 404'd, see SEO audit 2026-06-24). +export const DEAD_COMPOSITE_SLUGS = new Set([ "codex-kalshi", "codex-polymarket", "predexon-kalshi", @@ -405,6 +410,29 @@ const DEAD_COMPOSITE_SLUGS = new Set([ "predexon-polymarket", ]); +// Anonymous Hyperliquid builder addresses with no human-readable brand. +// The hyperliquid-frontends bench tracks 104 builders, ~15 of which have +// never been claimed by a frontend operator — they surface as raw +// `0x40e9d9fe...` slugs. Each one mints a thin /products/ page +// titled "0x... review and live performance benchmarks", with no editorial +// body and no inbound brand search demand. Google indexed the lot as +// thin content and they were burning PageRank from the products hub. +// +// Filter at the profile and resolution layers so: +// - `getProviders()` drops them before /products/index renders the table +// - `getProviderSlugs()` drops them before sitemap.ts emits URLs +// - `getProvider()` returns undefined so the per-slug page 404s and +// Google deindexes via 404 signal +// The HL bench page itself still lists every builder in its leaderboard +// (that's the bench's job); only the dedicated /products/ route is +// suppressed. +const HEX_ADDRESS_SLUG = /^0x[a-f0-9]+$/; + +function isBlacklistedSlug(slug: string): boolean { + const lc = slug.toLowerCase(); + return DEAD_COMPOSITE_SLUGS.has(lc) || HEX_ADDRESS_SLUG.test(lc); +} + // Cohort venues that should have a /products/ page even when no // bench in benchmarks/ ranks them. Imported as a flat list here to // avoid a build-time cycle with the perp-stats module. @@ -436,7 +464,12 @@ const PERP_VENUE_SEED = [ * bump this key when the buildProviders() output shape changes. */ const buildProvidersCached = unstable_cache( buildProviders, - ["providers-v3"], + // v4 bump: hex builder addresses + composite zombies are now filtered + // at buildProviders() exit. Without this bump, Vercel ISR would keep + // serving the v3 list (containing 0x... slugs) until the `benchmarks` + // tag fires, leaving thin /products/ URLs in the sitemap for the + // next 60s window after deploy. + ["providers-v4"], { revalidate: 60, tags: ["benchmarks"] }, ); @@ -450,6 +483,11 @@ export async function getProviderSlugs(): Promise { } export async function getProvider(slug: string): Promise { + // Early reject blacklisted slugs (dead composites + anonymous hex + // builder addresses) so /products/ 404s cleanly even if a stale + // cache layer ever produces a profile. Mirrors the buildProviders() + // filter; cheap second guard against eventual-consistency leaks. + if (isBlacklistedSlug(slug)) return undefined; const profiles = await getProviders(); // Aliased URLs (e.g. /products/helius-sender, /products/btc-usd) resolve // to the canonical profile so backward-compat links keep working. diff --git a/src/lib/related-providers.ts b/src/lib/related-providers.ts index e321921a..cbd90e14 100644 --- a/src/lib/related-providers.ts +++ b/src/lib/related-providers.ts @@ -19,7 +19,10 @@ import { cache } from "react"; import { unstable_cache } from "next/cache"; -import { canonicalize, getProviders } from "@/lib/providers"; +// DEAD_COMPOSITE_SLUGS lives in providers.ts (the canonical source of +// /products/ eligibility); re-imported here so both modules share +// one list and we never drift the two literals out of sync. +import { canonicalize, DEAD_COMPOSITE_SLUGS, getProviders } from "@/lib/providers"; import { canonicalPairSlug } from "@/lib/compare-pairing-shared"; import { loadAllAlternatives } from "@/lib/alternatives"; import { loadBenchmark } from "@/lib/spec"; @@ -48,23 +51,6 @@ const COMPARE_CAP = 12; /** Hard cap on how many alternatives lists we render. */ const ALTERNATIVES_CAP = 8; -/** - * Zombie slugs from pre-PR #647 pm-api-latency, when each aggregator was - * split per venue (codex-kalshi, codex-polymarket, predexon-*). The bench - * now collapses everything into the parent provider slug (codex, - * predexon) but the materialize worker's per-slug Redis blobs survive - * for the TTL window after each schema change, and old slugs leak into - * compare candidates / alternatives links until they expire. Filtering - * here keeps the UI clean across the eventual-consistency gap. - */ -const DEAD_COMPOSITE_SLUGS = new Set([ - "codex-kalshi", - "codex-polymarket", - "predexon-kalshi", - "predexon-limitless", - "predexon-polymarket", -]); - /** * Returns the providers that share at least one live benchmark with the * given product, sorted by shared-bench count descending then by name. From 2147040bed8246810512b12b74afb78d42d6e63c Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 26 Jun 2026 15:23:01 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(seo):=20scope=20ton=E2=86=92gram=20redi?= =?UTF-8?q?rect=20to=20benches=20with=20gram=20explainer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catch-all /benchmarks/:slug/ton redirect fired on every bench, including aggregator-head-lag/gas-estimation/rpc-capabilities/etc that have no per_chain_explainer for gram. Result: 308 chain into 404, which Ahrefs + Google flag as a soft-404 anti-pattern. Now explicit per-bench: l1-finality, network-fees, wallet-labels-coverage. --- next.config.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/next.config.ts b/next.config.ts index 5fec030c..25e4f89a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -178,9 +178,24 @@ const nextConfig: NextConfig = { destination: "/chains/gram", permanent: true, }, + // Scoped to the 3 benches that ship a gram (formerly ton) + // per_chain_explainer. The previous catch-all `/benchmarks/:slug/ton` + // fired for every bench, redirecting non-gram benches to a /gram + // path that 404s (e.g. aggregator-head-lag/ton → aggregator-head-lag/gram → 404), + // which Ahrefs and Google flag as a soft-404 redirect chain. { - source: "/benchmarks/:slug/ton", - destination: "/benchmarks/:slug/gram", + source: "/benchmarks/l1-finality/ton", + destination: "/benchmarks/l1-finality/gram", + permanent: true, + }, + { + source: "/benchmarks/network-fees/ton", + destination: "/benchmarks/network-fees/gram", + permanent: true, + }, + { + source: "/benchmarks/wallet-labels-coverage/ton", + destination: "/benchmarks/wallet-labels-coverage/gram", permanent: true, }, ...chainRedirects,