Add Ahrefs Domain Rating enrichment to the Backlinks table (#249)
This commit is contained in:
parent
fbef08fe8e
commit
c38b453ab0
@ -181,6 +181,7 @@ export function BacklinksBody({
|
||||
summaryStats={summaryStats}
|
||||
/>
|
||||
<BacklinksResultsCard
|
||||
projectId={projectId}
|
||||
activeTab={searchState.tab}
|
||||
filteredData={filteredData}
|
||||
filters={filters}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
|
||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||
import { ArrowLeft, SlidersHorizontal } from "lucide-react";
|
||||
import {
|
||||
BacklinksNewLostChart,
|
||||
BacklinksTrendChart,
|
||||
@ -19,8 +18,13 @@ import {
|
||||
TAB_DESCRIPTIONS,
|
||||
formatRelativeTimestamp,
|
||||
} from "./backlinksPageUtils";
|
||||
import { buildBacklinksTabExport, exportBacklinksTabCsv } from "./export";
|
||||
import {
|
||||
BacklinksActionsMenu,
|
||||
BacklinksExportMenu,
|
||||
} from "./BacklinksToolbarMenus";
|
||||
import { buildBacklinksTabExport } from "./export";
|
||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||
import { useAhrefsDomainRatings } from "./useAhrefsDomainRatings";
|
||||
|
||||
const BACKLINKS_RESULTS_TABS: Array<{
|
||||
tab: BacklinksSearchState["tab"];
|
||||
@ -75,6 +79,7 @@ export function BacklinksOverviewPanels({
|
||||
}
|
||||
|
||||
export function BacklinksResultsCard({
|
||||
projectId,
|
||||
activeTab,
|
||||
filteredData,
|
||||
filters,
|
||||
@ -83,6 +88,7 @@ export function BacklinksResultsCard({
|
||||
exportTarget,
|
||||
onTabChange,
|
||||
}: {
|
||||
projectId: string;
|
||||
activeTab: BacklinksSearchState["tab"];
|
||||
filteredData: {
|
||||
backlinks: BacklinksOverviewData["backlinks"];
|
||||
@ -100,6 +106,27 @@ export function BacklinksResultsCard({
|
||||
() => 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 (
|
||||
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
||||
@ -122,25 +149,20 @@ export function BacklinksResultsCard({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ExportToSheetsButton
|
||||
<BacklinksExportMenu
|
||||
activeTab={activeTab}
|
||||
exportTarget={exportTarget}
|
||||
filteredData={filteredData}
|
||||
headers={exportTable.headers}
|
||||
rows={exportTable.rows}
|
||||
feature={`backlinks_${activeTab}`}
|
||||
className="btn-sm"
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost justify-start lg:justify-center"
|
||||
onClick={() =>
|
||||
exportBacklinksTabCsv({
|
||||
tab: activeTab,
|
||||
target: exportTarget,
|
||||
rows: filteredData,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
{activeTab !== "pages" ? (
|
||||
<BacklinksActionsMenu
|
||||
isLoadingRatings={isLoadingRatings}
|
||||
loadRatings={loadRatings}
|
||||
ratableDomains={ratableDomains}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -171,13 +193,19 @@ export function BacklinksResultsCard({
|
||||
</div>
|
||||
) : null}
|
||||
{activeTab === "backlinks" ? (
|
||||
<BacklinksTable rows={filteredData.backlinks} />
|
||||
<BacklinksTable
|
||||
rows={filteredData.backlinks}
|
||||
domainRatings={domainRatings}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === "domains" && isTabLoading && !tabErrorMessage ? (
|
||||
<TabLoadingState label="Loading referring domains" />
|
||||
) : null}
|
||||
{activeTab === "domains" && !isTabLoading && !tabErrorMessage ? (
|
||||
<ReferringDomainsTable rows={filteredData.referringDomains} />
|
||||
<ReferringDomainsTable
|
||||
rows={filteredData.referringDomains}
|
||||
domainRatings={domainRatings}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === "pages" && isTabLoading && !tabErrorMessage ? (
|
||||
<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({
|
||||
data,
|
||||
summaryStats,
|
||||
|
||||
@ -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 }],
|
||||
},
|
||||
|
||||
@ -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<GroupedBacklinkDomain>[] = [
|
||||
const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||
{
|
||||
id: "source",
|
||||
accessorKey: "domain",
|
||||
@ -319,3 +320,51 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
146
src/client/features/backlinks/BacklinksToolbarMenus.tsx
Normal file
146
src/client/features/backlinks/BacklinksToolbarMenus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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<ReferringDomainRow> = (left, right, columnId) => {
|
||||
);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
const baseColumns = [
|
||||
columnHelper.accessor("domain", {
|
||||
header: ({ column }) => (
|
||||
<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 }];
|
||||
|
||||
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<SortingState>(DEFAULT_SORTING);
|
||||
const columns = useMemo(
|
||||
() => buildReferringDomainColumns(domainRatings),
|
||||
[domainRatings],
|
||||
);
|
||||
|
||||
const table = useAppTable({
|
||||
data: rows,
|
||||
|
||||
66
src/client/features/backlinks/useAhrefsDomainRatings.ts
Normal file
66
src/client/features/backlinks/useAhrefsDomainRatings.ts
Normal 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 };
|
||||
}
|
||||
119
src/serverFunctions/ahrefs.ts
Normal file
119
src/serverFunctions/ahrefs.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user