From c38b453ab042e0f5291ace435862184f69aa7013 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:42:30 -0400 Subject: [PATCH] Add Ahrefs Domain Rating enrichment to the Backlinks table (#249) --- .../backlinks/BacklinksPageContent.tsx | 1 + .../backlinks/BacklinksPageSections.tsx | 89 ++++++++--- .../features/backlinks/BacklinksTable.tsx | 11 +- .../backlinks/BacklinksTableColumns.tsx | 51 +++++- .../backlinks/BacklinksToolbarMenus.tsx | 146 ++++++++++++++++++ .../backlinks/ReferringDomainsTable.tsx | 51 +++++- .../backlinks/useAhrefsDomainRatings.ts | 66 ++++++++ src/serverFunctions/ahrefs.ts | 119 ++++++++++++++ 8 files changed, 507 insertions(+), 27 deletions(-) create mode 100644 src/client/features/backlinks/BacklinksToolbarMenus.tsx create mode 100644 src/client/features/backlinks/useAhrefsDomainRatings.ts create mode 100644 src/serverFunctions/ahrefs.ts diff --git a/src/client/features/backlinks/BacklinksPageContent.tsx b/src/client/features/backlinks/BacklinksPageContent.tsx index 8b367d4..d1604e8 100644 --- a/src/client/features/backlinks/BacklinksPageContent.tsx +++ b/src/client/features/backlinks/BacklinksPageContent.tsx @@ -181,6 +181,7 @@ export function BacklinksBody({ summaryStats={summaryStats} /> buildBacklinksTabExport({ tab: activeTab, rows: 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 (
@@ -122,25 +149,20 @@ export function BacklinksResultsCard({
- - + {activeTab !== "pages" ? ( + + ) : null}
@@ -171,13 +193,19 @@ export function BacklinksResultsCard({ ) : null} {activeTab === "backlinks" ? ( - + ) : null} {activeTab === "domains" && isTabLoading && !tabErrorMessage ? ( ) : null} {activeTab === "domains" && !isTabLoading && !tabErrorMessage ? ( - + ) : null} {activeTab === "pages" && isTabLoading && !tabErrorMessage ? ( @@ -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({ data, summaryStats, diff --git a/src/client/features/backlinks/BacklinksTable.tsx b/src/client/features/backlinks/BacklinksTable.tsx index affa959..e2a7f27 100644 --- a/src/client/features/backlinks/BacklinksTable.tsx +++ b/src/client/features/backlinks/BacklinksTable.tsx @@ -4,20 +4,27 @@ import { useAppTable, } from "@/client/components/table/AppDataTable"; import { EmptyTableState } from "./BacklinksPageEmptyTableState"; -import { backlinksColumns } from "./BacklinksTableColumns"; +import { buildBacklinksColumns } from "./BacklinksTableColumns"; import type { BacklinksOverviewData } from "./backlinksPageTypes"; import { groupBacklinksByDomain } from "./backlinksPageUtils"; +import type { DomainRatings } from "./useAhrefsDomainRatings"; export function BacklinksTable({ rows, + domainRatings, }: { rows: BacklinksOverviewData["backlinks"]; + domainRatings: DomainRatings | null; }) { const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]); + const columns = useMemo( + () => buildBacklinksColumns(domainRatings), + [domainRatings], + ); const table = useAppTable({ data: groupedData, - columns: backlinksColumns, + columns, initialState: { sorting: [{ id: "firstSeen", desc: true }], }, diff --git a/src/client/features/backlinks/BacklinksTableColumns.tsx b/src/client/features/backlinks/BacklinksTableColumns.tsx index 8c76e93..e615508 100644 --- a/src/client/features/backlinks/BacklinksTableColumns.tsx +++ b/src/client/features/backlinks/BacklinksTableColumns.tsx @@ -14,6 +14,7 @@ import { formatDecimal, formatNumber, } from "./backlinksPageUtils"; +import type { DomainRatings } from "./useAhrefsDomainRatings"; function BacklinkFlags({ row }: { row: BacklinksRow }) { return ( @@ -73,7 +74,7 @@ function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) { ); } -export const backlinksColumns: ColumnDef[] = [ +const baseBacklinksColumns: ColumnDef[] = [ { id: "source", accessorKey: "domain", @@ -319,3 +320,51 @@ export const backlinksColumns: ColumnDef[] = [ 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[] { + if (!domainRatings) return baseBacklinksColumns; + + const ratings = domainRatings; + const drColumn: ColumnDef = { + id: "ahrefsDr", + accessorFn: (row) => ratings[row.domain] ?? null, + header: ({ column }) => ( + + ), + size: 90, + minSize: 70, + cell: ({ row }) => { + if (row.depth > 0) return null; + const dr = ratings[row.original.domain] ?? null; + return ( +
+ {dr == null ? "—" : formatDecimal(dr)} +
+ ); + }, + sortingFn: numericNullsLast, + sortDescFirst: true, + }; + + const insertAt = + baseBacklinksColumns.findIndex( + (column) => column.id === "domainAuthority", + ) + 1; + return [ + ...baseBacklinksColumns.slice(0, insertAt), + drColumn, + ...baseBacklinksColumns.slice(insertAt), + ]; +} diff --git a/src/client/features/backlinks/BacklinksToolbarMenus.tsx b/src/client/features/backlinks/BacklinksToolbarMenus.tsx new file mode 100644 index 0000000..e63f9b0 --- /dev/null +++ b/src/client/features/backlinks/BacklinksToolbarMenus.tsx @@ -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 ( +
+
+ + Export + +
+
    +
  • + +
  • +
  • + +
  • +
+
+ ); +} + +export function BacklinksActionsMenu({ + isLoadingRatings, + loadRatings, + ratableDomains, +}: { + isLoadingRatings: boolean; + loadRatings: (domains: string[]) => void | Promise; + ratableDomains: string[]; +}) { + return ( +
+
+ +
+
    +
  • + +
  • +
+
+ ); +} diff --git a/src/client/features/backlinks/ReferringDomainsTable.tsx b/src/client/features/backlinks/ReferringDomainsTable.tsx index 76b3f37..11c2777 100644 --- a/src/client/features/backlinks/ReferringDomainsTable.tsx +++ b/src/client/features/backlinks/ReferringDomainsTable.tsx @@ -3,7 +3,7 @@ import { type SortingFn, type SortingState, } from "@tanstack/react-table"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { SafeExternalLink } from "@/client/components/SafeExternalLink"; import { AppDataTable, @@ -24,6 +24,7 @@ import { formatDecimal, formatNumber, } from "./backlinksPageUtils"; +import type { DomainRatings } from "./useAhrefsDomainRatings"; type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number]; @@ -47,7 +48,7 @@ const sortByIssues: SortingFn = (left, right, columnId) => { ); }; -const columns = [ +const baseColumns = [ columnHelper.accessor("domain", { header: ({ column }) => ( (row.domain ? (ratings[row.domain] ?? null) : null), + { + id: "ahrefsDr", + header: ({ column }) => ( + + ), + 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 }]; function getDomainWebsiteHref(domain: string) { @@ -164,10 +205,16 @@ function getDomainWebsiteHref(domain: string) { export function ReferringDomainsTable({ rows, + domainRatings, }: { rows: BacklinksOverviewData["referringDomains"]; + domainRatings: DomainRatings | null; }) { const [sorting, setSorting] = useState(DEFAULT_SORTING); + const columns = useMemo( + () => buildReferringDomainColumns(domainRatings), + [domainRatings], + ); const table = useAppTable({ data: rows, diff --git a/src/client/features/backlinks/useAhrefsDomainRatings.ts b/src/client/features/backlinks/useAhrefsDomainRatings.ts new file mode 100644 index 0000000..de93a58 --- /dev/null +++ b/src/client/features/backlinks/useAhrefsDomainRatings.ts @@ -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; + +// 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(null); + const ratingsRef = useRef(null); + const pendingDomainsRef = useRef(new Set()); + 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 }; +} diff --git a/src/serverFunctions/ahrefs.ts b/src/serverFunctions/ahrefs.ts new file mode 100644 index 0000000..9b4b82f --- /dev/null +++ b/src/serverFunctions/ahrefs.ts @@ -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 = {}; + + // 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(); + 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(); + 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 { + 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 { + 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; + } +}