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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* Domain rating block: the comparison under the histogram, then the caption and Ahrefs credit. */
.drCompare {
border-top: 1px dashed var(--color-border);
display: grid;
gap: var(--space-4);
margin-top: var(--space-8);
padding-top: var(--space-7);
}

/* Bars this short read better at reading width than across a wide canvas. */
.drCompare > dl {
max-width: 640px;
}

.drCompare > p,
.drNote {
color: var(--color-text-muted);
font: var(--text-small) / 1.4 var(--font-mono);
margin: 0;
}

.drNote {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-6);
justify-content: space-between;
margin-top: var(--space-7);
}
37 changes: 36 additions & 1 deletion app/(site)/(directories)/ad-networks/[slug]/network-stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { JoinedLeftBars } from "@/app/ui/ad-networks/viz/joined-left";
import { SwitchFlow } from "@/app/ui/ad-networks/viz/switch-flow";
import { TypeScale } from "@/app/ui/ad-networks/viz/type-scale";
import { nicheName } from "@/app/ui/ad-networks/site-format";
import { AhrefsCredit } from "@/app/ui/ahrefs-credit";
import { ordinal, percent } from "../ad-networks-shared";
import styles from "./network-stats.module.css";

// Loader shares are 0 to 100. Never round a share that isn't 100% up to 100%.
const pct = (share: number | undefined) => percent(share ?? 0);
Expand Down Expand Up @@ -104,6 +106,7 @@ export function NetworkStats({ network, stats }: { network: AdNetworkListRow; st
...(otherPlatforms > 0 ? [{ label: "Other", value: otherPlatforms }] : []),
...(unknownPlatforms > 0 ? [{ label: "Unknown", value: unknownPlatforms, tone: "track" as const }] : []),
];
const dr = stats.domain_rating;
const topEndings = stats.domain_endings.slice(0, 5);
const otherEndings = stats.domain_endings.slice(5).reduce((sum, row) => sum + (row.share ?? 0), 0);
const endings = [
Expand Down Expand Up @@ -162,6 +165,38 @@ export function NetworkStats({ network, stats }: { network: AdNetworkListRow; st
/>
</ReportBlock>
) : null;
// Hidden until enough of this network's sites are rated (AD_NETWORK_DR_GATE in lib/ad-networks-report.ts).
// Ahrefs' license needs the linked credit wherever DR shows, so it sits in the block's last line.
const drBlock = dr ? (
<ReportBlock description="How strong each site's backlinks are, on Ahrefs' 0 to 100 scale. Higher means more and better sites link to it." key="dr" label="Domain rating">
<ReportNumbers
items={[
{ label: "median DR", value: Math.round(dr.median), accent: true },
{ label: "average DR", value: Math.round(dr.average) },
{ label: `of ${formatNumber(dr.listed)} sites rated`, value: formatNumber(dr.rated) },
]}
variant="plain"
/>
<ColumnChart
caption={`${name} sites by Domain Rating`}
items={dr.bands.map((band) => ({ label: band.label, value: band.count, tone: band.key === dr.median_band ? "ac" as const : "ac2" as const }))}
/>
{dr.comparison.length ? (
<div className={styles.drCompare}>
<p>Average DR of rated sites only: {shortName(name)} vs other big networks</p>
<RankedBars
ariaLabel={`Average Domain Rating of ${name} and other big networks`}
rows={dr.comparison.map((row) => ({
label: shortName(row.name), value: row.average, display: <>{Math.round(row.average)}<br />{formatNumber(row.rated)} of {formatNumber(row.listed)} rated</>,
highlight: row.current, href: row.current ? undefined : `/ad-networks/${row.slug}`,
}))}
tone="highlight"
/>
</div>
) : null}
<p className={styles.drNote}><span>{name} sites by Domain Rating. The highlighted bar holds the median.</span><AhrefsCredit /></p>
</ReportBlock>
) : null;
const buildBlocks = [
measuredPlatforms.length ? (
<ReportBlock description="What the sites are built with, from clues in their page code." key="platforms" label="Platforms">
Expand Down Expand Up @@ -214,7 +249,7 @@ export function NetworkStats({ network, stats }: { network: AdNetworkListRow; st
},
{ title: `What kind of sites use ${name}?`, has: kindBlocks.some(Boolean), content: <Row blocks={kindBlocks} layout="1-1" /> },
{ title: `Which sites joined or left ${name}?`, has: moveBlocks.some(Boolean), content: <Row blocks={moveBlocks} layout="1-1" /> },
{ title: `How are ${name} sites built?`, has: Boolean(ageBlock) || buildBlocks.some(Boolean), content: <>{ageBlock}<Row blocks={buildBlocks} layout="1-1" /></> },
{ title: `How are ${name} sites built?`, has: Boolean(ageBlock || drBlock) || buildBlocks.some(Boolean), content: <>{ageBlock}{drBlock}<Row blocks={buildBlocks} layout="1-1" /></> },
{ title: "What else is in their ads.txt?", has: adsTxtBlocks.some(Boolean), content: <Row blocks={adsTxtBlocks} layout="1-1" /> },
];

Expand Down
5 changes: 3 additions & 2 deletions app/(site)/(directories)/ad-networks/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,17 @@ export default async function AdNetworkPage({ params }: { params: Promise<{ slug
<PageCanvas flow className={styles.body}>
{network.listed_count ? (
<SitesTable
columns={["niche", "traffic", "age", "platform", "manager", "checked"]}
columns={["niche", "traffic", "dr", "age", "platform", "manager", "checked"]}
defaultSort={sites.effectiveSort}
drFilter
heading={`Sites using ${network.name}`}
headingId="sites"
initial={sites}
nicheFilter
noun="sites"
route={`/ad-networks/${slug}`}
scope={{ network: slug }}
sorts={["traffic", "oldest", "newest", "domain"]}
sorts={["traffic", "highest-dr", "lowest-dr", "oldest", "newest", "domain"]}
/>
) : null}
<NetworkDetails content={content} name={network.name} slug={slug} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,17 @@ export function NicheView({ breadcrumbs, data, Intro, title, updatedAt }: {
</EntryHero>
<PageCanvas className={styles.sections} flow>
<SitesTable
columns={["networks", "traffic", "age", "platform", "checked"]}
columns={["networks", "traffic", "dr", "age", "platform", "checked"]}
defaultSort={sites.effectiveSort}
drFilter
heading={`Which ${noun} run ads?`}
headingId="sites"
initial={sites}
networkFilter={niche.networks.map(({ name, slug }) => ({ name, slug }))}
noun={noun}
route={`/ad-networks/niches/${niche.slug}`}
scope={{ niche: niche.slug }}
sorts={["traffic", "oldest", "newest", "domain"]}
sorts={["traffic", "highest-dr", "lowest-dr", "oldest", "newest", "domain"]}
/>

{niche.networks.length ? (
Expand Down
58 changes: 38 additions & 20 deletions app/(site)/(directories)/ad-networks/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { Report, ReportBars, ReportBlock, ReportGroup, ReportNumbers, ReportRow,
import { ColumnChart } from "@/app/ui/ad-networks/viz/columns";
import { DivergingBars } from "@/app/ui/ad-networks/viz/diverging-bars";
import { DotPlot } from "@/app/ui/ad-networks/viz/dot-plot";
import { formatCompact, logStepPosition } from "@/app/ui/ad-networks/viz/format";
import { formatCompact, formatNumber, logStepPosition } from "@/app/ui/ad-networks/viz/format";
import { MultiLine, type LineSeries } from "@/app/ui/ad-networks/viz/multi-line";
import { ScatterPlot } from "@/app/ui/ad-networks/viz/scatter-plot";
import { SizeStacks } from "@/app/ui/ad-networks/viz/size-stacks";
import { StackedColumns } from "@/app/ui/ad-networks/viz/stacked-columns";
import { formatCount } from "@/app/ui/ad-networks/site-format";
import { AhrefsCredit } from "@/app/ui/ahrefs-credit";
import { AppShell } from "@/app/ui/site/app-shell";
import { EntryHero } from "@/app/ui/site/entry-hero";
import { PageCanvas } from "@/app/ui/site/page-canvas";
Expand Down Expand Up @@ -139,6 +140,34 @@ export default async function AdNetworkStatsPage() {
const ageRows = data.median_domain_age.slice(0, 9).map((row) => ({ label: row.name, shortLabel: shortName(row.name), value: Math.round(row.years) }));
const wordpressRows = data.wordpress_share.slice(0, 8)
.map((row) => ({ label: row.name, value: row.share, display: percent(row.share), href: `/ad-networks/${row.slug}` }));
// Only networks with enough rated sites (AD_NETWORK_DR_GATE), highest average first.
const drRows = (data.domain_rating_by_network ?? []).slice(0, 8)
.map((row) => ({ label: row.name, value: row.average, display: <>{Math.round(row.average)}<br />{formatNumber(row.rated)} of {formatNumber(row.listed)} rated</>, href: `/ad-networks/${row.slug}` }));

// data-gate: the domain-age run fills median ages; the platform run fills WordPress shares; the
// domain-rating run fills average DR, with the Ahrefs credit its license needs.
const compareBlocks = [
scatter.length > 1 ? (
<ReportBlock description={`Each dot is a network, placed by the monthly traffic it asks for and how many sites run it.${guideNote} Networks without a public traffic rule are left out.`} key="rules" label="joining rule vs sites">
<ScatterPlot caption="Monthly traffic rule and sites per network" points={scatter} xLabels={ruleStepLabels} xTitle="traffic to join →" />
</ReportBlock>
) : null,
ageRows.length ? (
<ReportBlock description="How long the domains of each network's sites have been registered." key="age" label="median domain age">
<DotPlot caption="Median domain age per network, in years" rows={ageRows} unit="yrs" />
</ReportBlock>
) : null,
wordpressRows.length ? (
<ReportBlock description="The share of each network's sites built on WordPress." key="wordpress" label="WordPress share">
<ReportBars ariaLabel="WordPress share by network" max={100} rows={wordpressRows} />
</ReportBlock>
) : null,
drRows.length ? (
<ReportBlock description={<>Average backlink strength from 0 to 100, covering rated sites only. <AhrefsCredit /></>} key="dr" label="average domain rating">
<ReportBars ariaLabel="Average Domain Rating by network" max={100} rows={drRows} />
</ReportBlock>
) : null,
].filter(Boolean);

const ads = data.ads_txt_metrics;
const fileSizeTop = Math.max(0, ...data.ads_txt.file_sizes.map((row) => row.count));
Expand Down Expand Up @@ -237,26 +266,15 @@ export default async function AdNetworkStatsPage() {
</ReportGroup>
) : null}

{/* data-gate: the domain-age run fills median ages; the platform run fills WordPress shares. */}
{scatter.length > 1 || ageRows.length || wordpressRows.length ? (
{compareBlocks.length ? (
<ReportGroup level={2} title="How do the networks compare?">
<ReportRow layout="1-1-1">
{scatter.length > 1 ? (
<ReportBlock description={`Each dot is a network, placed by the monthly traffic it asks for and how many sites run it.${guideNote} Networks without a public traffic rule are left out.`} label="joining rule vs sites">
<ScatterPlot caption="Monthly traffic rule and sites per network" points={scatter} xLabels={ruleStepLabels} xTitle="traffic to join →" />
</ReportBlock>
) : null}
{ageRows.length ? (
<ReportBlock description="How long the domains of each network's sites have been registered." label="median domain age">
<DotPlot caption="Median domain age per network, in years" rows={ageRows} unit="yrs" />
</ReportBlock>
) : null}
{wordpressRows.length ? (
<ReportBlock description="The share of each network's sites built on WordPress." label="WordPress share">
<ReportBars ariaLabel="WordPress share by network" max={100} rows={wordpressRows} />
</ReportBlock>
) : null}
</ReportRow>
{/* Four blocks sit as two pairs; three keep the original row of three. */}
{compareBlocks.length === 4 ? (
<>
<ReportRow layout="1-1">{compareBlocks.slice(0, 2)}</ReportRow>
<ReportRow layout="1-1">{compareBlocks.slice(2)}</ReportRow>
</>
) : <ReportRow layout="1-1-1">{compareBlocks}</ReportRow>}
</ReportGroup>
) : null}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
shortUrl, submitStatusText, trafficLabels, trafficSteps, waitShort,
} from "../launch-format";
import { LaunchLogo } from "../launch-logo";
import { AhrefsCredit } from "../ahrefs-credit";
import { AhrefsCredit } from "@/app/ui/ahrefs-credit";
import styles from "./directory.module.css";

// Build every place during the release. A place without a published registry row still returns 404.
Expand Down
11 changes: 0 additions & 11 deletions app/(site)/(directories)/launch-directories/ahrefs-credit.tsx

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { LaunchDirectory } from "@/lib/launch-directories";
import { LaunchCard, LaunchGrid, launchHref, type LaunchCategory } from "../launch-card";
import { costShort, formatDate, formatPrice, isSubmitReady, linkShort, shortUrl, submitStatusText, trafficLabels, trafficSteps, waitShort } from "../launch-format";
import { LaunchLogo } from "../launch-logo";
import { AhrefsCredit } from "../ahrefs-credit";
import { AhrefsCredit } from "@/app/ui/ahrefs-credit";
import styles from "./compare.module.css";

export type CompareRecord = Pick<LaunchDirectory,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,6 @@

.countLine { display: grid; gap: 2px; }
.count { margin: 0; color: var(--color-text-muted); font: var(--text-small) var(--font-mono); }
.ahrefsCredit { color: var(--color-text-muted); font: 0.78rem / 1.4 var(--font-mono); }
.ahrefsCredit a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
.ahrefsCredit a:hover { color: var(--color-text); }
.count b { color: var(--color-text); font-weight: var(--weight-semibold); }
.toolbarControls { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
.toolbarControls :global(.directory-select) { display: flex; align-items: center; gap: var(--space-4); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { queryLaunchDirectories } from "@/lib/launch-directories-query";
import { LaunchCard, LaunchGrid, launchHref, type LaunchCategory } from "./launch-card";
import { costShort, isSubmitReady, linkShort, submitStatusText, waitShort } from "./launch-format";
import { LaunchLogo } from "./launch-logo";
import { AhrefsCredit } from "./ahrefs-credit";
import { AhrefsCredit } from "@/app/ui/ahrefs-credit";
import { backlinkOptions, costOptions, drOptions, linkOptions, quickPicks, sortOptions, submissionOptions } from "./launch-directory-options";
import styles from "./launch-directories.module.css";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { RankedBars, StackBar } from "@/app/ui/ad-networks/viz/bars";
import { ColumnChart } from "@/app/ui/ad-networks/viz/columns";
import type { LaunchDirectory } from "@/lib/launch-directories";
import { launchDirectoryHasFreeOption } from "@/lib/launch-directories-query";
import { AhrefsCredit } from "./ahrefs-credit";
import { AhrefsCredit } from "@/app/ui/ahrefs-credit";
import { linkOptions } from "./launch-directory-options";
import styles from "./launch-directories.module.css";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type SiteFact,
} from "@/app/ui/ad-networks/site-view";
import { TrafficMeter } from "@/app/ui/ad-networks/traffic-meter";
import { AhrefsCredit } from "@/app/ui/ahrefs-credit";
import { RankedBars, StackBar } from "@/app/ui/ad-networks/viz/bars";
import { formatNumber, formatShare, type VizTone } from "@/app/ui/ad-networks/viz/format";
import { HistoryGantt, type GanttRow } from "@/app/ui/ad-networks/viz/history-gantt";
Expand Down Expand Up @@ -270,6 +271,11 @@ function facts(view: AdNetworkCheckerSiteView): SiteFact[] {
site.managed_by ? { label: "main ad network", value: <Link href={networkHref(site.managed_by)} prefetch={false}>{site.managed_by.name}</Link> } : null,
// data-gate: the traffic, niche, platform, domain-age, and publisher-names runs fill these facts.
site.traffic_group ? { label: "traffic", value: <TrafficMeter group={site.traffic_group} muted={false} /> } : null,
// data-gate: the domain-rating run. Ahrefs' license needs the linked credit beside the number.
typeof site.domain_rating === "number" ? { label: "domain rating", value: <>
{site.domain_rating}{site.domain_rating_checked_on ? <> <FactMuted>· {formatDay(site.domain_rating_checked_on)}</FactMuted></> : null}
<br /><AhrefsCredit />
</> } : null,
niche && site.niche ? { label: "niche", value: <Link href={`/ad-networks/niches/${site.niche}`} prefetch={false}>{niche}</Link> } : null,
site.platform ? { label: "platform", value: site.platform } : null,
age ? { label: "domain registered", value: <>{age.year}{age.years !== null ? <> <FactMuted>· {age.years} {age.years === 1 ? "yr" : "yrs"}</FactMuted></> : null}</> } : null,
Expand Down
15 changes: 10 additions & 5 deletions app/api/ad-networks/input.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { domainToASCII } from "node:url";
import { isIP } from "node:net";
import { getDomain } from "tldts";
import { AD_NETWORK_SITES_PAGE_SIZE } from "../../../lib/ad-networks-report.ts";
import { AD_NETWORK_DR_FILTER_STEPS, AD_NETWORK_SITES_PAGE_SIZE } from "../../../lib/ad-networks-report.ts";
import { getAdNetworkNiche } from "../../../lib/ad-network-niches.ts";
import type { AdNetworkNicheSlug } from "../../../lib/ad-network-niches.ts";
import type { AdNetworkSitesQuery, AdNetworkSitesSort } from "../../../lib/ad-networks-types.ts";
import type { AdNetworkDrFilter, AdNetworkSitesQuery, AdNetworkSitesSort } from "../../../lib/ad-networks-types.ts";

export class AdNetworkInputError extends Error {}
const sorts = new Set(["domain", "traffic", "oldest", "newest", "checked"]);
const sorts = new Set(["domain", "traffic", "oldest", "newest", "checked", "highest-dr", "lowest-dr"]);
// Only the select's exact steps, so each DR view has one cache key (`dr=30`, never `dr=030` or `dr=31`).
const drSteps = new Map(AD_NETWORK_DR_FILTER_STEPS.map((step) => [String(step), step]));
const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function parseAdNetworkSitesParams(params: URLSearchParams): AdNetworkSitesQuery {
const allowed = new Set(["network", "niche", "q", "sort", "page", "pageSize"]);
const allowed = new Set(["network", "niche", "q", "sort", "page", "pageSize", "dr"]);
for (const key of params.keys()) {
if (!allowed.has(key) || params.getAll(key).length > 1) throw new AdNetworkInputError(`Invalid parameter: ${key}`);
}
Expand All @@ -22,14 +24,17 @@ export function parseAdNetworkSitesParams(params: URLSearchParams): AdNetworkSit
if (niche && !getAdNetworkNiche(niche)) throw new AdNetworkInputError("Invalid niche.");
if (q.length > 100 || /[\x00-\x1f\x7f]/.test(q)) throw new AdNetworkInputError("Search must be at most 100 characters.");
if (!sorts.has(sort)) throw new AdNetworkInputError("Invalid sort.");
const dr = params.get("dr");
if (dr !== null && !drSteps.has(dr)) throw new AdNetworkInputError("Invalid dr.");
function integer(key: string, fallback: number, max: number) {
const raw = params.get(key);
if (raw === null) return fallback;
if (!/^[1-9]\d*$/.test(raw) || Number(raw) > max) throw new AdNetworkInputError(`Invalid ${key}.`);
return Number(raw);
}
return { network, niche: niche as AdNetworkNicheSlug | null, q, sort: sort as AdNetworkSitesSort,
page: integer("page", 1, 10000), pageSize: integer("pageSize", AD_NETWORK_SITES_PAGE_SIZE, 100) };
page: integer("page", 1, 10000), pageSize: integer("pageSize", AD_NETWORK_SITES_PAGE_SIZE, 100),
minDr: dr === null ? null : drSteps.get(dr) as AdNetworkDrFilter };
}
export function parseAdNetworkSiteParams(params: URLSearchParams): string {
if ([...params.keys()].some(key => key !== "domain") || params.getAll("domain").length !== 1) {
Expand Down
Loading
Loading