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}
|
summaryStats={summaryStats}
|
||||||
/>
|
/>
|
||||||
<BacklinksResultsCard
|
<BacklinksResultsCard
|
||||||
|
projectId={projectId}
|
||||||
activeTab={searchState.tab}
|
activeTab={searchState.tab}
|
||||||
filteredData={filteredData}
|
filteredData={filteredData}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 }],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
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 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,
|
||||||
|
|||||||
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