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
8 changes: 8 additions & 0 deletions eslint.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,14 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// Standalone sub-apps deployed independently (own package.json + Railway
// config). Each has its own build, lint and typecheck pipeline; scanning
// them from the main frontend's lint pass caused every unrelated PR to
// fail on their pre-existing warnings (unescaped entities, no-explicit-any,
// react-hooks/set-state-in-effect) that the sub-app maintainers can fix
// in their own dedicated PRs.
"infrastructure/**",
"worker/**",
]),
]);

Expand Down
94 changes: 87 additions & 7 deletions src/app/compare/[slug]/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
);

Expand DownExpand Up@@ -266,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
Expand DownExpand Up@@ -615,6 +696,8 @@ export default async function ComparePage({
]),
};

const comparisonProse = buildComparisonProse(shared, a.name, b.name);

return (
<main className="mx-auto max-w-5xl px-6 pt-10 pb-16 sm:pt-14">
<script
Expand DownExpand Up@@ -651,11 +734,8 @@ export default async function ComparePage({
{b.name}
</h1>
<p className="mt-3 max-w-2xl text-base text-ink-soft leading-snug">
Side by side OpenChainBench measurements. Identical layout, no
editorial verdict, the live data leads. Each panel surfaces
the aggregate plus the chain and region breakdowns when the
underlying bench exposes them, straight from the Prometheus
queries that drive the parent benchmark pages.
{comparisonProse ||
`${a.name} vs ${b.name} on ${shared.length} shared OpenChainBench ${shared.length === 1 ? "benchmark" : "benchmarks"}. Live measurements, reproducible methodology, per-chain and per-region breakdowns straight from the Prometheus queries driving the parent benchmark pages.`}
</p>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-ink-muted">
<Link
Expand Down
53 changes: 41 additions & 12 deletions src/app/products/[slug]/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -170,6 +175,33 @@ export default async function ProviderPage({
return a.benchmark.title.localeCompare(b.benchmark.title);
});

// Data-driven prose summary of the provider's OCB standing. Replaces the
// identical templated intro paragraph that used to sit above the fold
// and made every /products/* page look near-duplicate to Bing (SEO audit
// 2026-07-05: only 2 of ~5000 pages indexed). Each sentence is derived
// from live measurements — no editorial claim.
const rankedAppearances = sorted.filter(
(a) => a.rank > 0 && a.result.ms.p50 > 0,
);
const topLines: string[] = [];
for (const a of rankedAppearances.slice(0, 4)) {
const p50Str = fmtUnit(a.result.ms.p50, a.benchmark.unit);
const rankStr = a.rank === 1 ? "ranks #1" : `ranks #${a.rank} of ${a.totalRanked}`;
topLines.push(`${a.benchmark.title} (${rankStr}, ${p50Str} p50)`);
}
const proseParts: string[] = [];
if (topLines.length > 0) {
proseParts.push(
`${p.name} ${topLines.length === 1 ? "is measured on" : "is measured across"} ${p.appearances.length} live OpenChainBench ${p.appearances.length === 1 ? "benchmark" : "benchmarks"}${p.wins > 0 ? `, with ${p.wins} #1 ${p.wins === 1 ? "finish" : "finishes"}` : ""}:`,
);
proseParts.push(`${topLines.join(", ")}.`);
} else {
proseParts.push(
`${p.name} performance benchmarks, live across ${p.appearances.length} ${p.appearances.length === 1 ? "category" : "categories"}. Reproducible measurements, open methodology.`,
);
}
const productProse = proseParts.join(" ");

// Embeddable badge cards. Scope rules, most exact source first:
//
// 1. Benches with a `rank_matrix_query` AND region dimensions use the
Expand DownExpand Up@@ -406,10 +438,7 @@ export default async function ProviderPage({
{p.name}
</h1>
<p className="mt-1 text-base text-ink-soft">
{p.name} performance benchmarks, live across{" "}
{p.appearances.length}{" "}
{p.appearances.length === 1 ? "category" : "categories"}.
Reproducible measurements, open methodology.
{productProse}
</p>
<p className="mt-2 font-sans text-[11px] uppercase tracking-[0.18em] text-ink-muted font-medium">
{p.appearances.length} {p.appearances.length === 1 ? "benchmark" : "benchmarks"}
Expand Down
26 changes: 17 additions & 9 deletions src/app/sitemap.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -313,14 +314,19 @@ async function buildFullSitemap(): Promise<MetadataRoute.Sitemap> {
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<string, Set<string>>();
for (const p of profiles) {
Expand All@@ -339,7 +345,9 @@ async function buildFullSitemap(): Promise<MetadataRoute.Sitemap> {
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);
Expand Down
10 changes: 4 additions & 6 deletions src/data/compare-pairs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
45 changes: 45 additions & 0 deletions src/lib/compare/brand-whitelist.ts
Original file line numberDiff line numberDiff line change
@@ -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/<slug> or /chains/<slug> page, and (b) are named
* targets in provider outreach or already show up in query data.
*/
export const BRAND_WHITELIST: ReadonlySet<string> = 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",
]);
Loading