Add Ahrefs Domain Rating enrichment to the Backlinks table (#249)

This commit is contained in:
Ben Senescu 2026-06-09 21:42:30 -04:00 committed by GitHub
parent fbef08fe8e
commit c38b453ab0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 507 additions and 27 deletions

View File

@ -181,6 +181,7 @@ export function BacklinksBody({
summaryStats={summaryStats} summaryStats={summaryStats}
/> />
<BacklinksResultsCard <BacklinksResultsCard
projectId={projectId}
activeTab={searchState.tab} activeTab={searchState.tab}
filteredData={filteredData} filteredData={filteredData}
filters={filters} filters={filters}

View File

@ -1,8 +1,7 @@
import { useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react"; import { ArrowLeft, SlidersHorizontal } from "lucide-react";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import { import {
BacklinksNewLostChart, BacklinksNewLostChart,
BacklinksTrendChart, BacklinksTrendChart,
@ -19,8 +18,13 @@ import {
TAB_DESCRIPTIONS, TAB_DESCRIPTIONS,
formatRelativeTimestamp, formatRelativeTimestamp,
} from "./backlinksPageUtils"; } from "./backlinksPageUtils";
import { buildBacklinksTabExport, exportBacklinksTabCsv } from "./export"; import {
BacklinksActionsMenu,
BacklinksExportMenu,
} from "./BacklinksToolbarMenus";
import { buildBacklinksTabExport } from "./export";
import type { BacklinksFiltersState } from "./useBacklinksFilters"; import type { BacklinksFiltersState } from "./useBacklinksFilters";
import { useAhrefsDomainRatings } from "./useAhrefsDomainRatings";
const BACKLINKS_RESULTS_TABS: Array<{ const BACKLINKS_RESULTS_TABS: Array<{
tab: BacklinksSearchState["tab"]; tab: BacklinksSearchState["tab"];
@ -75,6 +79,7 @@ export function BacklinksOverviewPanels({
} }
export function BacklinksResultsCard({ export function BacklinksResultsCard({
projectId,
activeTab, activeTab,
filteredData, filteredData,
filters, filters,
@ -83,6 +88,7 @@ export function BacklinksResultsCard({
exportTarget, exportTarget,
onTabChange, onTabChange,
}: { }: {
projectId: string;
activeTab: BacklinksSearchState["tab"]; activeTab: BacklinksSearchState["tab"];
filteredData: { filteredData: {
backlinks: BacklinksOverviewData["backlinks"]; backlinks: BacklinksOverviewData["backlinks"];
@ -100,6 +106,27 @@ export function BacklinksResultsCard({
() => buildBacklinksTabExport({ tab: activeTab, rows: filteredData }), () => buildBacklinksTabExport({ tab: activeTab, rows: filteredData }),
[activeTab, filteredData], [activeTab, filteredData],
); );
const {
ratings: domainRatings,
isLoading: isLoadingRatings,
loadRatings,
} = useAhrefsDomainRatings(projectId);
// Domains keyed by both tables that the DR column can enrich. The Referring
// Domains list loads lazily, so this grows once that tab is opened.
const ratableDomains = useMemo(
() => collectRatableDomains(filteredData),
[filteredData],
);
// Once the user has opted in, keep newly loaded domains enriched without a
// re-click (e.g. after switching to the lazily-loaded Referring Domains tab).
// KV-cached, so re-requesting already-known domains is nearly free.
useEffect(() => {
if (!domainRatings) return;
const missing = ratableDomains.filter(
(domain) => !Object.hasOwn(domainRatings, domain),
);
if (missing.length > 0) void loadRatings(missing);
}, [domainRatings, ratableDomains, loadRatings]);
return ( return (
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden"> <div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
@ -122,25 +149,20 @@ export function BacklinksResultsCard({
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<ExportToSheetsButton <BacklinksExportMenu
activeTab={activeTab}
exportTarget={exportTarget}
filteredData={filteredData}
headers={exportTable.headers} headers={exportTable.headers}
rows={exportTable.rows} rows={exportTable.rows}
feature={`backlinks_${activeTab}`}
className="btn-sm"
/> />
<button {activeTab !== "pages" ? (
className="btn btn-sm btn-ghost justify-start lg:justify-center" <BacklinksActionsMenu
onClick={() => isLoadingRatings={isLoadingRatings}
exportBacklinksTabCsv({ loadRatings={loadRatings}
tab: activeTab, ratableDomains={ratableDomains}
target: exportTarget, />
rows: filteredData, ) : null}
})
}
>
<Download className="size-4" />
Export CSV
</button>
</div> </div>
</div> </div>
@ -171,13 +193,19 @@ export function BacklinksResultsCard({
</div> </div>
) : null} ) : null}
{activeTab === "backlinks" ? ( {activeTab === "backlinks" ? (
<BacklinksTable rows={filteredData.backlinks} /> <BacklinksTable
rows={filteredData.backlinks}
domainRatings={domainRatings}
/>
) : null} ) : null}
{activeTab === "domains" && isTabLoading && !tabErrorMessage ? ( {activeTab === "domains" && isTabLoading && !tabErrorMessage ? (
<TabLoadingState label="Loading referring domains" /> <TabLoadingState label="Loading referring domains" />
) : null} ) : null}
{activeTab === "domains" && !isTabLoading && !tabErrorMessage ? ( {activeTab === "domains" && !isTabLoading && !tabErrorMessage ? (
<ReferringDomainsTable rows={filteredData.referringDomains} /> <ReferringDomainsTable
rows={filteredData.referringDomains}
domainRatings={domainRatings}
/>
) : null} ) : null}
{activeTab === "pages" && isTabLoading && !tabErrorMessage ? ( {activeTab === "pages" && isTabLoading && !tabErrorMessage ? (
<TabLoadingState label="Loading top pages" /> <TabLoadingState label="Loading top pages" />
@ -190,6 +218,23 @@ export function BacklinksResultsCard({
); );
} }
/** Unique domains the DR column keys on, from both the backlinks and referring
* domains tables, normalized to match how each table renders its domain. */
function collectRatableDomains(filteredData: {
backlinks: BacklinksOverviewData["backlinks"];
referringDomains: BacklinksOverviewData["referringDomains"];
}): string[] {
const domains = [
...filteredData.backlinks.map((row) =>
row.domainFrom?.replace(/^www\./, ""),
),
...filteredData.referringDomains.map((row) => row.domain),
];
return [
...new Set(domains.filter((domain): domain is string => Boolean(domain))),
];
}
function OverviewGrid({ function OverviewGrid({
data, data,
summaryStats, summaryStats,

View File

@ -4,20 +4,27 @@ import {
useAppTable, useAppTable,
} from "@/client/components/table/AppDataTable"; } from "@/client/components/table/AppDataTable";
import { EmptyTableState } from "./BacklinksPageEmptyTableState"; import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import { backlinksColumns } from "./BacklinksTableColumns"; import { buildBacklinksColumns } from "./BacklinksTableColumns";
import type { BacklinksOverviewData } from "./backlinksPageTypes"; import type { BacklinksOverviewData } from "./backlinksPageTypes";
import { groupBacklinksByDomain } from "./backlinksPageUtils"; import { groupBacklinksByDomain } from "./backlinksPageUtils";
import type { DomainRatings } from "./useAhrefsDomainRatings";
export function BacklinksTable({ export function BacklinksTable({
rows, rows,
domainRatings,
}: { }: {
rows: BacklinksOverviewData["backlinks"]; rows: BacklinksOverviewData["backlinks"];
domainRatings: DomainRatings | null;
}) { }) {
const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]); const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
const columns = useMemo(
() => buildBacklinksColumns(domainRatings),
[domainRatings],
);
const table = useAppTable({ const table = useAppTable({
data: groupedData, data: groupedData,
columns: backlinksColumns, columns,
initialState: { initialState: {
sorting: [{ id: "firstSeen", desc: true }], sorting: [{ id: "firstSeen", desc: true }],
}, },

View File

@ -14,6 +14,7 @@ import {
formatDecimal, formatDecimal,
formatNumber, formatNumber,
} from "./backlinksPageUtils"; } from "./backlinksPageUtils";
import type { DomainRatings } from "./useAhrefsDomainRatings";
function BacklinkFlags({ row }: { row: BacklinksRow }) { function BacklinkFlags({ row }: { row: BacklinksRow }) {
return ( return (
@ -73,7 +74,7 @@ function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) {
); );
} }
export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [ const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
{ {
id: "source", id: "source",
accessorKey: "domain", accessorKey: "domain",
@ -319,3 +320,51 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
sortDescFirst: true, sortDescFirst: true,
}, },
]; ];
/**
* Columns for the grouped backlinks table. When `domainRatings` is provided
* (the user clicked "Ahrefs DR"), an Ahrefs DR column is inserted after DA;
* otherwise it stays hidden.
*/
export function buildBacklinksColumns(
domainRatings: DomainRatings | null,
): ColumnDef<GroupedBacklinkDomain>[] {
if (!domainRatings) return baseBacklinksColumns;
const ratings = domainRatings;
const drColumn: ColumnDef<GroupedBacklinkDomain> = {
id: "ahrefsDr",
accessorFn: (row) => ratings[row.domain] ?? null,
header: ({ column }) => (
<SortableHeader
column={column}
label="Ahrefs DR"
helpText="Ahrefs Domain Rating (0-100) for the linking domain."
align="right"
/>
),
size: 90,
minSize: 70,
cell: ({ row }) => {
if (row.depth > 0) return null;
const dr = ratings[row.original.domain] ?? null;
return (
<div className="text-right tabular-nums text-sm">
{dr == null ? "—" : formatDecimal(dr)}
</div>
);
},
sortingFn: numericNullsLast,
sortDescFirst: true,
};
const insertAt =
baseBacklinksColumns.findIndex(
(column) => column.id === "domainAuthority",
) + 1;
return [
...baseBacklinksColumns.slice(0, insertAt),
drColumn,
...baseBacklinksColumns.slice(insertAt),
];
}

View File

@ -0,0 +1,146 @@
import { useState } from "react";
import {
ChevronDown,
Download,
Gauge,
MoreHorizontal,
Sheet,
} from "lucide-react";
import type { CsvValue } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import type {
BacklinksOverviewData,
BacklinksSearchState,
} from "./backlinksPageTypes";
import { exportBacklinksTabCsv } from "./export";
export function BacklinksExportMenu({
activeTab,
exportTarget,
filteredData,
headers,
rows,
}: {
activeTab: BacklinksSearchState["tab"];
exportTarget: string;
filteredData: {
backlinks: BacklinksOverviewData["backlinks"];
referringDomains: BacklinksOverviewData["referringDomains"];
topPages: BacklinksOverviewData["topPages"];
};
headers: string[];
rows: CsvValue[][];
}) {
const [isExportingSheets, setIsExportingSheets] = useState(false);
const canExport = rows.length > 0 && !isExportingSheets;
const handleExportToSheets = async () => {
if (!canExport) return;
setIsExportingSheets(true);
try {
await exportTableToSheets({
headers,
rows,
feature: `backlinks_${activeTab}`,
});
} finally {
setIsExportingSheets(false);
}
};
return (
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
className={`btn btn-sm btn-ghost gap-1 ${rows.length === 0 ? "btn-disabled" : ""}`}
aria-label="Export backlinks table"
>
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul
tabIndex={0}
role="menu"
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-56"
>
<li>
<button
type="button"
onClick={() => void handleExportToSheets()}
disabled={!canExport}
>
{isExportingSheets ? (
<span className="loading loading-spinner loading-xs" />
) : (
<Sheet className="size-4" />
)}
Export to Sheets
</button>
</li>
<li>
<button
type="button"
onClick={() =>
exportBacklinksTabCsv({
tab: activeTab,
target: exportTarget,
rows: filteredData,
})
}
disabled={rows.length === 0}
>
<Download className="size-4" />
Export CSV
</button>
</li>
</ul>
</div>
);
}
export function BacklinksActionsMenu({
isLoadingRatings,
loadRatings,
ratableDomains,
}: {
isLoadingRatings: boolean;
loadRatings: (domains: string[]) => void | Promise<void>;
ratableDomains: string[];
}) {
return (
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
className="btn btn-sm btn-ghost btn-square"
aria-label="Backlinks table actions"
title="Backlinks table actions"
>
<MoreHorizontal className="size-4" />
</div>
<ul
tabIndex={0}
role="menu"
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-52"
>
<li>
<button
type="button"
onClick={() => void loadRatings(ratableDomains)}
disabled={isLoadingRatings}
title="Look up Ahrefs Domain Rating for each domain in the table"
>
{isLoadingRatings ? (
<span className="loading loading-spinner loading-xs" />
) : (
<Gauge className="size-4" />
)}
Ahrefs DR
</button>
</li>
</ul>
</div>
);
}

View File

@ -3,7 +3,7 @@ import {
type SortingFn, type SortingFn,
type SortingState, type SortingState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { useState } from "react"; import { useMemo, useState } from "react";
import { SafeExternalLink } from "@/client/components/SafeExternalLink"; import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { import {
AppDataTable, AppDataTable,
@ -24,6 +24,7 @@ import {
formatDecimal, formatDecimal,
formatNumber, formatNumber,
} from "./backlinksPageUtils"; } from "./backlinksPageUtils";
import type { DomainRatings } from "./useAhrefsDomainRatings";
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number]; type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
@ -47,7 +48,7 @@ const sortByIssues: SortingFn<ReferringDomainRow> = (left, right, columnId) => {
); );
}; };
const columns = [ const baseColumns = [
columnHelper.accessor("domain", { columnHelper.accessor("domain", {
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
@ -152,6 +153,46 @@ const columns = [
}), }),
]; ];
/**
* Columns for the referring domains table. When `domainRatings` is provided
* (the user clicked "Ahrefs DR"), an Ahrefs DR column is inserted after Rank;
* otherwise it stays hidden.
*/
function buildReferringDomainColumns(domainRatings: DomainRatings | null) {
if (!domainRatings) return baseColumns;
const ratings = domainRatings;
const drColumn = columnHelper.accessor(
(row) => (row.domain ? (ratings[row.domain] ?? null) : null),
{
id: "ahrefsDr",
header: ({ column }) => (
<SortableHeader
column={column}
label="Ahrefs DR"
helpText="Ahrefs Domain Rating (0-100) for this referring domain."
/>
),
cell: ({ getValue }) => {
const dr = getValue();
return dr == null ? "—" : formatDecimal(dr);
},
sortingFn: numericNullsLast,
sortDescFirst: true,
},
);
const insertAt =
baseColumns.findIndex(
(column) => "accessorKey" in column && column.accessorKey === "rank",
) + 1;
return [
...baseColumns.slice(0, insertAt),
drColumn,
...baseColumns.slice(insertAt),
];
}
const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }]; const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }];
function getDomainWebsiteHref(domain: string) { function getDomainWebsiteHref(domain: string) {
@ -164,10 +205,16 @@ function getDomainWebsiteHref(domain: string) {
export function ReferringDomainsTable({ export function ReferringDomainsTable({
rows, rows,
domainRatings,
}: { }: {
rows: BacklinksOverviewData["referringDomains"]; rows: BacklinksOverviewData["referringDomains"];
domainRatings: DomainRatings | null;
}) { }) {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING); const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(
() => buildReferringDomainColumns(domainRatings),
[domainRatings],
);
const table = useAppTable({ const table = useAppTable({
data: rows, data: rows,

View File

@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { chunk, unique } from "remeda";
import { toast } from "sonner";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getAhrefsDomainRatings } from "@/serverFunctions/ahrefs";
/** Map of domain (as held in table rows) → Ahrefs DR, or null when unknown. */
export type DomainRatings = Record<string, number | null>;
// The server function caps each call at 100 domains (Workers subrequest limit),
// so the client chunks larger sets and calls sequentially.
const DOMAINS_PER_REQUEST = 100;
export function useAhrefsDomainRatings(projectId: string) {
const [ratings, setRatings] = useState<DomainRatings | null>(null);
const ratingsRef = useRef<DomainRatings | null>(null);
const pendingDomainsRef = useRef(new Set<string>());
const [activeLoadCount, setActiveLoadCount] = useState(0);
useEffect(() => {
ratingsRef.current = ratings;
}, [ratings]);
const loadRatings = useCallback(
async (domains: string[]) => {
const currentRatings = ratingsRef.current;
const pendingDomains = pendingDomainsRef.current;
const targets = unique(domains.filter(Boolean)).filter(
(domain) =>
!Object.hasOwn(currentRatings ?? {}, domain) &&
!pendingDomains.has(domain),
);
if (targets.length === 0) return;
for (const domain of targets) pendingDomains.add(domain);
setActiveLoadCount((count) => count + 1);
const fetched: DomainRatings = {};
try {
for (const batch of chunk(targets, DOMAINS_PER_REQUEST)) {
Object.assign(
fetched,
await getAhrefsDomainRatings({
data: { projectId, domains: batch },
}),
);
}
} catch (error) {
// Opt-in convenience feature — surface partial results, don't crash.
toast.error(
getStandardErrorMessage(error, "Could not load Ahrefs DR."),
);
} finally {
if (Object.keys(fetched).length > 0) {
const nextRatings = { ...ratingsRef.current, ...fetched };
ratingsRef.current = nextRatings;
setRatings(nextRatings);
}
for (const domain of targets) pendingDomains.delete(domain);
setActiveLoadCount((count) => Math.max(0, count - 1));
}
},
[projectId],
);
return { ratings, isLoading: activeLoadCount > 0, loadRatings };
}

View File

@ -0,0 +1,119 @@
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
import { chunk } from "remeda";
import { z } from "zod";
import { normalizeDomainInput } from "@/server/lib/domainUtils";
import { requireProjectContext } from "@/serverFunctions/middleware";
/**
* Ahrefs publishes a free, keyless Domain Rating lookup. We use it to enrich the
* Backlinks table on demand no billing, no stored data. Every result (a DR, or
* `null` when Ahrefs has no rating) is cached in KV for a day so re-opening the
* table is free.
*/
const AHREFS_DR_ENDPOINT =
"https://api.ahrefs.com/v3/public/domain-rating-free";
const CACHE_PREFIX = "ahrefs-dr:";
const CACHE_TTL_SECONDS = 86_400; // 24 hours
const FETCH_TIMEOUT_MS = 5_000;
const FETCH_BATCH_SIZE = 20;
const MAX_DOMAINS_PER_CALL = 100;
const domainRatingsInputSchema = z.object({
projectId: z.string().min(1),
domains: z.array(z.string().trim().min(1).max(253)).max(MAX_DOMAINS_PER_CALL),
});
const ahrefsResponseSchema = z.object({
domain_rating: z.object({
domain_rating: z.number().min(0).max(100),
}),
});
export const getAhrefsDomainRatings = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => domainRatingsInputSchema.parse(data))
.handler(async ({ data }) => {
const result: Record<string, number | null> = {};
// Several original inputs can collapse to one normalized domain (www/non-www,
// protocol variants). Resolve each normalized domain once, then fan the value
// back out to every original key the client will look up by.
const originalsByDomain = new Map<string, string[]>();
for (const original of data.domains) {
const domain = normalizeDomainInput(original, true);
const existing = originalsByDomain.get(domain);
if (existing) existing.push(original);
else originalsByDomain.set(domain, [original]);
}
const ratings = new Map<string, number | null>();
for (const batch of chunk(
[...originalsByDomain.keys()],
FETCH_BATCH_SIZE,
)) {
const resolved = await Promise.all(
batch.map(async (domain) => {
// A single failure (KV blip, etc.) must not fail the whole call.
try {
return [domain, await resolveDomainRating(domain)] as const;
} catch {
return [domain, null] as const;
}
}),
);
for (const [domain, dr] of resolved) ratings.set(domain, dr);
}
for (const [domain, originals] of originalsByDomain) {
const dr = ratings.get(domain) ?? null;
for (const original of originals) result[original] = dr;
}
return result;
});
/** Cache-first lookup for a single normalized domain. */
async function resolveDomainRating(domain: string): Promise<number | null> {
const cacheKey = `${CACHE_PREFIX}${domain}`;
// KV returns JS `null` only when the key is absent; a cached "no rating" is
// stored as the string "null", so cache hits (including nulls) skip the fetch.
const cached = await env.KV.get(cacheKey);
if (cached !== null) return parseCachedRating(cached);
const dr = await fetchDomainRating(domain);
await env.KV.put(cacheKey, JSON.stringify(dr), {
expirationTtl: CACHE_TTL_SECONDS,
});
return dr;
}
async function fetchDomainRating(domain: string): Promise<number | null> {
const response = await fetch(
`${AHREFS_DR_ENDPOINT}?target=${encodeURIComponent(domain)}`,
{ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
);
if (!response.ok) {
throw new Error(`Ahrefs DR lookup failed with status ${response.status}`);
}
const parsed = ahrefsResponseSchema.safeParse(await response.json());
if (!parsed.success) {
throw new Error("Ahrefs DR lookup returned an unexpected response");
}
// Ahrefs returns 200 with DR 0 for domains it has no rating for (new or
// unknown), so treat 0 as "no rating" — the table renders it as "—".
const dr = parsed.data.domain_rating.domain_rating;
return dr > 0 ? dr : null;
}
function parseCachedRating(raw: string): number | null {
try {
const value: unknown = JSON.parse(raw);
// Mirror fetchDomainRating: a DR of 0 means "no rating", so render it as "—".
return typeof value === "number" && value > 0 ? value : null;
} catch {
return null;
}
}