Backlinks: server-side pagination, sorting, filters, and a one-per-domain view (#258)

This commit is contained in:
Ben Senescu 2026-06-10 23:27:04 -04:00 committed by GitHub
parent 977c69c3de
commit 16b92ccea3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 2973 additions and 2156 deletions

View File

@ -42,22 +42,45 @@ async function main() {
const includeTabs = parseBoolean(args.includeTabs, true); const includeTabs = parseBoolean(args.includeTabs, true);
const runs = []; const runs = [];
const pageInput = {
...input,
page: 1,
pageSize: 100,
sortOrder: "desc",
} as const;
for (let index = 0; index < repeat; index += 1) { for (let index = 0; index < repeat; index += 1) {
const overview = await service.profileOverview(input, billingCustomer); const overview = await service.profileOverview(input, billingCustomer);
const rows = includeTabs
? await service.profileBacklinksPage(
{ ...pageInput, sortField: "rank", filters: {}, mode: "as_is" },
billingCustomer,
)
: null;
const domains = includeTabs const domains = includeTabs
? await service.profileReferringDomains(input, billingCustomer) ? await service.profileReferringDomainsPage(
{ ...pageInput, sortField: "backlinks", filters: {} },
billingCustomer,
)
: null; : null;
const pages = includeTabs const pages = includeTabs
? await service.profileTopPages(input, billingCustomer) ? await service.profileTopPagesPage(
{ ...pageInput, sortField: "backlinks", filters: {} },
billingCustomer,
)
: null; : null;
runs.push({ runs.push({
run: index + 1, run: index + 1,
overview: { overview: {
backlinksRows: overview.overview.backlinks.length,
trendRows: overview.overview.trends.length, trendRows: overview.overview.trends.length,
newLostRows: overview.overview.newLostTrends.length, newLostRows: overview.overview.newLostTrends.length,
}, },
backlinksTab: rows
? {
rows: rows.rows.length,
}
: null,
domainsTab: domains domainsTab: domains
? { ? {
rows: domains.rows.length, rows: domains.rows.length,

View File

@ -0,0 +1,97 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
type Props = {
page: number;
pageSize: number;
pageSizes: readonly number[];
totalCount: number | null;
hasNextPage: boolean;
isLoading: boolean;
onPageChange: (nextPage: number) => void;
onPageSizeChange: (nextPageSize: number) => void;
};
function formatRange(
page: number,
pageSize: number,
totalCount: number | null,
) {
const start = (page - 1) * pageSize + 1;
if (totalCount == null) {
return `${start.toLocaleString()}${(start + pageSize - 1).toLocaleString()}`;
}
if (totalCount === 0) return "0";
const end = Math.min(totalCount, start + pageSize - 1);
return `${start.toLocaleString()}${end.toLocaleString()} of ${totalCount.toLocaleString()}`;
}
export function TablePagination({
page,
pageSize,
pageSizes,
totalCount,
hasNextPage,
isLoading,
onPageChange,
onPageSizeChange,
}: Props) {
const totalPages =
totalCount != null ? Math.max(1, Math.ceil(totalCount / pageSize)) : null;
const canGoPrev = page > 1;
const canGoNext = totalPages != null ? page < totalPages : hasNextPage;
return (
<div className="flex flex-col gap-3 border-t border-base-300 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm text-base-content/70 tabular-nums">
<span>{formatRange(page, pageSize, totalCount)}</span>
{isLoading ? (
<span className="loading loading-spinner loading-xs" />
) : null}
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm text-base-content/70">
<span className="whitespace-nowrap">Rows per page</span>
<select
className="select select-bordered select-sm w-20"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
>
{pageSizes.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-sm tabular-nums text-base-content/70">
Page {page.toLocaleString()}
{totalPages != null ? ` of ${totalPages.toLocaleString()}` : ""}
</span>
<div className="flex items-center gap-1">
<button
type="button"
aria-label="Previous page"
className="btn btn-ghost btn-sm btn-square"
disabled={!canGoPrev || isLoading}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
aria-label="Next page"
className="btn btn-ghost btn-sm btn-square"
disabled={!canGoNext || isLoading}
onClick={() => onPageChange(page + 1)}
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -7,20 +7,16 @@ import type { Row } from "@tanstack/react-table";
* direction from the cell context and return a value that survives the flip. * direction from the cell context and return a value that survives the flip.
*/ */
export function isDescending<TData>( function isDescending<TData>(row: Row<TData>, columnId: string): boolean {
row: Row<TData>,
columnId: string,
): boolean {
const cell = row.getAllCells().find((c) => c.column.id === columnId); const cell = row.getAllCells().find((c) => c.column.id === columnId);
return cell?.column.getIsSorted() === "desc"; return cell?.column.getIsSorted() === "desc";
} }
/** /**
* Compare two nullable numeric values with nulls always at the bottom, * Compare two nullable numeric values with nulls always at the bottom,
* regardless of the column's current sort direction. Use this directly for * regardless of the column's current sort direction.
* tiebreakers or when the value isn't the column's accessor.
*/ */
export function compareNumericNullsLast( function compareNumericNullsLast(
a: number | null | undefined, a: number | null | undefined,
b: number | null | undefined, b: number | null | undefined,
descending: boolean, descending: boolean,
@ -44,33 +40,3 @@ export function numericNullsLast<TData>(
isDescending(rowA, columnId), isDescending(rowA, columnId),
); );
} }
export function stringNullsLast<TData>(
rowA: Row<TData>,
rowB: Row<TData>,
columnId: string,
): number {
const a = rowA.getValue<string | null | undefined>(columnId);
const b = rowB.getValue<string | null | undefined>(columnId);
if (!a && !b) return 0;
if (!a || !b) {
const sign = isDescending(rowA, columnId) ? -1 : 1;
return (!a ? 1 : -1) * sign;
}
return a.toLowerCase().localeCompare(b.toLowerCase());
}
export function dateNullsLast<TData>(
rowA: Row<TData>,
rowB: Row<TData>,
columnId: string,
): number {
const a = rowA.getValue<string | null | undefined>(columnId);
const b = rowB.getValue<string | null | undefined>(columnId);
if (!a && !b) return 0;
if (!a || !b) {
const sign = isDescending(rowA, columnId) ? -1 : 1;
return (!a ? 1 : -1) * sign;
}
return Date.parse(a) - Date.parse(b);
}

View File

@ -1,177 +1,194 @@
import { RotateCcw } from "lucide-react"; import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
import type { BacklinksTab } from "@/types/schemas/backlinks"; import type { BacklinksTab } from "@/types/schemas/backlinks";
import {
BACKLINKS_FILTER_FIELDS,
REFERRING_DOMAINS_FILTER_FIELDS,
TOP_PAGES_FILTER_FIELDS,
countFilterConditions,
type BacklinksTabFilterValues,
} from "./backlinksFilterTypes";
import type { BacklinksFiltersState } from "./useBacklinksFilters"; import type { BacklinksFiltersState } from "./useBacklinksFilters";
// eslint-disable-next-line @typescript-eslint/no-explicit-any /**
type AnyForm = { Field: React.ComponentType<any> }; * Filters are applied explicitly (not per keystroke) because every change
* triggers a billed DataForSEO request. Each include/exclude term and each
function FilterTextInput({ * set field costs one DataForSEO filter condition, capped per request
form, * DomainFilterPanel surfaces the count and gates Apply.
name, */
label, export function BacklinksFilterPanel({
placeholder, activeTab,
filters,
onApplied,
}: { }: {
form: AnyForm; activeTab: BacklinksTab;
name: string; filters: BacklinksFiltersState;
label: string; onApplied: () => void;
placeholder: string;
}) { }) {
if (activeTab === "backlinks") {
const state = filters.backlinks;
return ( return (
<label className="form-control gap-1.5"> <DomainFilterPanel
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60"> key="backlinks"
{label} debugName="BacklinksFilterPanel"
</span> appliedFilters={state.values}
<form.Field name={name}> fields={BACKLINKS_FILTER_FIELDS}
{(field: { activeFilterCount={state.activeFilterCount}
state: { value: string }; countConditions={countFilterConditions}
handleChange: (v: string) => void; textFields={[
}) => ( {
<input key: "include",
className="input input-bordered input-sm w-full bg-base-100" label: "Source URL Contains",
placeholder={placeholder} placeholder: "example.com, blog",
value={field.state.value} },
onChange={(event) => field.handleChange(event.target.value)} {
/> key: "exclude",
label: "Source URL Excludes",
placeholder: "spam, forum",
},
]}
rangeFields={[
{
title: "Domain Authority",
minKey: "minDomainRank",
maxKey: "maxDomainRank",
},
{
title: "Link Authority",
minKey: "minLinkAuthority",
maxKey: "maxLinkAuthority",
},
{
title: "Spam Score",
minKey: "minSpamScore",
maxKey: "maxSpamScore",
step: "0.1",
},
]}
onApply={(values) => {
state.apply(values);
onApplied();
}}
onClear={() => {
state.reset();
onApplied();
}}
renderExtra={(draft, setValue) => (
<BacklinksToggleControls draft={draft} setValue={setValue} />
)} )}
</form.Field> />
</label> );
}
if (activeTab === "domains") {
const state = filters.domains;
return (
<DomainFilterPanel
key="domains"
debugName="ReferringDomainsFilterPanel"
appliedFilters={state.values}
fields={REFERRING_DOMAINS_FILTER_FIELDS}
activeFilterCount={state.activeFilterCount}
countConditions={countFilterConditions}
textFields={[
{
key: "include",
label: "Domain Contains",
placeholder: "example.com, blog",
},
{
key: "exclude",
label: "Domain Excludes",
placeholder: "spam, forum",
},
]}
rangeFields={[
{
title: "Backlinks",
minKey: "minBacklinks",
maxKey: "maxBacklinks",
},
{ title: "Rank", minKey: "minRank", maxKey: "maxRank" },
{
title: "Spam Score",
minKey: "minSpamScore",
maxKey: "maxSpamScore",
step: "0.1",
},
]}
onApply={(values) => {
state.apply(values);
onApplied();
}}
onClear={() => {
state.reset();
onApplied();
}}
/>
);
}
const state = filters.pages;
return (
<DomainFilterPanel
key="pages"
debugName="TopPagesFilterPanel"
appliedFilters={state.values}
fields={TOP_PAGES_FILTER_FIELDS}
activeFilterCount={state.activeFilterCount}
countConditions={countFilterConditions}
textFields={[
{
key: "include",
label: "Page URL Contains",
placeholder: "/blog, /products",
},
{
key: "exclude",
label: "Page URL Excludes",
placeholder: "/tag, /author",
},
]}
rangeFields={[
{ title: "Backlinks", minKey: "minBacklinks", maxKey: "maxBacklinks" },
{
title: "Referring Domains",
minKey: "minReferringDomains",
maxKey: "maxReferringDomains",
},
{ title: "Rank", minKey: "minRank", maxKey: "maxRank" },
]}
onApply={(values) => {
state.apply(values);
onApplied();
}}
onClear={() => {
state.reset();
onApplied();
}}
/>
); );
} }
function FilterRangeInputs({ function BacklinksToggleControls({
form, draft,
title, setValue,
minName,
maxName,
step,
}: { }: {
form: AnyForm; draft: BacklinksTabFilterValues;
title: string; setValue: (key: keyof BacklinksTabFilterValues, value: string) => void;
minName: string;
maxName: string;
step?: string;
}) { }) {
return ( return (
<div className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{title}
</p>
<div className="grid grid-cols-2 gap-2">
<CompactRangeInput
form={form}
name={minName}
placeholder="Min"
step={step}
/>
<CompactRangeInput
form={form}
name={maxName}
placeholder="Max"
step={step}
/>
</div>
</div>
);
}
function CompactRangeInput({
form,
name,
placeholder,
step,
}: {
form: AnyForm;
name: string;
placeholder: string;
step?: string;
}) {
return (
<form.Field name={name}>
{(field: {
state: { value: string };
handleChange: (v: string) => void;
}) => (
<input
className="input input-bordered input-xs bg-base-100"
placeholder={placeholder}
type="number"
step={step}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
);
}
function BacklinksTabFilters({
form,
showAhrefsDrFilter,
}: {
form: BacklinksFiltersState["backlinks"]["form"];
showAhrefsDrFilter: boolean;
}) {
return (
<>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<FilterTextInput
form={form}
name="include"
label="Include Terms"
placeholder="example.com, blog"
/>
<FilterTextInput
form={form}
name="exclude"
label="Exclude Terms"
placeholder="spam, forum"
/>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
<FilterRangeInputs
form={form}
title="Domain Authority"
minName="minDomainRank"
maxName="maxDomainRank"
/>
{showAhrefsDrFilter ? (
<FilterRangeInputs
form={form}
title="Ahrefs DR"
minName="minAhrefsDr"
maxName="maxAhrefsDr"
/>
) : null}
<FilterRangeInputs
form={form}
title="Link Authority"
minName="minLinkAuthority"
maxName="maxLinkAuthority"
/>
<FilterRangeInputs
form={form}
title="Spam Score"
minName="minSpamScore"
maxName="maxSpamScore"
step="0.1"
/>
</div>
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60"> <p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
Link Type Link Type
</p> </p>
<form.Field name="linkType">
{(field) => (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{(["", "dofollow", "nofollow"] as const).map((value) => ( {(["", "dofollow", "nofollow"] as const).map((value) => (
<button <button
key={value || "all"} key={value || "all"}
type="button" type="button"
className={`btn btn-xs ${field.state.value === value ? "btn-soft" : "btn-ghost"}`} className={`btn btn-xs ${draft.linkType === value ? "btn-soft" : "btn-ghost"}`}
onClick={() => field.handleChange(value)} onClick={() => setValue("linkType", value)}
> >
{value === "" {value === ""
? "All" ? "All"
@ -181,8 +198,6 @@ function BacklinksTabFilters({
</button> </button>
))} ))}
</div> </div>
)}
</form.Field>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -190,196 +205,30 @@ function BacklinksTabFilters({
Visibility Visibility
</p> </p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<form.Field name="hideLost">
{(field) => (
<label className="flex items-center gap-1.5 cursor-pointer"> <label className="flex items-center gap-1.5 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
className="checkbox checkbox-xs" className="checkbox checkbox-xs"
checked={field.state.value === "true"} checked={draft.hideLost === "true"}
onChange={(event) => onChange={(event) =>
field.handleChange(event.target.checked ? "true" : "") setValue("hideLost", event.target.checked ? "true" : "")
} }
/> />
<span className="text-xs">Hide lost</span> <span className="text-xs">Hide lost</span>
</label> </label>
)}
</form.Field>
<form.Field name="hideBroken">
{(field) => (
<label className="flex items-center gap-1.5 cursor-pointer"> <label className="flex items-center gap-1.5 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
className="checkbox checkbox-xs" className="checkbox checkbox-xs"
checked={field.state.value === "true"} checked={draft.hideBroken === "true"}
onChange={(event) => onChange={(event) =>
field.handleChange(event.target.checked ? "true" : "") setValue("hideBroken", event.target.checked ? "true" : "")
} }
/> />
<span className="text-xs">Hide broken</span> <span className="text-xs">Hide broken</span>
</label> </label>
)}
</form.Field>
</div> </div>
</div> </div>
</div> </div>
</>
);
}
function ReferringDomainsFilters({
form,
showAhrefsDrFilter,
}: {
form: BacklinksFiltersState["domains"]["form"];
showAhrefsDrFilter: boolean;
}) {
return (
<>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<FilterTextInput
form={form}
name="include"
label="Include Terms"
placeholder="example.com, blog"
/>
<FilterTextInput
form={form}
name="exclude"
label="Exclude Terms"
placeholder="spam, forum"
/>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
<FilterRangeInputs
form={form}
title="Backlinks"
minName="minBacklinks"
maxName="maxBacklinks"
/>
<FilterRangeInputs
form={form}
title="Rank"
minName="minRank"
maxName="maxRank"
/>
{showAhrefsDrFilter ? (
<FilterRangeInputs
form={form}
title="Ahrefs DR"
minName="minAhrefsDr"
maxName="maxAhrefsDr"
/>
) : null}
<FilterRangeInputs
form={form}
title="Spam Score"
minName="minSpamScore"
maxName="maxSpamScore"
step="0.1"
/>
</div>
</>
);
}
function TopPagesFilters({
form,
}: {
form: BacklinksFiltersState["pages"]["form"];
}) {
return (
<>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<FilterTextInput
form={form}
name="include"
label="Include Terms"
placeholder="/blog, /products"
/>
<FilterTextInput
form={form}
name="exclude"
label="Exclude Terms"
placeholder="/tag, /author"
/>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
<FilterRangeInputs
form={form}
title="Backlinks"
minName="minBacklinks"
maxName="maxBacklinks"
/>
<FilterRangeInputs
form={form}
title="Referring Domains"
minName="minReferringDomains"
maxName="maxReferringDomains"
/>
<FilterRangeInputs
form={form}
title="Rank"
minName="minRank"
maxName="maxRank"
/>
</div>
</>
);
}
export function BacklinksFilterPanel({
activeTab,
filters,
showAhrefsDrFilter,
activeFilterCount,
}: {
activeTab: BacklinksTab;
filters: BacklinksFiltersState;
showAhrefsDrFilter: boolean;
activeFilterCount: number;
}) {
const current = filters[activeTab];
return (
<div className="shrink-0 border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">Refine results</p>
{activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount} active
</span>
) : null}
</div>
<button
type="button"
className="btn btn-xs btn-ghost gap-1"
onClick={current.reset}
disabled={activeFilterCount === 0}
>
<RotateCcw className="size-3" />
Clear all
</button>
</div>
{activeTab === "backlinks" ? (
<BacklinksTabFilters
form={filters.backlinks.form}
showAhrefsDrFilter={showAhrefsDrFilter}
/>
) : null}
{activeTab === "domains" ? (
<ReferringDomainsFilters
form={filters.domains.form}
showAhrefsDrFilter={showAhrefsDrFilter}
/>
) : null}
{activeTab === "pages" ? (
<TopPagesFilters form={filters.pages.form} />
) : null}
</div>
); );
} }

View File

@ -55,6 +55,9 @@ export function BacklinksHistorySection({
target: item.target, target: item.target,
scope: item.scope, scope: item.scope,
tab: undefined, tab: undefined,
page: undefined,
sort: undefined,
order: undefined,
})} })}
replace replace
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200" className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"

View File

@ -0,0 +1,152 @@
import { Link } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import {
BacklinksNewLostChart,
BacklinksTrendChart,
} from "./BacklinksPageCharts";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import { formatRelativeTimestamp } from "./backlinksPageUtils";
type SummaryStat = { label: string; value: string; description: string };
export function BacklinksOverviewPanels({
projectId,
data,
summaryStats,
}: {
projectId: string;
data: BacklinksOverviewData;
summaryStats: SummaryStat[];
}) {
return (
<>
<div>
<Link
to="/p/$projectId/backlinks"
params={{ projectId }}
search={{
target: undefined,
scope: undefined,
tab: undefined,
page: undefined,
size: undefined,
sort: undefined,
order: undefined,
}}
replace
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
>
<ArrowLeft className="size-4" />
Recent searches
</Link>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
<span className="badge badge-outline">{data.scope}</span>
<span>Target: {data.displayTarget}</span>
<span>-</span>
<span>Updated {formatRelativeTimestamp(data.fetchedAt)}</span>
</div>
<OverviewGrid data={data} summaryStats={summaryStats} />
{data.scope === "page" ? (
<div className="alert alert-info">
<span>
Showing backlinks for this exact page. Enter a bare domain for
site-wide results. Trend charts are only shown for domain-level
lookups.
</span>
</div>
) : null}
</>
);
}
function OverviewGrid({
data,
summaryStats,
}: {
data: BacklinksOverviewData;
summaryStats: SummaryStat[];
}) {
const domainScope = data.scope === "domain";
return (
<div
className={`grid grid-cols-1 gap-3 ${domainScope ? "md:grid-cols-2 xl:grid-cols-3" : ""}`}
>
<SummaryStatsGrid data={data} summaryStats={summaryStats} />
{domainScope ? <TrendPanels data={data} /> : null}
</div>
);
}
function SummaryStatsGrid({
data,
summaryStats,
}: {
data: BacklinksOverviewData;
summaryStats: SummaryStat[];
}) {
const cardClassName = `card bg-base-100 border border-base-300 ${data.scope === "domain" ? "md:col-span-2 xl:col-span-1" : ""}`;
return (
<div className={cardClassName}>
<div className="card-body p-4 xl:h-full">
<div className="grid grid-cols-2 gap-x-6 gap-y-5 xl:gap-y-6">
{summaryStats.map((item) => (
<div key={item.label}>
<div className="text-xs uppercase tracking-wide text-base-content/55">
<HeaderHelpLabel
label={item.label}
helpText={item.description}
/>
</div>
<p className="text-2xl font-semibold">{item.value}</p>
</div>
))}
</div>
</div>
</div>
);
}
function TrendPanels({ data }: { data: BacklinksOverviewData }) {
return (
<>
<TrendCard
title="Backlink growth"
description="Backlinks and referring domains over the last year"
>
<BacklinksTrendChart data={data.trends} />
</TrendCard>
<TrendCard
title="New vs lost"
description="Backlink acquisition and attrition"
>
<BacklinksNewLostChart data={data.newLostTrends} />
</TrendCard>
</>
);
}
function TrendCard({
children,
description,
title,
}: {
children: React.ReactNode;
description: string;
title: string;
}) {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-2 p-4">
<div>
<h2 className="text-sm font-medium">{title}</h2>
<p className="text-xs text-base-content/55">{description}</p>
</div>
{children}
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import type { SortingState, Updater } from "@tanstack/react-table";
import { BacklinksSearchCard } from "./BacklinksSearchCard"; import { BacklinksSearchCard } from "./BacklinksSearchCard";
import { BacklinksBody } from "./BacklinksPageContent"; import { BacklinksBody } from "./BacklinksPageContent";
import type { BacklinksPageProps } from "./backlinksPageTypes"; import type { BacklinksPageProps } from "./backlinksPageTypes";
@ -7,6 +8,7 @@ import {
navigateToBacklinksSearch, navigateToBacklinksSearch,
useBacklinksPageData, useBacklinksPageData,
} from "./useBacklinksPageData"; } from "./useBacklinksPageData";
import { useBacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
import { useBacklinksFilters } from "./useBacklinksFilters"; import { useBacklinksFilters } from "./useBacklinksFilters";
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory"; import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
import type { import type {
@ -14,6 +16,10 @@ import type {
SearchTabInput, SearchTabInput,
} from "@/client/features/search-tabs/types"; } from "@/client/features/search-tabs/types";
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation"; import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
import {
BACKLINKS_DEFAULT_SORT,
DEFAULT_BACKLINKS_PAGE_SIZE,
} from "@/types/schemas/backlinks";
export function BacklinksPage({ export function BacklinksPage({
projectId, projectId,
@ -21,18 +27,94 @@ export function BacklinksPage({
navigate, navigate,
}: BacklinksPageProps) { }: BacklinksPageProps) {
const filters = useBacklinksFilters(); const filters = useBacklinksFilters();
// Sort lives in the URL so sort changes and the page reset commit in one
// navigation (no transient fetch of the old page with the new sort).
const sorting = useMemo<SortingState>(() => {
const fallback = BACKLINKS_DEFAULT_SORT[searchState.tab];
const field = searchState.sort ?? fallback.field;
const order =
searchState.order ?? (searchState.sort ? "desc" : fallback.order);
return [{ id: field, desc: order === "desc" }];
}, [searchState.order, searchState.sort, searchState.tab]);
const handleSortingChange = useCallback(
(updater: Updater<SortingState>) => {
const next = typeof updater === "function" ? updater(sorting) : updater;
const first = next[0];
navigate({
search: (prev) => ({
...prev,
sort: first?.id,
order: first ? (first.desc ? "desc" : "asc") : undefined,
page: undefined,
}),
replace: true,
});
},
[navigate, sorting],
);
const handlePageChange = useCallback(
(nextPage: number) => {
navigate({
search: (prev) => ({
...prev,
page: nextPage === 1 ? undefined : nextPage,
}),
replace: true,
});
},
[navigate],
);
const handlePageSizeChange = useCallback(
(nextPageSize: number) => {
navigate({
search: (prev) => ({
...prev,
size:
nextPageSize === DEFAULT_BACKLINKS_PAGE_SIZE
? undefined
: nextPageSize,
page: undefined,
}),
replace: true,
});
},
[navigate],
);
const handleViewChange = useCallback(
(nextView: "all" | undefined) => {
navigate({
search: (prev) => ({ ...prev, view: nextView, page: undefined }),
replace: true,
});
},
[navigate],
);
const domainExpansion = useBacklinksDomainExpansion({
projectId,
searchState,
});
const { const {
accessGate, accessGate,
activeTabErrorMessage, activeTabErrorMessage,
activeTabQuery,
backlinksDisabledByError, backlinksDisabledByError,
overviewErrorMessage, overviewErrorMessage,
overviewQuery, overviewQuery,
referringDomainsQuery, referringDomainsQuery,
rowsQuery,
searchCardInitialValues, searchCardInitialValues,
topPagesQuery, topPagesQuery,
} = useBacklinksPageData({ } = useBacklinksPageData({
projectId, projectId,
searchState, searchState,
filters,
}); });
const { const {
@ -71,6 +153,9 @@ export function BacklinksPage({
search: (prev) => ({ search: (prev) => ({
...prev, ...prev,
tab: tab === "backlinks" ? undefined : tab, tab: tab === "backlinks" ? undefined : tab,
page: undefined,
sort: undefined,
order: undefined,
}), }),
replace: true, replace: true,
}); });
@ -134,19 +219,23 @@ export function BacklinksPage({
overviewData={overviewQuery.data} overviewData={overviewQuery.data}
overviewError={overviewErrorMessage} overviewError={overviewErrorMessage}
overviewLoading={overviewQuery.isLoading} overviewLoading={overviewQuery.isLoading}
referringDomains={referringDomainsQuery.data} backlinksRowsPage={rowsQuery.data}
referringDomainsPage={referringDomainsQuery.data}
topPagesPage={topPagesQuery.data}
searchState={searchState} searchState={searchState}
filters={filters} filters={filters}
sorting={sorting}
domainExpansion={domainExpansion}
tabErrorMessage={activeTabErrorMessage} tabErrorMessage={activeTabErrorMessage}
tabLoading={ tabLoading={activeTabQuery.isLoading}
(searchState.tab === "domains" && tabFetching={activeTabQuery.isFetching}
referringDomainsQuery.isLoading) || onPageChange={handlePageChange}
(searchState.tab === "pages" && topPagesQuery.isLoading) onPageSizeChange={handlePageSizeChange}
}
topPages={topPagesQuery.data}
onRemoveHistoryItem={removeHistoryItem} onRemoveHistoryItem={removeHistoryItem}
onRetryOverview={() => void overviewQuery.refetch()} onRetryOverview={() => void overviewQuery.refetch()}
onSortingChange={handleSortingChange}
onTabChange={handleResultTabChange} onTabChange={handleResultTabChange}
onViewChange={handleViewChange}
searchTabs={ searchTabs={
searchState.target searchState.target
? { ? {

View File

@ -1,8 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { import type { OnChangeFn, SortingState } from "@tanstack/react-table";
BacklinksOverviewPanels, import { BacklinksOverviewPanels } from "./BacklinksOverviewPanels";
BacklinksResultsCard, import { BacklinksResultsCard } from "./BacklinksPageSections";
} from "./BacklinksPageSections";
import { import {
BacklinksErrorState, BacklinksErrorState,
BacklinksLoadingState, BacklinksLoadingState,
@ -13,17 +12,15 @@ import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSear
import type { import type {
BacklinksOverviewData, BacklinksOverviewData,
BacklinksReferringDomainsData, BacklinksReferringDomainsData,
BacklinksRowsPageData,
BacklinksSearchState, BacklinksSearchState,
BacklinksTabRows,
BacklinksTopPagesData, BacklinksTopPagesData,
} from "./backlinksPageTypes"; } from "./backlinksPageTypes";
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate"; import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate"; import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
import { buildSummaryStats } from "./backlinksPageUtils"; import { buildSummaryStats } from "./backlinksPageUtils";
import { import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
filterBacklinkRows,
filterReferringDomainRows,
filterTopPageRows,
} from "./backlinksFiltering";
import type { BacklinksFiltersState } from "./useBacklinksFilters"; import type { BacklinksFiltersState } from "./useBacklinksFilters";
import { import {
SearchTabStrip, SearchTabStrip,
@ -39,15 +36,23 @@ type BacklinksBodyProps = {
overviewData: BacklinksOverviewData | undefined; overviewData: BacklinksOverviewData | undefined;
overviewError: string | null; overviewError: string | null;
overviewLoading: boolean; overviewLoading: boolean;
referringDomains: BacklinksReferringDomainsData | undefined; backlinksRowsPage: BacklinksRowsPageData | undefined;
referringDomainsPage: BacklinksReferringDomainsData | undefined;
topPagesPage: BacklinksTopPagesData | undefined;
searchState: BacklinksSearchState; searchState: BacklinksSearchState;
filters: BacklinksFiltersState; filters: BacklinksFiltersState;
sorting: SortingState;
domainExpansion: BacklinksDomainExpansion;
tabErrorMessage: string | null; tabErrorMessage: string | null;
tabLoading: boolean; tabLoading: boolean;
topPages: BacklinksTopPagesData | undefined; tabFetching: boolean;
onPageChange: (nextPage: number) => void;
onPageSizeChange: (nextPageSize: number) => void;
onRemoveHistoryItem: (timestamp: number) => void; onRemoveHistoryItem: (timestamp: number) => void;
onRetryOverview: () => void; onRetryOverview: () => void;
onSortingChange: OnChangeFn<SortingState>;
onTabChange: (tab: BacklinksSearchState["tab"]) => void; onTabChange: (tab: BacklinksSearchState["tab"]) => void;
onViewChange: (view: "all" | undefined) => void;
searchTabs: { searchTabs: {
activeTabId: string | null; activeTabId: string | null;
tabs: SearchTab[]; tabs: SearchTab[];
@ -66,45 +71,42 @@ export function BacklinksBody({
overviewData, overviewData,
overviewError, overviewError,
overviewLoading, overviewLoading,
referringDomains, backlinksRowsPage,
referringDomainsPage,
topPagesPage,
searchState, searchState,
filters, filters,
sorting,
domainExpansion,
tabErrorMessage, tabErrorMessage,
tabLoading, tabLoading,
topPages, tabFetching,
onPageChange,
onPageSizeChange,
onRemoveHistoryItem, onRemoveHistoryItem,
onRetryOverview, onRetryOverview,
onSortingChange,
onTabChange, onTabChange,
onViewChange,
searchTabs, searchTabs,
}: BacklinksBodyProps) { }: BacklinksBodyProps) {
const mergedData = useMemo( const tabRows = useMemo<BacklinksTabRows>(
() => mergeTabData(overviewData, referringDomains, topPages), () => ({
[overviewData, referringDomains, topPages], backlinks: backlinksRowsPage?.rows ?? [],
referringDomains: referringDomainsPage?.rows ?? [],
topPages: topPagesPage?.rows ?? [],
}),
[backlinksRowsPage, referringDomainsPage, topPagesPage],
); );
const filteredData = useMemo(() => { const activeTabPage =
if (!mergedData) { searchState.tab === "backlinks"
return { backlinks: [], referringDomains: [], topPages: [] }; ? backlinksRowsPage
} : searchState.tab === "domains"
return { ? referringDomainsPage
backlinks: filterBacklinkRows( : topPagesPage;
mergedData.backlinks,
filters.backlinks.values,
),
referringDomains: filterReferringDomainRows(
mergedData.referringDomains,
filters.domains.values,
),
topPages: filterTopPageRows(mergedData.topPages, filters.pages.values),
};
}, [
mergedData,
filters.backlinks.values,
filters.domains.values,
filters.pages.values,
]);
const summaryStats = useMemo( const summaryStats = useMemo(
() => buildSummaryStats(mergedData), () => buildSummaryStats(overviewData),
[mergedData], [overviewData],
); );
const tabStrip = searchTabs ? ( const tabStrip = searchTabs ? (
<SearchTabStrip <SearchTabStrip
@ -160,7 +162,7 @@ export function BacklinksBody({
); );
} }
if (!mergedData) { if (!overviewData) {
return ( return (
<> <>
{tabStrip} {tabStrip}
@ -177,37 +179,33 @@ export function BacklinksBody({
{tabStrip} {tabStrip}
<BacklinksOverviewPanels <BacklinksOverviewPanels
projectId={projectId} projectId={projectId}
data={mergedData} data={overviewData}
summaryStats={summaryStats} summaryStats={summaryStats}
/> />
<BacklinksResultsCard <BacklinksResultsCard
projectId={projectId} projectId={projectId}
activeTab={searchState.tab} activeTab={searchState.tab}
filteredData={filteredData} tabRows={tabRows}
filters={filters} filters={filters}
isTabLoading={searchState.tab !== "backlinks" && tabLoading} sorting={sorting}
tabErrorMessage={ view={searchState.view}
searchState.tab !== "backlinks" ? tabErrorMessage : null domainExpansion={domainExpansion}
} isTabLoading={tabLoading}
exportTarget={mergedData.displayTarget || searchState.target} tabErrorMessage={tabErrorMessage}
exportTarget={overviewData.displayTarget || searchState.target}
pagination={{
page: searchState.page,
pageSize: searchState.pageSize,
totalCount: activeTabPage?.totalCount ?? null,
hasNextPage: activeTabPage?.hasMore ?? false,
isFetching: tabFetching,
}}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onSortingChange={onSortingChange}
onTabChange={onTabChange} onTabChange={onTabChange}
onViewChange={onViewChange}
/> />
</> </>
); );
} }
function mergeTabData(
data: BacklinksOverviewData | undefined,
referringDomains: BacklinksReferringDomainsData | undefined,
topPages: BacklinksTopPagesData | undefined,
) {
if (!data) {
return undefined;
}
return {
...data,
referringDomains: referringDomains ?? data.referringDomains,
topPages: topPages ?? data.topPages,
};
}

View File

@ -1,34 +1,28 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Link } from "@tanstack/react-router"; import { SlidersHorizontal } from "lucide-react";
import { HeaderHelpLabel } from "@/client/features/keywords/components"; import type { OnChangeFn, SortingState } from "@tanstack/react-table";
import { ArrowLeft, SlidersHorizontal } from "lucide-react";
import {
BacklinksNewLostChart,
BacklinksTrendChart,
} from "./BacklinksPageCharts";
import { BacklinksFilterPanel } from "./BacklinksFilterPanel"; import { BacklinksFilterPanel } from "./BacklinksFilterPanel";
import { BacklinksTable } from "./BacklinksTable"; import { BacklinksTable } from "./BacklinksTable";
import { ReferringDomainsTable } from "./ReferringDomainsTable"; import { ReferringDomainsTable } from "./ReferringDomainsTable";
import { TopPagesTable } from "./TopPagesTable"; import { TopPagesTable } from "./TopPagesTable";
import type { import type {
BacklinksOverviewData,
BacklinksSearchState, BacklinksSearchState,
BacklinksTabRows,
} from "./backlinksPageTypes"; } from "./backlinksPageTypes";
import { import { TAB_DESCRIPTIONS } from "./backlinksPageUtils";
TAB_DESCRIPTIONS,
formatRelativeTimestamp,
} from "./backlinksPageUtils";
import { import {
BacklinksActionsMenu, BacklinksActionsMenu,
BacklinksExportMenu, BacklinksExportMenu,
} from "./BacklinksToolbarMenus"; } from "./BacklinksToolbarMenus";
import { buildBacklinksTabExport } from "./export"; import { buildBacklinksTabExport } from "./export";
import { import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
filterBacklinkRows,
filterReferringDomainRows,
} from "./backlinksFiltering";
import type { BacklinksFiltersState } from "./useBacklinksFilters"; import type { BacklinksFiltersState } from "./useBacklinksFilters";
import { useAhrefsDomainRatings } from "./useAhrefsDomainRatings"; import { useAhrefsDomainRatings } from "./useAhrefsDomainRatings";
import { TablePagination } from "@/client/components/table/TablePagination";
import {
BACKLINKS_PAGE_SIZES,
type BacklinksTab,
} from "@/types/schemas/backlinks";
const BACKLINKS_RESULTS_TABS: Array<{ const BACKLINKS_RESULTS_TABS: Array<{
tab: BacklinksSearchState["tab"]; tab: BacklinksSearchState["tab"];
@ -39,118 +33,66 @@ const BACKLINKS_RESULTS_TABS: Array<{
{ tab: "pages", label: "Top Pages" }, { tab: "pages", label: "Top Pages" },
]; ];
export function BacklinksOverviewPanels({
projectId,
data,
summaryStats,
}: {
projectId: string;
data: BacklinksOverviewData;
summaryStats: Array<{ label: string; value: string; description: string }>;
}) {
return (
<>
<div>
<Link
to="/p/$projectId/backlinks"
params={{ projectId }}
search={{ target: undefined, scope: undefined, tab: undefined }}
replace
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
>
<ArrowLeft className="size-4" />
Recent searches
</Link>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
<span className="badge badge-outline">{data.scope}</span>
<span>Target: {data.displayTarget}</span>
<span>-</span>
<span>Updated {formatRelativeTimestamp(data.fetchedAt)}</span>
</div>
<OverviewGrid data={data} summaryStats={summaryStats} />
{data.scope === "page" ? (
<div className="alert alert-info">
<span>
Showing backlinks for this exact page. Enter a bare domain for
site-wide results. Trend charts are only shown for domain-level
lookups.
</span>
</div>
) : null}
</>
);
}
export function BacklinksResultsCard({ export function BacklinksResultsCard({
projectId, projectId,
activeTab, activeTab,
filteredData, tabRows,
filters, filters,
sorting,
view,
domainExpansion,
isTabLoading, isTabLoading,
tabErrorMessage, tabErrorMessage,
exportTarget, exportTarget,
pagination,
onPageChange,
onPageSizeChange,
onSortingChange,
onTabChange, onTabChange,
onViewChange,
}: { }: {
projectId: string; projectId: string;
activeTab: BacklinksSearchState["tab"]; activeTab: BacklinksSearchState["tab"];
filteredData: { tabRows: BacklinksTabRows;
backlinks: BacklinksOverviewData["backlinks"];
referringDomains: BacklinksOverviewData["referringDomains"];
topPages: BacklinksOverviewData["topPages"];
};
filters: BacklinksFiltersState; filters: BacklinksFiltersState;
sorting: SortingState;
view: "all" | undefined;
domainExpansion: BacklinksDomainExpansion;
isTabLoading: boolean; isTabLoading: boolean;
tabErrorMessage: string | null; tabErrorMessage: string | null;
exportTarget: string; exportTarget: string;
pagination: {
page: number;
pageSize: number;
totalCount: number | null;
hasNextPage: boolean;
isFetching: boolean;
};
onPageChange: (nextPage: number) => void;
onPageSizeChange: (nextPageSize: number) => void;
onSortingChange: OnChangeFn<SortingState>;
onTabChange: (tab: BacklinksSearchState["tab"]) => void; onTabChange: (tab: BacklinksSearchState["tab"]) => void;
onViewChange: (view: "all" | undefined) => void;
}) { }) {
const { const {
ratings: domainRatings, ratings: domainRatings,
isLoading: isLoadingRatings, isLoading: isLoadingRatings,
loadRatings, loadRatings,
} = useAhrefsDomainRatings(projectId); } = useAhrefsDomainRatings(projectId);
const showAhrefsDrFilter = domainRatings !== null && activeTab !== "pages"; const activeFilterCount = filters[activeTab].activeFilterCount;
const currentFilterCount = countVisibleFilters(
filters[activeTab].values,
showAhrefsDrFilter,
);
const visibleFilteredData = useMemo(
() => ({
backlinks: filterBacklinkRows(
filteredData.backlinks,
filters.backlinks.values,
domainRatings,
),
referringDomains: filterReferringDomainRows(
filteredData.referringDomains,
filters.domains.values,
domainRatings,
),
topPages: filteredData.topPages,
}),
[
domainRatings,
filteredData.backlinks,
filteredData.referringDomains,
filteredData.topPages,
filters.backlinks.values,
filters.domains.values,
],
);
const exportTable = useMemo( const exportTable = useMemo(
() => () =>
buildBacklinksTabExport({ tab: activeTab, rows: visibleFilteredData }), buildBacklinksTabExport({ tab: activeTab, rows: tabRows, domainRatings }),
[activeTab, visibleFilteredData], [activeTab, domainRatings, tabRows],
); );
// Domains keyed by both tables that the DR column can enrich. The Referring // Domains keyed by both tables that the DR column can enrich. Each table
// Domains list loads lazily, so this grows once that tab is opened. // holds the currently loaded page, so this changes as the user paginates.
const ratableDomains = useMemo( const ratableDomains = useMemo(
() => collectRatableDomains(visibleFilteredData), () => collectRatableDomains(tabRows),
[visibleFilteredData], [tabRows],
); );
// Once the user has opted in, keep newly loaded domains enriched without a // 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). // re-click (e.g. after paging or switching to the Referring Domains tab).
// KV-cached, so re-requesting already-known domains is nearly free. // KV-cached, so re-requesting already-known domains is nearly free.
useEffect(() => { useEffect(() => {
if (!domainRatings) return; if (!domainRatings) return;
@ -184,7 +126,6 @@ export function BacklinksResultsCard({
<BacklinksExportMenu <BacklinksExportMenu
activeTab={activeTab} activeTab={activeTab}
exportTarget={exportTarget} exportTarget={exportTarget}
filteredData={visibleFilteredData}
headers={exportTable.headers} headers={exportTable.headers}
rows={exportTable.rows} rows={exportTable.rows}
/> />
@ -198,7 +139,7 @@ export function BacklinksResultsCard({
</div> </div>
</div> </div>
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300"> <div className="flex flex-wrap items-center gap-2 px-4 py-2 border-b border-base-300">
<button <button
className={`btn btn-ghost btn-sm gap-1.5 ${filters.showFilters ? "btn-active" : ""}`} className={`btn btn-ghost btn-sm gap-1.5 ${filters.showFilters ? "btn-active" : ""}`}
onClick={() => filters.setShowFilters((current) => !current)} onClick={() => filters.setShowFilters((current) => !current)}
@ -206,20 +147,47 @@ export function BacklinksResultsCard({
> >
<SlidersHorizontal className="size-3.5" /> <SlidersHorizontal className="size-3.5" />
Filters Filters
{currentFilterCount > 0 ? ( {activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content"> <span className="badge badge-xs badge-primary border-0 text-primary-content">
{currentFilterCount} {activeFilterCount}
</span> </span>
) : null} ) : null}
</button> </button>
{activeTab === "backlinks" ? (
<div
role="tablist"
aria-label="Backlinks view"
className="ml-auto tabs tabs-box tabs-xs w-fit"
>
<button
type="button"
role="tab"
aria-selected={view !== "all"}
className={`tab ${view !== "all" ? "tab-active" : ""}`}
title="Show each referring domain's strongest link; expand a row for the rest"
onClick={() => onViewChange(undefined)}
>
One per domain
</button>
<button
type="button"
role="tab"
aria-selected={view === "all"}
className={`tab ${view === "all" ? "tab-active" : ""}`}
title="List every individual backlink"
onClick={() => onViewChange("all")}
>
All links
</button>
</div>
) : null}
</div> </div>
{filters.showFilters ? ( {filters.showFilters ? (
<BacklinksFilterPanel <BacklinksFilterPanel
activeTab={activeTab} activeTab={activeTab}
filters={filters} filters={filters}
showAhrefsDrFilter={showAhrefsDrFilter} onApplied={() => onPageChange(1)}
activeFilterCount={currentFilterCount}
/> />
) : null} ) : null}
@ -229,154 +197,72 @@ export function BacklinksResultsCard({
<span>{tabErrorMessage}</span> <span>{tabErrorMessage}</span>
</div> </div>
) : null} ) : null}
{isTabLoading && !tabErrorMessage ? (
<TabLoadingState label={TAB_LOADING_LABELS[activeTab]} />
) : null}
{!isTabLoading && !tabErrorMessage ? (
<>
{activeTab === "backlinks" ? ( {activeTab === "backlinks" ? (
<BacklinksTable <BacklinksTable
rows={visibleFilteredData.backlinks} rows={tabRows.backlinks}
domainRatings={domainRatings} domainRatings={domainRatings}
sorting={sorting}
onSortingChange={onSortingChange}
expansion={view === "all" ? null : domainExpansion}
/> />
) : null} ) : null}
{activeTab === "domains" && isTabLoading && !tabErrorMessage ? ( {activeTab === "domains" ? (
<TabLoadingState label="Loading referring domains" />
) : null}
{activeTab === "domains" && !isTabLoading && !tabErrorMessage ? (
<ReferringDomainsTable <ReferringDomainsTable
rows={visibleFilteredData.referringDomains} rows={tabRows.referringDomains}
domainRatings={domainRatings} domainRatings={domainRatings}
sorting={sorting}
onSortingChange={onSortingChange}
/> />
) : null} ) : null}
{activeTab === "pages" && isTabLoading && !tabErrorMessage ? ( {activeTab === "pages" ? (
<TabLoadingState label="Loading top pages" /> <TopPagesTable
rows={tabRows.topPages}
sorting={sorting}
onSortingChange={onSortingChange}
/>
) : null} ) : null}
{activeTab === "pages" && !isTabLoading && !tabErrorMessage ? ( </>
<TopPagesTable rows={filteredData.topPages} />
) : null} ) : null}
</div> </div>
{/* Kept visible on tab errors so a failing page still offers a way back. */}
<TablePagination
page={pagination.page}
pageSize={pagination.pageSize}
pageSizes={BACKLINKS_PAGE_SIZES}
totalCount={pagination.totalCount}
hasNextPage={pagination.hasNextPage}
isLoading={pagination.isFetching}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
</div> </div>
); );
} }
const TAB_LOADING_LABELS: Record<BacklinksTab, string> = {
backlinks: "Loading backlinks",
domains: "Loading referring domains",
pages: "Loading top pages",
};
/** Unique domains the DR column keys on, from both the backlinks and referring /** Unique domains the DR column keys on, from both the backlinks and referring
* domains tables, normalized to match how each table renders its domain. */ * domains tables, normalized to match how each table renders its domain. */
function collectRatableDomains(filteredData: { function collectRatableDomains(tabRows: BacklinksTabRows): string[] {
backlinks: BacklinksOverviewData["backlinks"];
referringDomains: BacklinksOverviewData["referringDomains"];
}): string[] {
const domains = [ const domains = [
...filteredData.backlinks.map((row) => ...tabRows.backlinks.map((row) => row.domainFrom?.replace(/^www\./, "")),
row.domainFrom?.replace(/^www\./, ""), ...tabRows.referringDomains.map((row) => row.domain),
),
...filteredData.referringDomains.map((row) => row.domain),
]; ];
return [ return [
...new Set(domains.filter((domain): domain is string => Boolean(domain))), ...new Set(domains.filter((domain): domain is string => Boolean(domain))),
]; ];
} }
function countVisibleFilters(
values: Record<string, string>,
showAhrefsDrFilter: boolean,
) {
return Object.entries(values).filter(([key, value]) => {
if (
!showAhrefsDrFilter &&
(key === "minAhrefsDr" || key === "maxAhrefsDr")
) {
return false;
}
return value.trim() !== "";
}).length;
}
function OverviewGrid({
data,
summaryStats,
}: {
data: BacklinksOverviewData;
summaryStats: Array<{ label: string; value: string; description: string }>;
}) {
const domainScope = data.scope === "domain";
return (
<div
className={`grid grid-cols-1 gap-3 ${domainScope ? "md:grid-cols-2 xl:grid-cols-3" : ""}`}
>
<SummaryStatsGrid data={data} summaryStats={summaryStats} />
{domainScope ? <TrendPanels data={data} /> : null}
</div>
);
}
function SummaryStatsGrid({
data,
summaryStats,
}: {
data: BacklinksOverviewData;
summaryStats: Array<{ label: string; value: string; description: string }>;
}) {
const cardClassName = `card bg-base-100 border border-base-300 ${data.scope === "domain" ? "md:col-span-2 xl:col-span-1" : ""}`;
return (
<div className={cardClassName}>
<div className="card-body p-4 xl:h-full">
<div className="grid grid-cols-2 gap-x-6 gap-y-5 xl:gap-y-6">
{summaryStats.map((item) => (
<div key={item.label}>
<div className="text-xs uppercase tracking-wide text-base-content/55">
<HeaderHelpLabel
label={item.label}
helpText={item.description}
/>
</div>
<p className="text-2xl font-semibold">{item.value}</p>
</div>
))}
</div>
</div>
</div>
);
}
function TrendPanels({ data }: { data: BacklinksOverviewData }) {
return (
<>
<TrendCard
title="Backlink growth"
description="Backlinks and referring domains over the last year"
>
<BacklinksTrendChart data={data.trends} />
</TrendCard>
<TrendCard
title="New vs lost"
description="Backlink acquisition and attrition"
>
<BacklinksNewLostChart data={data.newLostTrends} />
</TrendCard>
</>
);
}
function TrendCard({
children,
description,
title,
}: {
children: React.ReactNode;
description: string;
title: string;
}) {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-2 p-4">
<div>
<h2 className="text-sm font-medium">{title}</h2>
<p className="text-xs text-base-content/55">{description}</p>
</div>
{children}
</div>
</div>
);
}
function TabLink({ function TabLink({
activeTab, activeTab,
label, label,

View File

@ -1,37 +1,108 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { OnChangeFn, SortingState } from "@tanstack/react-table";
import { import {
AppDataTable, AppDataTable,
useAppTable, useAppTable,
} from "@/client/components/table/AppDataTable"; } from "@/client/components/table/AppDataTable";
import { EmptyTableState } from "./BacklinksPageEmptyTableState"; import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import { buildBacklinksColumns } from "./BacklinksTableColumns"; import {
import type { BacklinksOverviewData } from "./backlinksPageTypes"; buildBacklinksColumns,
import { groupBacklinksByDomain } from "./backlinksPageUtils"; type BacklinksDisplayRow,
} from "./BacklinksTableColumns";
import type { BacklinksRow } from "./backlinksPageTypes";
import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
import type { DomainRatings } from "./useAhrefsDomainRatings"; import type { DomainRatings } from "./useAhrefsDomainRatings";
/** Interleaves expanded domains' extra links beneath their page row. */
function buildDisplayRows(
rows: BacklinksRow[],
expansion: BacklinksDomainExpansion | null,
): BacklinksDisplayRow[] {
if (!expansion) {
return rows.map((row) => ({
kind: "link",
row,
depth: 0,
expandable: false,
expanded: false,
}));
}
const out: BacklinksDisplayRow[] = [];
for (const row of rows) {
const domain = row.domainFrom;
const expanded = Boolean(domain && expansion.expandedDomains.has(domain));
out.push({
kind: "link",
row,
depth: 0,
expandable: Boolean(domain),
expanded,
});
if (!expanded || !domain) continue;
const entry = expansion.entriesByDomain[domain];
if (!entry || entry.status === "loading") {
out.push({ kind: "status", domain, status: "loading" });
} else if (entry.status === "error") {
out.push({ kind: "status", domain, status: "error" });
} else {
// The page row already shows the domain's strongest link; list the rest.
const children = entry.rows.filter(
(child) =>
!(
child.urlFrom === row.urlFrom &&
child.urlTo === row.urlTo &&
child.anchor === row.anchor
),
);
if (children.length === 0) {
out.push({ kind: "status", domain, status: "empty" });
} else {
for (const child of children) {
out.push({
kind: "link",
row: child,
depth: 1,
expandable: false,
expanded: false,
});
}
}
}
}
return out;
}
export function BacklinksTable({ export function BacklinksTable({
rows, rows,
domainRatings, domainRatings,
sorting,
onSortingChange,
expansion,
}: { }: {
rows: BacklinksOverviewData["backlinks"]; rows: BacklinksRow[];
domainRatings: DomainRatings | null; domainRatings: DomainRatings | null;
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
/** Present in the one-per-domain view; null when listing all links. */
expansion: BacklinksDomainExpansion | null;
}) { }) {
const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
const columns = useMemo( const columns = useMemo(
() => buildBacklinksColumns(domainRatings), () => buildBacklinksColumns(domainRatings, expansion?.toggleDomain),
[domainRatings], [domainRatings, expansion?.toggleDomain],
);
const displayRows = useMemo(
() => buildDisplayRows(rows, expansion),
[rows, expansion],
); );
const table = useAppTable({ const table = useAppTable({
data: groupedData, data: displayRows,
columns, columns,
initialState: { state: { sorting },
sorting: [{ id: "firstSeen", desc: true }], onSortingChange,
}, manualSorting: true,
getSubRows: (row) => row.subRows,
withSorting: true,
withExpanded: true,
getRowCanExpand: (row) => row.depth === 0,
}); });
if (rows.length === 0) { if (rows.length === 0) {
@ -42,13 +113,11 @@ export function BacklinksTable({
<AppDataTable <AppDataTable
table={table} table={table}
fixedLayout fixedLayout
getRowProps={(row) => ({ getRowClassName={(row) =>
className: row.original.kind !== "link" || row.original.depth > 0
row.depth === 0 ? "bg-base-200/30"
? "cursor-pointer bg-base-200/50 transition-colors hover:bg-base-200/80" : undefined
: "bg-base-100", }
onClick: row.depth === 0 ? row.getToggleExpandedHandler() : undefined,
})}
/> />
); );
} }

View File

@ -1,14 +1,10 @@
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { SortableHeader } from "@/client/components/table/SortableHeader"; import { SortableHeader } from "@/client/components/table/SortableHeader";
import {
dateNullsLast,
numericNullsLast,
stringNullsLast,
} from "@/client/components/table/nullSafeSort";
import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { BacklinksSourceLink } from "./BacklinksPageLinks"; import { BacklinksSourceLink } from "./BacklinksPageLinks";
import type { BacklinksRow, GroupedBacklinkDomain } from "./backlinksPageTypes"; import type { BacklinksRow } from "./backlinksPageTypes";
import type { BacklinksRowsSortField } from "@/types/schemas/backlinks";
import { import {
formatCompactDate, formatCompactDate,
formatDecimal, formatDecimal,
@ -16,6 +12,21 @@ import {
} from "./backlinksPageUtils"; } from "./backlinksPageUtils";
import type { DomainRatings } from "./useAhrefsDomainRatings"; import type { DomainRatings } from "./useAhrefsDomainRatings";
/**
* Row model for the backlinks table. In the one-per-domain view, depth-0 rows
* are each domain's strongest link and can expand into the domain's remaining
* links (depth-1) plus a transient status row while they load.
*/
export type BacklinksDisplayRow =
| {
kind: "link";
row: BacklinksRow;
depth: 0 | 1;
expandable: boolean;
expanded: boolean;
}
| { kind: "status"; domain: string; status: "loading" | "error" | "empty" };
function BacklinkFlags({ row }: { row: BacklinksRow }) { function BacklinkFlags({ row }: { row: BacklinksRow }) {
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@ -39,61 +50,41 @@ function BacklinkFlags({ row }: { row: BacklinksRow }) {
); );
} }
function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) { function StatusCell({ status }: { status: "loading" | "error" | "empty" }) {
const badges: Array<{ label: string; className: string }> = []; if (status === "loading") {
if (group.lostCount > 0) {
badges.push({
label: `${group.lostCount} Lost`,
className:
"badge badge-sm badge-error badge-outline min-w-fit whitespace-nowrap",
});
}
if (group.brokenCount > 0) {
badges.push({
label: `${group.brokenCount} Broken`,
className:
"badge badge-sm badge-warning badge-outline min-w-fit whitespace-nowrap",
});
}
if (group.nofollowCount > 0) {
badges.push({
label: `${group.nofollowCount} Nofollow`,
className: "badge badge-sm badge-outline min-w-fit whitespace-nowrap",
});
}
if (badges.length === 0) return null;
return ( return (
<div className="flex flex-wrap gap-1"> <span className="flex items-center gap-2 pl-6 text-sm text-base-content/60">
{badges.map((badge) => ( <span className="loading loading-spinner loading-xs" />
<span key={badge.label} className={badge.className}> Loading links
{badge.label} </span>
);
}
return (
<span className="pl-6 text-sm text-base-content/60">
{status === "error"
? "Couldn't load this domain's links."
: "No other links from this domain."}
</span> </span>
))}
</div>
); );
} }
const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [ function SourceCell({
{ displayRow,
id: "source", onToggleDomain,
accessorKey: "domain", }: {
header: ({ column }) => ( displayRow: BacklinksDisplayRow;
<SortableHeader onToggleDomain?: (domain: string) => void;
column={column} }) {
label="Source" if (displayRow.kind === "status") {
helpText="Page or domain linking to you" return <StatusCell status={displayRow.status} />;
/> }
),
size: 250, const { row, depth, expandable, expanded } = displayRow;
minSize: 180, if (depth > 0) {
cell: ({ row }) => {
if (row.depth > 0) {
const child = row.original._backlink;
return ( return (
<div className="pl-6 break-all"> <div className="break-all pl-6">
{child?.urlFrom ? ( {row.urlFrom ? (
<BacklinksSourceLink url={child.urlFrom} maxLength={48} muted /> <BacklinksSourceLink url={row.urlFrom} maxLength={48} muted />
) : ( ) : (
<span className="text-base-content/55">-</span> <span className="text-base-content/55">-</span>
)} )}
@ -101,78 +92,97 @@ const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
); );
} }
const group = row.original; const domainLabel = row.domainFrom?.replace(/^www\./, "") ?? "-";
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-start gap-1.5 break-all">
{expandable && row.domainFrom && onToggleDomain ? (
<button
type="button"
className="btn btn-ghost btn-xs btn-square shrink-0 -ml-1"
aria-label={`${expanded ? "Hide" : "Show"} all links from ${domainLabel}`}
aria-expanded={expanded}
onClick={() => onToggleDomain(row.domainFrom ?? "")}
>
<ChevronRight <ChevronRight
className={`size-4 shrink-0 transition-transform ${row.getIsExpanded() ? "rotate-90" : ""}`} className={`size-4 transition-transform ${expanded ? "rotate-90" : ""}`}
/> />
</button>
) : null}
<div> <div>
<div className="font-semibold">{group.domain}</div> <div className="font-semibold">{domainLabel}</div>
<div className="text-xs text-base-content/55"> {row.urlFrom ? (
{group.backlinkCount}{" "} <BacklinksSourceLink url={row.urlFrom} maxLength={48} muted />
{group.backlinkCount === 1 ? "backlink" : "backlinks"} &middot;{" "} ) : null}
{group.targetCount} {group.targetCount === 1 ? "page" : "pages"}
</div>
</div> </div>
</div> </div>
); );
}, }
sortingFn: stringNullsLast,
/** Renders nothing for status rows, the link cell otherwise. */
function linkCell(
render: (row: BacklinksRow) => React.ReactNode,
): (ctx: { row: { original: BacklinksDisplayRow } }) => React.ReactNode {
return ({ row }) =>
row.original.kind === "link" ? render(row.original.row) : null;
}
function buildBaseColumns(
onToggleDomain?: (domain: string) => void,
): ColumnDef<BacklinksDisplayRow>[] {
// Sortable column ids ("rank", "domainRank", "spamScore", "firstSeen") map
// to server-side sort fields — sorting re-queries DataForSEO across the
// full backlink profile, not just the loaded page.
return [
{
id: "source",
enableSorting: false,
header: () => (
<HeaderHelpLabel label="Source" helpText="Page linking to you" />
),
size: 250,
minSize: 180,
cell: ({ row }) => (
<SourceCell displayRow={row.original} onToggleDomain={onToggleDomain} />
),
}, },
{ {
id: "target", id: "target",
enableSorting: false,
header: () => ( header: () => (
<HeaderHelpLabel label="Target" helpText="Destination on your site" /> <HeaderHelpLabel label="Target" helpText="Destination on your site" />
), ),
size: 220, size: 220,
minSize: 150, minSize: 150,
enableSorting: false, cell: linkCell((row) => (
cell: ({ row }) => {
if (row.depth > 0) {
const child = row.original._backlink;
return (
<div className="break-all"> <div className="break-all">
{child?.urlTo ? ( {row.urlTo ? (
<BacklinksSourceLink url={child.urlTo} maxLength={40} /> <BacklinksSourceLink url={row.urlTo} maxLength={40} />
) : ( ) : (
"-" "-"
)} )}
</div> </div>
); )),
}
return null;
},
}, },
{ {
id: "anchor", id: "anchor",
enableSorting: false,
header: () => ( header: () => (
<HeaderHelpLabel label="Anchor" helpText="Text or format of the link" /> <HeaderHelpLabel label="Anchor" helpText="Text or format of the link" />
), ),
size: 150, size: 150,
minSize: 100, minSize: 100,
enableSorting: false, cell: linkCell((row) => (
cell: ({ row }) => {
if (row.depth > 0) {
const child = row.original._backlink;
return (
<div className="space-y-0.5 break-words"> <div className="space-y-0.5 break-words">
<span className="text-sm">{child?.anchor || "No anchor text"}</span> <span className="text-sm">{row.anchor || "No anchor text"}</span>
{child?.itemType ? ( {row.itemType ? (
<div className="text-xs text-base-content/55"> <div className="text-xs text-base-content/55">{row.itemType}</div>
{child.itemType}
</div>
) : null} ) : null}
</div> </div>
); )),
}
return null;
},
}, },
{ {
id: "flags", id: "flags",
enableSorting: false,
header: () => ( header: () => (
<HeaderHelpLabel <HeaderHelpLabel
label="Flags" label="Flags"
@ -181,58 +191,33 @@ const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
), ),
size: 130, size: 130,
minSize: 80, minSize: 80,
enableSorting: false, cell: linkCell((row) => <BacklinkFlags row={row} />),
cell: ({ row }) => {
if (row.depth > 0) {
const child = row.original._backlink;
const hasFlags =
child?.isLost ||
child?.isBroken ||
child?.isDofollow === false ||
(child?.linksCount != null && child.linksCount > 1);
return hasFlags && child ? <BacklinkFlags row={child} /> : null;
}
return <DomainFlagBadges group={row.original} />;
},
}, },
{ {
id: "linkAuthority", id: "rank" satisfies BacklinksRowsSortField,
header: () => ( accessorFn: (displayRow) =>
<span className="flex w-full justify-end"> displayRow.kind === "link" ? displayRow.row.rank : null,
<HeaderHelpLabel header: ({ column }) => (
<SortableHeader
column={column}
label="Link" label="Link"
helpText="Authority of the linking page" helpText="Authority of the linking page"
align="right"
/> />
</span>
), ),
size: 70, size: 70,
minSize: 50, minSize: 50,
enableSorting: false, sortDescFirst: true,
cell: ({ row }) => { cell: linkCell((row) => (
if (row.depth > 0) {
const child = row.original._backlink;
return (
<div className="text-right tabular-nums text-sm"> <div className="text-right tabular-nums text-sm">
<span {formatNumber(row.rank)}
title={
child?.spamScore != null
? `Spam score: ${formatDecimal(child.spamScore)}`
: undefined
}
>
{formatNumber(child?.rank)}
</span>
</div> </div>
); )),
}
return null;
},
}, },
{ {
id: "domainAuthority", id: "domainRank" satisfies BacklinksRowsSortField,
accessorKey: "domainAuthority", accessorFn: (displayRow) =>
displayRow.kind === "link" ? displayRow.row.domainFromRank : null,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -243,49 +228,41 @@ const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
), ),
size: 70, size: 70,
minSize: 50, minSize: 50,
cell: ({ row }) => {
if (row.depth > 0) return null;
return (
<div className="text-right tabular-nums text-sm">
{formatNumber(row.original.domainAuthority)}
</div>
);
},
sortingFn: numericNullsLast,
sortDescFirst: true, sortDescFirst: true,
cell: linkCell((row) => (
<div className="text-right tabular-nums text-sm">
{formatNumber(row.domainFromRank)}
</div>
)),
}, },
{ {
id: "spamScore", id: "spamScore" satisfies BacklinksRowsSortField,
accessorKey: "spamScore", accessorFn: (displayRow) =>
displayRow.kind === "link" ? displayRow.row.spamScore : null,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
label="Spam" label="Spam"
helpText="Estimated spam risk for the linking domain or backlink. Higher scores are more likely to be manipulative or low quality." helpText="Estimated spam risk for this backlink. Higher scores are more likely to be manipulative or low quality."
align="right" align="right"
/> />
), ),
size: 70, size: 70,
minSize: 50, minSize: 50,
cell: ({ row }) => { sortDescFirst: true,
const value = cell: linkCell((row) => {
row.depth > 0 const value = row.spamScore;
? row.original._backlink?.spamScore
: row.original.spamScore;
return ( return (
<div className="text-right tabular-nums text-sm"> <div className="text-right tabular-nums text-sm">
{value != null && value > 0 ? Math.round(value) : null} {value != null && value > 0 ? Math.round(value) : null}
</div> </div>
); );
}, }),
sortingFn: numericNullsLast,
sortDescFirst: true,
}, },
{ {
id: "firstSeen", id: "firstSeen" satisfies BacklinksRowsSortField,
accessorKey: "firstSeen", accessorFn: (displayRow) =>
displayRow.kind === "link" ? displayRow.row.firstSeen : null,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -295,76 +272,64 @@ const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
), ),
size: 110, size: 110,
minSize: 80, minSize: 80,
cell: ({ row }) => { sortDescFirst: true,
if (row.depth > 0) { cell: linkCell((row) => (
const child = row.original._backlink;
return (
<div className="whitespace-nowrap text-sm"> <div className="whitespace-nowrap text-sm">
<div>{formatCompactDate(child?.firstSeen)}</div> <div>{formatCompactDate(row.firstSeen)}</div>
{child?.lastSeen ? ( {row.lastSeen ? (
<div className="text-xs text-base-content/55"> <div className="text-xs text-base-content/55">
Last {formatCompactDate(child.lastSeen)} Last {formatCompactDate(row.lastSeen)}
</div> </div>
) : null} ) : null}
</div> </div>
); )),
}
return (
<div className="whitespace-nowrap text-sm">
{formatCompactDate(row.original.firstSeen)}
</div>
);
}, },
sortingFn: dateNullsLast, ];
sortDescFirst: true, }
},
];
/** /**
* Columns for the grouped backlinks table. When `domainRatings` is provided * Columns for the backlinks table. When `domainRatings` is provided (the user
* (the user clicked "Ahrefs DR"), an Ahrefs DR column is inserted after DA; * clicked "Ahrefs DR"), an Ahrefs DR column is inserted after DA; otherwise it
* otherwise it stays hidden. * stays hidden. DR is loaded client-side from Ahrefs, so it can't participate
* in server-side sorting.
*/ */
export function buildBacklinksColumns( export function buildBacklinksColumns(
domainRatings: DomainRatings | null, domainRatings: DomainRatings | null,
): ColumnDef<GroupedBacklinkDomain>[] { onToggleDomain?: (domain: string) => void,
if (!domainRatings) return baseBacklinksColumns; ): ColumnDef<BacklinksDisplayRow>[] {
const baseColumns = buildBaseColumns(onToggleDomain);
if (!domainRatings) return baseColumns;
const ratings = domainRatings; const ratings = domainRatings;
const drColumn: ColumnDef<GroupedBacklinkDomain> = { const drColumn: ColumnDef<BacklinksDisplayRow> = {
id: "ahrefsDr", id: "ahrefsDr",
accessorFn: (row) => ratings[row.domain] ?? null, enableSorting: false,
header: ({ column }) => ( header: () => (
<SortableHeader <span className="flex w-full justify-end">
column={column} <HeaderHelpLabel
label="Ahrefs DR" label="Ahrefs DR"
helpText="Ahrefs Domain Rating (0-100) for the linking domain." helpText="Ahrefs Domain Rating (0-100) for the linking domain."
align="right"
/> />
</span>
), ),
size: 90, size: 90,
minSize: 70, minSize: 70,
cell: ({ row }) => { cell: linkCell((row) => {
if (row.depth > 0) return null; const domain = row.domainFrom?.replace(/^www\./, "");
const dr = ratings[row.original.domain] ?? null; const dr = domain ? (ratings[domain] ?? null) : null;
return ( return (
<div className="text-right tabular-nums text-sm"> <div className="text-right tabular-nums text-sm">
{dr == null ? "—" : formatDecimal(dr)} {dr == null ? "—" : formatDecimal(dr)}
</div> </div>
); );
}, }),
sortingFn: numericNullsLast,
sortDescFirst: true,
}; };
const insertAt = const insertAt =
baseBacklinksColumns.findIndex( baseColumns.findIndex((column) => column.id === "domainRank") + 1;
(column) => column.id === "domainAuthority",
) + 1;
return [ return [
...baseBacklinksColumns.slice(0, insertAt), ...baseColumns.slice(0, insertAt),
drColumn, drColumn,
...baseBacklinksColumns.slice(insertAt), ...baseColumns.slice(insertAt),
]; ];
} }

View File

@ -8,26 +8,17 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { CsvValue } from "@/client/lib/csv"; import type { CsvValue } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { exportTableToSheets } from "@/client/lib/exportToSheets";
import type { import type { BacklinksSearchState } from "./backlinksPageTypes";
BacklinksOverviewData,
BacklinksSearchState,
} from "./backlinksPageTypes";
import { exportBacklinksTabCsv } from "./export"; import { exportBacklinksTabCsv } from "./export";
export function BacklinksExportMenu({ export function BacklinksExportMenu({
activeTab, activeTab,
exportTarget, exportTarget,
filteredData,
headers, headers,
rows, rows,
}: { }: {
activeTab: BacklinksSearchState["tab"]; activeTab: BacklinksSearchState["tab"];
exportTarget: string; exportTarget: string;
filteredData: {
backlinks: BacklinksOverviewData["backlinks"];
referringDomains: BacklinksOverviewData["referringDomains"];
topPages: BacklinksOverviewData["topPages"];
};
headers: string[]; headers: string[];
rows: CsvValue[][]; rows: CsvValue[][];
}) { }) {
@ -86,7 +77,8 @@ export function BacklinksExportMenu({
exportBacklinksTabCsv({ exportBacklinksTabCsv({
tab: activeTab, tab: activeTab,
target: exportTarget, target: exportTarget,
rows: filteredData, headers,
rows,
}) })
} }
disabled={rows.length === 0} disabled={rows.length === 0}

View File

@ -1,24 +1,16 @@
import { import { createColumnHelper } from "@tanstack/react-table";
createColumnHelper, import type { OnChangeFn, SortingState } from "@tanstack/react-table";
type SortingFn, import { useMemo } from "react";
type SortingState,
} from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { SafeExternalLink } from "@/client/components/SafeExternalLink"; import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { import {
AppDataTable, AppDataTable,
useAppTable, useAppTable,
} from "@/client/components/table/AppDataTable"; } from "@/client/components/table/AppDataTable";
import { SortableHeader } from "@/client/components/table/SortableHeader"; import { SortableHeader } from "@/client/components/table/SortableHeader";
import { import { HeaderHelpLabel } from "@/client/features/keywords/components";
compareNumericNullsLast,
dateNullsLast,
isDescending,
numericNullsLast,
stringNullsLast,
} from "@/client/components/table/nullSafeSort";
import { EmptyTableState } from "./BacklinksPageEmptyTableState"; import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import type { BacklinksOverviewData } from "./backlinksPageTypes"; import type { ReferringDomainRow } from "./backlinksPageTypes";
import type { ReferringDomainsSortField } from "@/types/schemas/backlinks";
import { import {
formatCompactDate, formatCompactDate,
formatDecimal, formatDecimal,
@ -26,30 +18,13 @@ import {
} from "./backlinksPageUtils"; } from "./backlinksPageUtils";
import type { DomainRatings } from "./useAhrefsDomainRatings"; import type { DomainRatings } from "./useAhrefsDomainRatings";
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
const columnHelper = createColumnHelper<ReferringDomainRow>(); const columnHelper = createColumnHelper<ReferringDomainRow>();
// Nulls always to the bottom in both directions, same as the pre-TanStack // Column ids map to server-side sort fields; sorting re-queries DataForSEO
// implementation. Secondary compare on brokenPages must also keep nulls last — // across all referring domains, not just the loaded page.
// coercing to 0 would mix unknown values with real zeroes.
const sortByIssues: SortingFn<ReferringDomainRow> = (left, right, columnId) => {
const descending = isDescending(left, columnId);
const primary = compareNumericNullsLast(
left.original.brokenBacklinks,
right.original.brokenBacklinks,
descending,
);
if (primary !== 0) return primary;
return compareNumericNullsLast(
left.original.brokenPages,
right.original.brokenPages,
descending,
);
};
const baseColumns = [ const baseColumns = [
columnHelper.accessor("domain", { columnHelper.accessor("domain", {
id: "domain" satisfies ReferringDomainsSortField,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -68,9 +43,9 @@ const baseColumns = [
/> />
); );
}, },
sortingFn: stringNullsLast,
}), }),
columnHelper.accessor("backlinks", { columnHelper.accessor("backlinks", {
id: "backlinks" satisfies ReferringDomainsSortField,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -79,10 +54,10 @@ const baseColumns = [
/> />
), ),
cell: ({ getValue }) => formatNumber(getValue()), cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true, sortDescFirst: true,
}), }),
columnHelper.accessor("referringPages", { columnHelper.accessor("referringPages", {
id: "referringPages" satisfies ReferringDomainsSortField,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -91,10 +66,10 @@ const baseColumns = [
/> />
), ),
cell: ({ getValue }) => formatNumber(getValue()), cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true, sortDescFirst: true,
}), }),
columnHelper.accessor("rank", { columnHelper.accessor("rank", {
id: "rank" satisfies ReferringDomainsSortField,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -103,10 +78,10 @@ const baseColumns = [
/> />
), ),
cell: ({ getValue }) => formatNumber(getValue()), cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true, sortDescFirst: true,
}), }),
columnHelper.accessor("spamScore", { columnHelper.accessor("spamScore", {
id: "spamScore" satisfies ReferringDomainsSortField,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -115,10 +90,10 @@ const baseColumns = [
/> />
), ),
cell: ({ getValue }) => formatDecimal(getValue()), cell: ({ getValue }) => formatDecimal(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true, sortDescFirst: true,
}), }),
columnHelper.accessor("firstSeen", { columnHelper.accessor("firstSeen", {
id: "firstSeen" satisfies ReferringDomainsSortField,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -127,11 +102,10 @@ const baseColumns = [
/> />
), ),
cell: ({ getValue }) => formatCompactDate(getValue()), cell: ({ getValue }) => formatCompactDate(getValue()),
sortingFn: dateNullsLast,
sortDescFirst: true, sortDescFirst: true,
}), }),
columnHelper.display({ columnHelper.accessor("brokenBacklinks", {
id: "issues", id: "brokenBacklinks" satisfies ReferringDomainsSortField,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -147,8 +121,6 @@ const baseColumns = [
</div> </div>
</div> </div>
), ),
enableSorting: true,
sortingFn: sortByIssues,
sortDescFirst: true, sortDescFirst: true,
}), }),
]; ];
@ -156,36 +128,29 @@ const baseColumns = [
/** /**
* Columns for the referring domains table. When `domainRatings` is provided * Columns for the referring domains table. When `domainRatings` is provided
* (the user clicked "Ahrefs DR"), an Ahrefs DR column is inserted after Rank; * (the user clicked "Ahrefs DR"), an Ahrefs DR column is inserted after Rank;
* otherwise it stays hidden. * otherwise it stays hidden. DR is loaded client-side from Ahrefs, so it can't
* participate in server-side sorting.
*/ */
function buildReferringDomainColumns(domainRatings: DomainRatings | null) { function buildReferringDomainColumns(domainRatings: DomainRatings | null) {
if (!domainRatings) return baseColumns; if (!domainRatings) return baseColumns;
const ratings = domainRatings; const ratings = domainRatings;
const drColumn = columnHelper.accessor( const drColumn = columnHelper.display({
(row) => (row.domain ? (ratings[row.domain] ?? null) : null),
{
id: "ahrefsDr", id: "ahrefsDr",
header: ({ column }) => ( header: () => (
<SortableHeader <HeaderHelpLabel
column={column}
label="Ahrefs DR" label="Ahrefs DR"
helpText="Ahrefs Domain Rating (0-100) for this referring domain." helpText="Ahrefs Domain Rating (0-100) for this referring domain."
/> />
), ),
cell: ({ getValue }) => { cell: ({ row }) => {
const dr = getValue(); const domain = row.original.domain;
const dr = domain ? (ratings[domain] ?? null) : null;
return dr == null ? "—" : formatDecimal(dr); return dr == null ? "—" : formatDecimal(dr);
}, },
sortingFn: numericNullsLast, });
sortDescFirst: true,
},
);
const insertAt = const insertAt = baseColumns.findIndex((column) => column.id === "rank") + 1;
baseColumns.findIndex(
(column) => "accessorKey" in column && column.accessorKey === "rank",
) + 1;
return [ return [
...baseColumns.slice(0, insertAt), ...baseColumns.slice(0, insertAt),
drColumn, drColumn,
@ -193,8 +158,6 @@ function buildReferringDomainColumns(domainRatings: DomainRatings | null) {
]; ];
} }
const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }];
function getDomainWebsiteHref(domain: string) { function getDomainWebsiteHref(domain: string) {
try { try {
return new URL(domain).toString(); return new URL(domain).toString();
@ -206,11 +169,14 @@ function getDomainWebsiteHref(domain: string) {
export function ReferringDomainsTable({ export function ReferringDomainsTable({
rows, rows,
domainRatings, domainRatings,
sorting,
onSortingChange,
}: { }: {
rows: BacklinksOverviewData["referringDomains"]; rows: ReferringDomainRow[];
domainRatings: DomainRatings | null; domainRatings: DomainRatings | null;
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
}) { }) {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo( const columns = useMemo(
() => buildReferringDomainColumns(domainRatings), () => buildReferringDomainColumns(domainRatings),
[domainRatings], [domainRatings],
@ -220,8 +186,8 @@ export function ReferringDomainsTable({
data: rows, data: rows,
columns, columns,
state: { sorting }, state: { sorting },
onSortingChange: setSorting, onSortingChange,
withSorting: true, manualSorting: true,
}); });
if (rows.length === 0) { if (rows.length === 0) {

View File

@ -1,28 +1,27 @@
import { createColumnHelper, type SortingState } from "@tanstack/react-table"; import { createColumnHelper } from "@tanstack/react-table";
import { useState } from "react"; import type { OnChangeFn, SortingState } from "@tanstack/react-table";
import { SafeExternalLink } from "@/client/components/SafeExternalLink"; import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { import {
AppDataTable, AppDataTable,
useAppTable, useAppTable,
} from "@/client/components/table/AppDataTable"; } from "@/client/components/table/AppDataTable";
import { SortableHeader } from "@/client/components/table/SortableHeader"; import { SortableHeader } from "@/client/components/table/SortableHeader";
import { import { HeaderHelpLabel } from "@/client/features/keywords/components";
numericNullsLast,
stringNullsLast,
} from "@/client/components/table/nullSafeSort";
import { EmptyTableState } from "./BacklinksPageEmptyTableState"; import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import type { BacklinksOverviewData } from "./backlinksPageTypes"; import type { TopPageRow } from "./backlinksPageTypes";
import type { TopPagesSortField } from "@/types/schemas/backlinks";
import { formatNumber } from "./backlinksPageUtils"; import { formatNumber } from "./backlinksPageUtils";
type TopPageRow = BacklinksOverviewData["topPages"][number];
const columnHelper = createColumnHelper<TopPageRow>(); const columnHelper = createColumnHelper<TopPageRow>();
// Column ids map to server-side sort fields; sorting re-queries DataForSEO
// across all pages, not just the loaded page of results.
const columns = [ const columns = [
columnHelper.accessor("page", { columnHelper.accessor("page", {
header: ({ column }) => ( id: "page",
<SortableHeader enableSorting: false,
column={column} header: () => (
<HeaderHelpLabel
label="Page" label="Page"
helpText="Page on the target site receiving backlinks." helpText="Page on the target site receiving backlinks."
/> />
@ -39,9 +38,9 @@ const columns = [
"-" "-"
); );
}, },
sortingFn: stringNullsLast,
}), }),
columnHelper.accessor("backlinks", { columnHelper.accessor("backlinks", {
id: "backlinks" satisfies TopPagesSortField,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -50,10 +49,10 @@ const columns = [
/> />
), ),
cell: ({ getValue }) => formatNumber(getValue()), cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true, sortDescFirst: true,
}), }),
columnHelper.accessor("referringDomains", { columnHelper.accessor("referringDomains", {
id: "referringDomains" satisfies TopPagesSortField,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -62,10 +61,10 @@ const columns = [
/> />
), ),
cell: ({ getValue }) => formatNumber(getValue()), cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true, sortDescFirst: true,
}), }),
columnHelper.accessor("rank", { columnHelper.accessor("rank", {
id: "rank" satisfies TopPagesSortField,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -74,10 +73,10 @@ const columns = [
/> />
), ),
cell: ({ getValue }) => formatNumber(getValue()), cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true, sortDescFirst: true,
}), }),
columnHelper.accessor("brokenBacklinks", { columnHelper.accessor("brokenBacklinks", {
id: "brokenBacklinks" satisfies TopPagesSortField,
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader <SortableHeader
column={column} column={column}
@ -86,26 +85,25 @@ const columns = [
/> />
), ),
cell: ({ getValue }) => formatNumber(getValue()), cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true, sortDescFirst: true,
}), }),
]; ];
const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }];
export function TopPagesTable({ export function TopPagesTable({
rows, rows,
sorting,
onSortingChange,
}: { }: {
rows: BacklinksOverviewData["topPages"]; rows: TopPageRow[];
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
}) { }) {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const table = useAppTable({ const table = useAppTable({
data: rows, data: rows,
columns, columns,
state: { sorting }, state: { sorting },
onSortingChange: setSorting, onSortingChange,
withSorting: true, manualSorting: true,
}); });
if (rows.length === 0) { if (rows.length === 0) {

View File

@ -1,10 +1,14 @@
import type {
BacklinksRowsFilters,
ReferringDomainsFilters,
TopPagesFilters,
} from "@/types/schemas/backlinks";
export type BacklinksTabFilterValues = { export type BacklinksTabFilterValues = {
include: string; include: string;
exclude: string; exclude: string;
minDomainRank: string; minDomainRank: string;
maxDomainRank: string; maxDomainRank: string;
minAhrefsDr: string;
maxAhrefsDr: string;
minLinkAuthority: string; minLinkAuthority: string;
maxLinkAuthority: string; maxLinkAuthority: string;
minSpamScore: string; minSpamScore: string;
@ -21,8 +25,6 @@ export type ReferringDomainsFilterValues = {
maxBacklinks: string; maxBacklinks: string;
minRank: string; minRank: string;
maxRank: string; maxRank: string;
minAhrefsDr: string;
maxAhrefsDr: string;
minSpamScore: string; minSpamScore: string;
maxSpamScore: string; maxSpamScore: string;
}; };
@ -43,8 +45,6 @@ export const EMPTY_BACKLINKS_FILTERS: BacklinksTabFilterValues = {
exclude: "", exclude: "",
minDomainRank: "", minDomainRank: "",
maxDomainRank: "", maxDomainRank: "",
minAhrefsDr: "",
maxAhrefsDr: "",
minLinkAuthority: "", minLinkAuthority: "",
maxLinkAuthority: "", maxLinkAuthority: "",
minSpamScore: "", minSpamScore: "",
@ -61,8 +61,6 @@ export const EMPTY_REFERRING_DOMAINS_FILTERS: ReferringDomainsFilterValues = {
maxBacklinks: "", maxBacklinks: "",
minRank: "", minRank: "",
maxRank: "", maxRank: "",
minAhrefsDr: "",
maxAhrefsDr: "",
minSpamScore: "", minSpamScore: "",
maxSpamScore: "", maxSpamScore: "",
}; };
@ -77,3 +75,116 @@ export const EMPTY_TOP_PAGES_FILTERS: TopPagesFilterValues = {
minRank: "", minRank: "",
maxRank: "", maxRank: "",
}; };
export const BACKLINKS_FILTER_FIELDS = [
"include",
"exclude",
"minDomainRank",
"maxDomainRank",
"minLinkAuthority",
"maxLinkAuthority",
"minSpamScore",
"maxSpamScore",
"linkType",
"hideLost",
"hideBroken",
] as const satisfies ReadonlyArray<keyof BacklinksTabFilterValues>;
export const REFERRING_DOMAINS_FILTER_FIELDS = [
"include",
"exclude",
"minBacklinks",
"maxBacklinks",
"minRank",
"maxRank",
"minSpamScore",
"maxSpamScore",
] as const satisfies ReadonlyArray<keyof ReferringDomainsFilterValues>;
export const TOP_PAGES_FILTER_FIELDS = [
"include",
"exclude",
"minBacklinks",
"maxBacklinks",
"minReferringDomains",
"maxReferringDomains",
"minRank",
"maxRank",
] as const satisfies ReadonlyArray<keyof TopPagesFilterValues>;
export function countActiveFilters(values: Record<string, string>): number {
return Object.values(values).filter((v) => v.trim() !== "").length;
}
/**
* Mirrors how the server translates filters to DataForSEO conditions: each
* include/exclude term is one condition, every other non-empty field is one.
* Used to enforce DataForSEO's per-request condition budget before applying.
*/
export function countFilterConditions(values: Record<string, string>): number {
let n = 0;
for (const [key, value] of Object.entries(values)) {
if (key === "include" || key === "exclude") {
for (const term of value.split(/[,+]/)) if (term.trim()) n += 1;
continue;
}
if (value.trim() !== "") n += 1;
}
return n;
}
function toNumberOrUndefined(value: string): number | undefined {
const trimmed = value.trim();
if (trimmed === "") return undefined;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : undefined;
}
export function toBacklinksFiltersPayload(
values: BacklinksTabFilterValues,
): BacklinksRowsFilters {
return {
include: values.include.trim() || undefined,
exclude: values.exclude.trim() || undefined,
minDomainRank: toNumberOrUndefined(values.minDomainRank),
maxDomainRank: toNumberOrUndefined(values.maxDomainRank),
minLinkAuthority: toNumberOrUndefined(values.minLinkAuthority),
maxLinkAuthority: toNumberOrUndefined(values.maxLinkAuthority),
minSpamScore: toNumberOrUndefined(values.minSpamScore),
maxSpamScore: toNumberOrUndefined(values.maxSpamScore),
linkType:
values.linkType === "dofollow" || values.linkType === "nofollow"
? values.linkType
: undefined,
hideLost: values.hideLost === "true" ? true : undefined,
hideBroken: values.hideBroken === "true" ? true : undefined,
};
}
export function toReferringDomainsFiltersPayload(
values: ReferringDomainsFilterValues,
): ReferringDomainsFilters {
return {
include: values.include.trim() || undefined,
exclude: values.exclude.trim() || undefined,
minBacklinks: toNumberOrUndefined(values.minBacklinks),
maxBacklinks: toNumberOrUndefined(values.maxBacklinks),
minRank: toNumberOrUndefined(values.minRank),
maxRank: toNumberOrUndefined(values.maxRank),
minSpamScore: toNumberOrUndefined(values.minSpamScore),
maxSpamScore: toNumberOrUndefined(values.maxSpamScore),
};
}
export function toTopPagesFiltersPayload(
values: TopPagesFilterValues,
): TopPagesFilters {
return {
include: values.include.trim() || undefined,
exclude: values.exclude.trim() || undefined,
minBacklinks: toNumberOrUndefined(values.minBacklinks),
maxBacklinks: toNumberOrUndefined(values.maxBacklinks),
minReferringDomains: toNumberOrUndefined(values.minReferringDomains),
maxReferringDomains: toNumberOrUndefined(values.maxReferringDomains),
minRank: toNumberOrUndefined(values.minRank),
maxRank: toNumberOrUndefined(values.maxRank),
};
}

View File

@ -1,175 +0,0 @@
import { describe, expect, it } from "vitest";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import {
EMPTY_BACKLINKS_FILTERS,
EMPTY_REFERRING_DOMAINS_FILTERS,
} from "./backlinksFilterTypes";
import {
filterBacklinkRows,
filterReferringDomainRows,
} from "./backlinksFiltering";
type BacklinkRow = BacklinksOverviewData["backlinks"][number];
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
function makeBacklinkRow(overrides: Partial<BacklinkRow> = {}): BacklinkRow {
return {
urlFrom: "https://example.com/post",
urlTo: "https://target.example/page",
domainFrom: "example.com",
anchor: "Example",
itemType: "anchor",
rank: 10,
domainFromRank: 20,
pageFromRank: 10,
spamScore: 2,
relAttributes: [],
firstSeen: null,
lastSeen: null,
linksCount: 1,
isDofollow: true,
isLost: false,
isBroken: false,
...overrides,
};
}
function makeReferringDomainRow(
overrides: Partial<ReferringDomainRow> = {},
): ReferringDomainRow {
return {
domain: "example.com",
backlinks: 10,
referringPages: 5,
rank: 20,
spamScore: 2,
firstSeen: null,
brokenBacklinks: 0,
brokenPages: 0,
...overrides,
};
}
describe("filterBacklinkRows", () => {
it("ignores Ahrefs DR range until ratings are loaded for the backlinks table", () => {
const rows = [
makeBacklinkRow({ domainFrom: "low.example" }),
makeBacklinkRow({ domainFrom: "high.example" }),
];
expect(
filterBacklinkRows(rows, {
...EMPTY_BACKLINKS_FILTERS,
minAhrefsDr: "50",
}),
).toEqual(rows);
});
it("filters by loaded Ahrefs DR range for the backlinks table", () => {
const rows = [
makeBacklinkRow({ domainFrom: "low.example" }),
makeBacklinkRow({ domainFrom: "www.high.example" }),
makeBacklinkRow({ domainFrom: "unknown.example" }),
];
const ratings = {
"low.example": 12,
"high.example": 64,
"unknown.example": null,
};
expect(
filterBacklinkRows(
rows,
{
...EMPTY_BACKLINKS_FILTERS,
minAhrefsDr: "50",
},
ratings,
),
).toEqual([rows[1], rows[2]]);
expect(
filterBacklinkRows(
rows,
{
...EMPTY_BACKLINKS_FILTERS,
maxAhrefsDr: "50",
},
ratings,
),
).toEqual([rows[0], rows[2]]);
});
});
describe("filterReferringDomainRows", () => {
it("filters by spam score range", () => {
const rows = [
makeReferringDomainRow({ domain: "clean.example", spamScore: 1 }),
makeReferringDomainRow({ domain: "risky.example", spamScore: 7 }),
makeReferringDomainRow({ domain: "unknown.example", spamScore: null }),
];
expect(
filterReferringDomainRows(rows, {
...EMPTY_REFERRING_DOMAINS_FILTERS,
maxSpamScore: "3",
}),
).toEqual([rows[0], rows[2]]);
expect(
filterReferringDomainRows(rows, {
...EMPTY_REFERRING_DOMAINS_FILTERS,
minSpamScore: "3",
}),
).toEqual([rows[1], rows[2]]);
});
it("ignores Ahrefs DR range until ratings are loaded for referring domains", () => {
const rows = [
makeReferringDomainRow({ domain: "low.example" }),
makeReferringDomainRow({ domain: "high.example" }),
];
expect(
filterReferringDomainRows(rows, {
...EMPTY_REFERRING_DOMAINS_FILTERS,
minAhrefsDr: "50",
}),
).toEqual(rows);
});
it("filters by loaded Ahrefs DR range for referring domains", () => {
const rows = [
makeReferringDomainRow({ domain: "low.example" }),
makeReferringDomainRow({ domain: "high.example" }),
makeReferringDomainRow({ domain: "unknown.example" }),
];
const ratings = {
"low.example": 12,
"high.example": 64,
"unknown.example": null,
};
expect(
filterReferringDomainRows(
rows,
{
...EMPTY_REFERRING_DOMAINS_FILTERS,
minAhrefsDr: "50",
},
ratings,
),
).toEqual([rows[1], rows[2]]);
expect(
filterReferringDomainRows(
rows,
{
...EMPTY_REFERRING_DOMAINS_FILTERS,
maxAhrefsDr: "50",
},
ratings,
),
).toEqual([rows[0], rows[2]]);
});
});

View File

@ -1,179 +0,0 @@
import { parseTerms } from "@/client/features/keywords/utils";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import type {
BacklinksTabFilterValues,
ReferringDomainsFilterValues,
TopPagesFilterValues,
} from "./backlinksFilterTypes";
import type { DomainRatings } from "./useAhrefsDomainRatings";
function passesNumericFilter(
value: number | null | undefined,
min: string,
max: string,
): boolean {
if (value == null) return true;
const minN = Number(min);
if (min && !Number.isNaN(minN) && value < minN) return false;
const maxN = Number(max);
if (max && !Number.isNaN(maxN) && value > maxN) return false;
return true;
}
function passesTextFilter(
haystack: string,
includeTerms: string[],
excludeTerms: string[],
): boolean {
const lower = haystack.toLowerCase();
if (
includeTerms.length > 0 &&
!includeTerms.some((term) => lower.includes(term))
) {
return false;
}
if (excludeTerms.some((term) => lower.includes(term))) {
return false;
}
return true;
}
export function filterBacklinkRows(
rows: BacklinksOverviewData["backlinks"],
filters: BacklinksTabFilterValues,
domainRatings?: DomainRatings | null,
): BacklinksOverviewData["backlinks"] {
const includeTerms = parseTerms(filters.include);
const excludeTerms = parseTerms(filters.exclude);
return rows.filter((row) => {
const textFields = [row.domainFrom, row.urlFrom, row.urlTo, row.anchor]
.filter((v): v is string => Boolean(v))
.join(" ");
if (!passesTextFilter(textFields, includeTerms, excludeTerms)) return false;
if (
!passesNumericFilter(
row.domainFromRank,
filters.minDomainRank,
filters.maxDomainRank,
)
)
return false;
if (
domainRatings &&
!passesNumericFilter(
row.domainFrom
? domainRatings[row.domainFrom.replace(/^www\./, "")]
: null,
filters.minAhrefsDr,
filters.maxAhrefsDr,
)
)
return false;
if (
!passesNumericFilter(
row.rank,
filters.minLinkAuthority,
filters.maxLinkAuthority,
)
)
return false;
if (
!passesNumericFilter(
row.spamScore,
filters.minSpamScore,
filters.maxSpamScore,
)
)
return false;
if (filters.linkType === "dofollow" && row.isDofollow !== true)
return false;
if (filters.linkType === "nofollow" && row.isDofollow !== false)
return false;
if (filters.hideLost === "true" && row.isLost) return false;
if (filters.hideBroken === "true" && row.isBroken) return false;
return true;
});
}
export function filterReferringDomainRows(
rows: BacklinksOverviewData["referringDomains"],
filters: ReferringDomainsFilterValues,
domainRatings?: DomainRatings | null,
): BacklinksOverviewData["referringDomains"] {
const includeTerms = parseTerms(filters.include);
const excludeTerms = parseTerms(filters.exclude);
return rows.filter((row) => {
if (!passesTextFilter(row.domain ?? "", includeTerms, excludeTerms))
return false;
if (
!passesNumericFilter(
row.backlinks,
filters.minBacklinks,
filters.maxBacklinks,
)
)
return false;
if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank))
return false;
if (
domainRatings &&
!passesNumericFilter(
row.domain ? domainRatings[row.domain] : null,
filters.minAhrefsDr,
filters.maxAhrefsDr,
)
)
return false;
if (
!passesNumericFilter(
row.spamScore,
filters.minSpamScore,
filters.maxSpamScore,
)
)
return false;
return true;
});
}
export function filterTopPageRows(
rows: BacklinksOverviewData["topPages"],
filters: TopPagesFilterValues,
): BacklinksOverviewData["topPages"] {
const includeTerms = parseTerms(filters.include);
const excludeTerms = parseTerms(filters.exclude);
return rows.filter((row) => {
if (!passesTextFilter(row.page ?? "", includeTerms, excludeTerms))
return false;
if (
!passesNumericFilter(
row.backlinks,
filters.minBacklinks,
filters.maxBacklinks,
)
)
return false;
if (
!passesNumericFilter(
row.referringDomains,
filters.minReferringDomains,
filters.maxReferringDomains,
)
)
return false;
if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank))
return false;
return true;
});
}
export function countActiveFilters(values: Record<string, string>): number {
return Object.values(values).filter((v) => v.trim() !== "").length;
}

View File

@ -1,16 +1,21 @@
import type { import type {
BacklinksSortOrder,
BacklinksTab, BacklinksTab,
BacklinksTargetScope, BacklinksTargetScope,
} from "@/types/schemas/backlinks"; } from "@/types/schemas/backlinks";
import type { import type {
getBacklinksOverview, getBacklinksOverview,
getBacklinksReferringDomains, getBacklinksReferringDomains,
getBacklinksRows,
getBacklinksTopPages, getBacklinksTopPages,
} from "@/serverFunctions/backlinks"; } from "@/serverFunctions/backlinks";
export type BacklinksOverviewData = Awaited< export type BacklinksOverviewData = Awaited<
ReturnType<typeof getBacklinksOverview> ReturnType<typeof getBacklinksOverview>
>; >;
export type BacklinksRowsPageData = Awaited<
ReturnType<typeof getBacklinksRows>
>;
export type BacklinksReferringDomainsData = Awaited< export type BacklinksReferringDomainsData = Awaited<
ReturnType<typeof getBacklinksReferringDomains> ReturnType<typeof getBacklinksReferringDomains>
>; >;
@ -18,10 +23,21 @@ export type BacklinksTopPagesData = Awaited<
ReturnType<typeof getBacklinksTopPages> ReturnType<typeof getBacklinksTopPages>
>; >;
export type BacklinksRow = BacklinksRowsPageData["rows"][number];
export type ReferringDomainRow = BacklinksReferringDomainsData["rows"][number];
export type TopPageRow = BacklinksTopPagesData["rows"][number];
export type BacklinksSearchState = { export type BacklinksSearchState = {
target: string; target: string;
scope: BacklinksTargetScope; scope: BacklinksTargetScope;
tab: BacklinksTab; tab: BacklinksTab;
page: number;
pageSize: number;
/** Sort column id for the active tab; falls back to the tab's default. */
sort?: string;
order?: BacklinksSortOrder;
/** Backlinks tab only: "all" lists every link; default is one per domain. */
view?: "all";
}; };
export type BacklinksNavigate = (args: { export type BacklinksNavigate = (args: {
@ -35,20 +51,9 @@ export type BacklinksPageProps = {
navigate: BacklinksNavigate; navigate: BacklinksNavigate;
}; };
export type BacklinksRow = BacklinksOverviewData["backlinks"][number]; /** Page rows for all three tabs; tabs that haven't loaded yet are empty. */
export type BacklinksTabRows = {
export type GroupedBacklinkDomain = { backlinks: BacklinksRow[];
domain: string; referringDomains: ReferringDomainRow[];
domainAuthority: number | null; topPages: TopPageRow[];
spamScore: number | null;
firstSeen: string | null;
backlinkCount: number;
targetCount: number;
lostCount: number;
brokenCount: number;
nofollowCount: number;
/** Child rows for TanStack Table's getSubRows — each wraps a BacklinksRow */
subRows: GroupedBacklinkDomain[];
/** Set on child rows only — the original backlink data */
_backlink?: BacklinksRow;
}; };

View File

@ -1,53 +0,0 @@
import { describe, expect, it } from "vitest";
import type { BacklinksRow } from "./backlinksPageTypes";
import { groupBacklinksByDomain } from "./backlinksPageUtils";
function makeBacklinkRow(overrides: Partial<BacklinksRow> = {}): BacklinksRow {
return {
domainFrom: "source.example",
urlFrom: "https://source.example/post",
urlTo: "https://target.example/",
anchor: null,
itemType: null,
isDofollow: true,
relAttributes: [],
rank: null,
domainFromRank: null,
pageFromRank: null,
spamScore: null,
firstSeen: null,
lastSeen: null,
isLost: false,
isBroken: false,
linksCount: null,
...overrides,
};
}
describe("groupBacklinksByDomain", () => {
it("sums grouped backlink totals from linksCount", () => {
const groups = groupBacklinksByDomain([
makeBacklinkRow({ linksCount: 5 }),
makeBacklinkRow({
urlFrom: "https://source.example/second-post",
urlTo: "https://target.example/pricing",
}),
]);
expect(groups).toHaveLength(1);
expect(groups[0]?.backlinkCount).toBe(6);
});
it("falls back to one backlink when linksCount is missing", () => {
const groups = groupBacklinksByDomain([
makeBacklinkRow({ linksCount: null }),
makeBacklinkRow({
domainFrom: "other.example",
urlFrom: "https://other.example/post",
linksCount: 0,
}),
]);
expect(groups.map((group) => group.backlinkCount)).toEqual([1, 1]);
});
});

View File

@ -1,9 +1,5 @@
import type { BacklinksTab } from "@/types/schemas/backlinks"; import type { BacklinksTab } from "@/types/schemas/backlinks";
import type { import type { BacklinksOverviewData } from "./backlinksPageTypes";
BacklinksOverviewData,
BacklinksRow,
GroupedBacklinkDomain,
} from "./backlinksPageTypes";
export const TAB_DESCRIPTIONS: Record<BacklinksTab, string> = { export const TAB_DESCRIPTIONS: Record<BacklinksTab, string> = {
backlinks: backlinks:
@ -109,70 +105,6 @@ export function formatRelativeTimestamp(value: string) {
}); });
} }
export function groupBacklinksByDomain(
rows: BacklinksRow[],
): GroupedBacklinkDomain[] {
const groups = new Map<string, BacklinksRow[]>();
for (const row of rows) {
const key = row.domainFrom?.replace(/^www\./, "") ?? "unknown";
const existing = groups.get(key);
if (existing) {
existing.push(row);
} else {
groups.set(key, [row]);
}
}
return Array.from(groups.entries()).map(([domain, children]) => ({
domain,
domainAuthority: maxNullable(children.map((r) => r.domainFromRank)),
spamScore: maxNullable(children.map((r) => r.spamScore)),
firstSeen: minDateString(children.map((r) => r.firstSeen)),
backlinkCount: children.reduce(
(total, child) => total + getBacklinkCount(child),
0,
),
targetCount: new Set(children.map((r) => r.urlTo).filter(Boolean)).size,
lostCount: children.filter((r) => r.isLost).length,
brokenCount: children.filter((r) => r.isBroken).length,
nofollowCount: children.filter((r) => r.isDofollow === false).length,
subRows: children.map((child) => ({
domain: child.domainFrom?.replace(/^www\./, "") ?? "unknown",
domainAuthority: child.domainFromRank,
spamScore: child.spamScore,
firstSeen: child.firstSeen,
backlinkCount: 1,
targetCount: 1,
lostCount: child.isLost ? 1 : 0,
brokenCount: child.isBroken ? 1 : 0,
nofollowCount: child.isDofollow === false ? 1 : 0,
subRows: [],
_backlink: child,
})),
}));
}
function getBacklinkCount(row: BacklinksRow) {
return row.linksCount != null && row.linksCount > 0 ? row.linksCount : 1;
}
function maxNullable(values: (number | null)[]): number | null {
let result: number | null = null;
for (const v of values) {
if (v != null && (result == null || v > result)) result = v;
}
return result;
}
function minDateString(values: (string | null)[]): string | null {
let result: string | null = null;
for (const v of values) {
if (v && (result == null || v < result)) result = v;
}
return result;
}
export function extractUrlPath(url: string) { export function extractUrlPath(url: string) {
try { try {
const parsed = new URL(url); const parsed = new URL(url);

View File

@ -1,14 +1,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildBacklinksTabCsvFile } from "./export"; import { buildCsv } from "@/client/lib/csv";
import type { BacklinksRow, ReferringDomainRow } from "./backlinksPageTypes";
import {
buildBacklinksTabCsvFilename,
buildBacklinksTabExport,
} from "./export";
describe("buildBacklinksTabCsvFile", () => { function makeBacklinkRow(overrides: Partial<BacklinksRow> = {}): BacklinksRow {
it("builds backlinks csv with backlink-specific columns", () => { return {
const file = buildBacklinksTabCsvFile({
tab: "backlinks",
target: "https://Example.com/path?q=1",
rows: {
backlinks: [
{
domainFrom: "example.org", domainFrom: "example.org",
urlFrom: "https://example.org/post", urlFrom: "https://example.org/post",
urlTo: "https://example.com/path", urlTo: "https://example.com/path",
@ -25,27 +24,14 @@ describe("buildBacklinksTabCsvFile", () => {
isLost: false, isLost: false,
isBroken: false, isBroken: false,
linksCount: 2, linksCount: 2,
}, ...overrides,
], };
referringDomains: [], }
topPages: [],
},
});
expect(file.filename).toBe("backlinks-backlinks-example.com-path-q-1.csv"); function makeReferringDomainRow(
expect(file.content).toContain('"Domain","Source URL","Target URL"'); overrides: Partial<ReferringDomainRow> = {},
expect(file.content).toContain('"example.org"'); ): ReferringDomainRow {
expect(file.content).toContain('"noopener, noreferrer"'); return {
});
it("builds referring domains csv", () => {
const file = buildBacklinksTabCsvFile({
tab: "domains",
target: "Example.com",
rows: {
backlinks: [],
referringDomains: [
{
domain: "source.com", domain: "source.com",
backlinks: 12, backlinks: 12,
referringPages: 7, referringPages: 7,
@ -54,21 +40,95 @@ describe("buildBacklinksTabCsvFile", () => {
firstSeen: "2024-05-10", firstSeen: "2024-05-10",
brokenBacklinks: 1, brokenBacklinks: 1,
brokenPages: 0, brokenPages: 0,
}, ...overrides,
], };
}
function buildTabCsv(
...args: Parameters<typeof buildBacklinksTabExport>
): string {
const { headers, rows } = buildBacklinksTabExport(...args);
return buildCsv(headers, rows);
}
describe("buildBacklinksTabCsvFilename", () => {
it("normalizes the target into the filename per tab", () => {
expect(
buildBacklinksTabCsvFilename("backlinks", "https://Example.com/path?q=1"),
).toBe("backlinks-backlinks-example.com-path-q-1.csv");
expect(buildBacklinksTabCsvFilename("domains", "Example.com")).toBe(
"backlinks-referring-domains-example.com.csv",
);
expect(buildBacklinksTabCsvFilename("pages", "docs.example.com")).toBe(
"backlinks-top-pages-docs.example.com.csv",
);
});
});
describe("buildBacklinksTabExport", () => {
it("builds backlinks csv with backlink-specific columns", () => {
const content = buildTabCsv({
tab: "backlinks",
rows: {
backlinks: [makeBacklinkRow()],
referringDomains: [],
topPages: [], topPages: [],
}, },
}); });
expect(file.filename).toBe("backlinks-referring-domains-example.com.csv"); expect(content).toContain('"Domain","Source URL","Target URL"');
expect(file.content).toContain('"Domain","Backlinks","Referring Pages"'); expect(content).not.toContain('"Ahrefs DR"');
expect(file.content).toContain('"source.com"'); expect(content).toContain('"example.org"');
expect(content).toContain('"noopener, noreferrer"');
});
it("includes an Ahrefs DR column when ratings are loaded", () => {
const content = buildTabCsv({
tab: "domains",
domainRatings: { "source.com": 71.5, "other.com": null },
rows: {
backlinks: [],
referringDomains: [makeReferringDomainRow()],
topPages: [],
},
});
expect(content).toContain('"Rank","Ahrefs DR","Spam Score"');
expect(content).toContain('"71.5"');
});
it("keys backlink Ahrefs DR off the www-stripped source domain", () => {
const content = buildTabCsv({
tab: "backlinks",
domainRatings: { "example.org": 33 },
rows: {
backlinks: [makeBacklinkRow({ domainFrom: "www.example.org" })],
referringDomains: [],
topPages: [],
},
});
expect(content).toContain('"Domain Rank","Ahrefs DR","Source Page Rank"');
expect(content).toContain('"33"');
});
it("builds referring domains csv", () => {
const content = buildTabCsv({
tab: "domains",
rows: {
backlinks: [],
referringDomains: [makeReferringDomainRow()],
topPages: [],
},
});
expect(content).toContain('"Domain","Backlinks","Referring Pages"');
expect(content).toContain('"source.com"');
}); });
it("builds top pages csv", () => { it("builds top pages csv", () => {
const file = buildBacklinksTabCsvFile({ const content = buildTabCsv({
tab: "pages", tab: "pages",
target: "docs.example.com",
rows: { rows: {
backlinks: [], backlinks: [],
referringDomains: [], referringDomains: [],
@ -84,45 +144,34 @@ describe("buildBacklinksTabCsvFile", () => {
}, },
}); });
expect(file.filename).toBe("backlinks-top-pages-docs.example.com.csv"); expect(content).toContain(
expect(file.content).toContain(
'"Page","Backlinks","Referring Domains","Rank","Broken Backlinks"', '"Page","Backlinks","Referring Domains","Rank","Broken Backlinks"',
); );
expect(file.content).toContain('"https://docs.example.com/start"'); expect(content).toContain('"https://docs.example.com/start"');
}); });
it("sanitizes formula-like cell values to prevent CSV injection", () => { it("sanitizes formula-like cell values to prevent CSV injection", () => {
const file = buildBacklinksTabCsvFile({ const content = buildTabCsv({
tab: "backlinks", tab: "backlinks",
target: "example.com",
rows: { rows: {
backlinks: [ backlinks: [
{ makeBacklinkRow({
domainFrom: "=cmd|' /C calc'!A0", domainFrom: "=cmd|' /C calc'!A0",
urlFrom: "+https://evil.example/source", urlFrom: "+https://evil.example/source",
urlTo: "@https://evil.example/target", urlTo: "@https://evil.example/target",
anchor: "\tformula", anchor: "\tformula",
itemType: "organic",
isDofollow: true,
relAttributes: [], relAttributes: [],
rank: 1,
domainFromRank: 1,
pageFromRank: 1,
spamScore: 0,
firstSeen: "2025-01-01",
lastSeen: "2025-01-01",
isLost: false,
isBroken: false,
linksCount: 1, linksCount: 1,
}, }),
], ],
referringDomains: [], referringDomains: [],
topPages: [], topPages: [],
}, },
}); });
expect(file.content).toContain("\"'=cmd|' /C calc'!A0\""); expect(content).toContain("\"'=cmd|' /C calc'!A0\"");
expect(file.content).toContain('"\'+https://evil.example/source"'); expect(content).toContain('"\'+https://evil.example/source"');
expect(file.content).toContain('"\'@https://evil.example/target"'); expect(content).toContain('"\'@https://evil.example/target"');
expect(file.content).toContain('"\'\tformula"'); expect(content).toContain('"\'\tformula"');
}); });
}); });

View File

@ -1,20 +1,25 @@
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv"; import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import type { import type {
BacklinksOverviewData,
BacklinksSearchState, BacklinksSearchState,
BacklinksTabRows,
} from "./backlinksPageTypes"; } from "./backlinksPageTypes";
import type { DomainRatings } from "./useAhrefsDomainRatings";
type BacklinksFilteredData = { /**
backlinks: BacklinksOverviewData["backlinks"]; * Builds the export table for the active tab. When `domainRatings` is loaded
referringDomains: BacklinksOverviewData["referringDomains"]; * (the user clicked "Ahrefs DR"), an Ahrefs DR column is included for the
topPages: BacklinksOverviewData["topPages"]; * Backlinks and Referring Domains tabs, matching the on-screen table.
}; */
export function buildBacklinksTabExport(args: { export function buildBacklinksTabExport(args: {
tab: BacklinksSearchState["tab"]; tab: BacklinksSearchState["tab"];
rows: BacklinksFilteredData; rows: BacklinksTabRows;
domainRatings?: DomainRatings | null;
}): { headers: string[]; rows: CsvValue[][] } { }): { headers: string[]; rows: CsvValue[][] } {
const { tab, rows } = args; const { tab, rows, domainRatings } = args;
const ratingFor = (domain: string | null | undefined): CsvValue => {
if (!domainRatings || !domain) return null;
return domainRatings[domain.replace(/^www\./, "")] ?? null;
};
if (tab === "backlinks") { if (tab === "backlinks") {
return { return {
@ -27,6 +32,7 @@ export function buildBacklinksTabExport(args: {
"Dofollow", "Dofollow",
"Rel Attributes", "Rel Attributes",
"Domain Rank", "Domain Rank",
...(domainRatings ? ["Ahrefs DR"] : []),
"Source Page Rank", "Source Page Rank",
"Target Rank", "Target Rank",
"Spam Score", "Spam Score",
@ -45,6 +51,7 @@ export function buildBacklinksTabExport(args: {
row.isDofollow, row.isDofollow,
row.relAttributes.join(", "), row.relAttributes.join(", "),
row.domainFromRank, row.domainFromRank,
...(domainRatings ? [ratingFor(row.domainFrom)] : []),
row.pageFromRank, row.pageFromRank,
row.rank, row.rank,
row.spamScore, row.spamScore,
@ -64,6 +71,7 @@ export function buildBacklinksTabExport(args: {
"Backlinks", "Backlinks",
"Referring Pages", "Referring Pages",
"Rank", "Rank",
...(domainRatings ? ["Ahrefs DR"] : []),
"Spam Score", "Spam Score",
"First Seen", "First Seen",
"Broken Backlinks", "Broken Backlinks",
@ -74,6 +82,7 @@ export function buildBacklinksTabExport(args: {
row.backlinks, row.backlinks,
row.referringPages, row.referringPages,
row.rank, row.rank,
...(domainRatings ? [ratingFor(row.domain)] : []),
row.spamScore, row.spamScore,
row.firstSeen, row.firstSeen,
row.brokenBacklinks, row.brokenBacklinks,
@ -100,38 +109,28 @@ export function buildBacklinksTabExport(args: {
}; };
} }
export function buildBacklinksTabCsvFile(args: {
tab: BacklinksSearchState["tab"];
target: string;
rows: BacklinksFilteredData;
}) {
const { headers, rows } = buildBacklinksTabExport({
tab: args.tab,
rows: args.rows,
});
const filenamePrefix =
args.tab === "backlinks"
? "backlinks"
: args.tab === "domains"
? "referring-domains"
: "top-pages";
return {
filename: buildFilename(filenamePrefix, args.target),
content: buildCsv(headers, rows),
};
}
export function exportBacklinksTabCsv(args: { export function exportBacklinksTabCsv(args: {
tab: BacklinksSearchState["tab"]; tab: BacklinksSearchState["tab"];
target: string; target: string;
rows: BacklinksFilteredData; headers: string[];
rows: CsvValue[][];
}) { }) {
const file = buildBacklinksTabCsvFile(args); downloadCsv(
downloadCsv(file.filename, file.content); buildBacklinksTabCsvFilename(args.tab, args.target),
buildCsv(args.headers, args.rows),
);
} }
function buildFilename(tabPrefix: string, target: string) { export function buildBacklinksTabCsvFilename(
tab: BacklinksSearchState["tab"],
target: string,
) {
const tabPrefix =
tab === "backlinks"
? "backlinks"
: tab === "domains"
? "referring-domains"
: "top-pages";
const normalizedTarget = target const normalizedTarget = target
.toLowerCase() .toLowerCase()
.trim() .trim()

View File

@ -0,0 +1,93 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQueries } from "@tanstack/react-query";
import { getBacklinksRows } from "@/serverFunctions/backlinks";
import type { BacklinksRow, BacklinksSearchState } from "./backlinksPageTypes";
const DOMAIN_LINKS_PAGE_SIZE = 100;
const DOMAIN_LINKS_STALE_TIME_MS = 5 * 60 * 1000;
export type BacklinksDomainEntry =
| { status: "loading" }
| { status: "error" }
| { status: "ready"; rows: BacklinksRow[] };
export type BacklinksDomainExpansion = {
expandedDomains: ReadonlySet<string>;
/** One entry per expanded domain; keyed by the row's raw domainFrom. */
entriesByDomain: Record<string, BacklinksDomainEntry>;
toggleDomain: (domain: string) => void;
};
/**
* Lazily loads the full link list for referring domains the user expands in
* the one-per-domain backlinks view. Each expansion is one billed DataForSEO
* request (capped at 100 links), cached client-side and in R2.
*/
export function useBacklinksDomainExpansion({
projectId,
searchState,
}: {
projectId: string;
searchState: BacklinksSearchState;
}): BacklinksDomainExpansion {
const { target, scope } = searchState;
const [expanded, setExpanded] = useState<string[]>([]);
// Collapse everything when the lookup changes.
useEffect(() => {
setExpanded([]);
}, [projectId, target, scope]);
const queries = useQueries({
queries: expanded.map((domain) => ({
queryKey: [
"backlinksDomainLinks",
projectId,
scope,
target,
domain,
] as const,
staleTime: DOMAIN_LINKS_STALE_TIME_MS,
queryFn: () =>
getBacklinksRows({
data: {
projectId,
target,
scope,
page: 1,
pageSize: DOMAIN_LINKS_PAGE_SIZE,
sortField: "rank",
sortOrder: "desc",
filters: { domainFrom: domain },
mode: "as_is",
},
}),
})),
});
const entriesByDomain = useMemo(() => {
const map: Record<string, BacklinksDomainEntry> = {};
expanded.forEach((domain, index) => {
const query = queries[index];
if (!query) return;
map[domain] = query.data
? { status: "ready", rows: query.data.rows }
: query.error
? { status: "error" }
: { status: "loading" };
});
return map;
}, [expanded, queries]);
const expandedDomains = useMemo(() => new Set(expanded), [expanded]);
const toggleDomain = useCallback((domain: string) => {
setExpanded((current) =>
current.includes(domain)
? current.filter((entry) => entry !== domain)
: [...current, domain],
);
}, []);
return { expandedDomains, entriesByDomain, toggleDomain };
}

View File

@ -1,14 +1,15 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { useForm, useStore } from "@tanstack/react-form"; import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
import { import {
EMPTY_BACKLINKS_FILTERS, EMPTY_BACKLINKS_FILTERS,
EMPTY_REFERRING_DOMAINS_FILTERS, EMPTY_REFERRING_DOMAINS_FILTERS,
EMPTY_TOP_PAGES_FILTERS, EMPTY_TOP_PAGES_FILTERS,
countActiveFilters,
countFilterConditions,
type BacklinksTabFilterValues, type BacklinksTabFilterValues,
type ReferringDomainsFilterValues, type ReferringDomainsFilterValues,
type TopPagesFilterValues, type TopPagesFilterValues,
} from "./backlinksFilterTypes"; } from "./backlinksFilterTypes";
import { countActiveFilters } from "./backlinksFiltering";
const STORAGE_KEY_PREFIX = "backlinks-filters:"; const STORAGE_KEY_PREFIX = "backlinks-filters:";
@ -36,6 +37,13 @@ function loadFromStorage<T extends FilterValues>(tab: string, fallback: T): T {
} }
} }
// Filters persisted before the server-side-filtering change had no
// condition budget; values over the DataForSEO cap would fail every
// query on load, so start fresh instead.
if (countFilterConditions(result) > MAX_DATAFORSEO_FILTER_CONDITIONS) {
return fallbackClone;
}
return result; return result;
} catch { } catch {
return fallbackClone; return fallbackClone;
@ -50,24 +58,30 @@ function saveToStorage(tab: string, values: FilterValues) {
} }
} }
/**
* Holds the *applied* filters for one tab. Draft edits live inside the filter
* panel; values here are what the server queries use, persisted per tab.
*/
function useTabFilters<T extends FilterValues>(tab: string, emptyValues: T) { function useTabFilters<T extends FilterValues>(tab: string, emptyValues: T) {
const [defaultValues] = useState<T>(() => const [values, setValues] = useState<T>(() =>
loadFromStorage(tab, { ...emptyValues }), loadFromStorage(tab, { ...emptyValues }),
); );
const form = useForm({ defaultValues });
const values = useStore(form.store, (state) => state.values);
useEffect(() => { const apply = useCallback(
saveToStorage(tab, values); (next: T) => {
}, [tab, values]); setValues(next);
saveToStorage(tab, next);
},
[tab],
);
const reset = useCallback(() => { const reset = useCallback(() => {
form.reset({ ...emptyValues }, { keepDefaultValues: true }); apply({ ...emptyValues });
}, [emptyValues, form]); }, [apply, emptyValues]);
return { return {
form,
values, values,
apply,
reset, reset,
activeFilterCount: countActiveFilters(values), activeFilterCount: countActiveFilters(values),
}; };

View File

@ -12,16 +12,35 @@ import {
import { import {
getBacklinksOverview, getBacklinksOverview,
getBacklinksReferringDomains, getBacklinksReferringDomains,
getBacklinksRows,
getBacklinksTopPages, getBacklinksTopPages,
} from "@/serverFunctions/backlinks"; } from "@/serverFunctions/backlinks";
import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess"; import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
import {
BACKLINKS_DEFAULT_SORT,
backlinksRowsSortFieldSchema,
referringDomainsSortFieldSchema,
topPagesSortFieldSchema,
type BacklinksSortOrder,
} from "@/types/schemas/backlinks";
import {
toBacklinksFiltersPayload,
toReferringDomainsFiltersPayload,
toTopPagesFiltersPayload,
} from "./backlinksFilterTypes";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope"; import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope";
type UseBacklinksPageDataArgs = { type UseBacklinksPageDataArgs = {
projectId: string; projectId: string;
searchState: BacklinksSearchState; searchState: BacklinksSearchState;
filters: BacklinksFiltersState;
}; };
// Five-minute client staleness on top of the server's 6h R2 cache, so window
// refocus doesn't re-run the server functions for bytes that can't change.
const BACKLINKS_QUERY_STALE_TIME_MS = 5 * 60 * 1000;
function getBacklinksErrorMessage( function getBacklinksErrorMessage(
error: unknown, error: unknown,
fallback: string, fallback: string,
@ -34,9 +53,28 @@ function getBacklinksErrorMessage(
return getStandardErrorMessage(error, fallback); return getStandardErrorMessage(error, fallback);
} }
/**
* Maps the URL's sort/order params to a request's sortField/sortOrder pair.
* The sort param is checked against the tab's allowed sort fields; anything
* unexpected falls back to the tab's default sort.
*/
function toSort<T extends string>(
sortParam: string | undefined,
orderParam: BacklinksSortOrder | undefined,
allowedFields: readonly T[],
fallback: { field: T; order: BacklinksSortOrder },
): { field: T; order: BacklinksSortOrder } {
const field = sortParam
? allowedFields.find((candidate) => candidate === sortParam)
: undefined;
if (!field) return fallback;
return { field, order: orderParam ?? "desc" };
}
export function useBacklinksPageData({ export function useBacklinksPageData({
projectId, projectId,
searchState, searchState,
filters,
}: UseBacklinksPageDataArgs) { }: UseBacklinksPageDataArgs) {
const accessGate = useAccessGate({ const accessGate = useAccessGate({
queryKey: ["backlinksAccessStatus", projectId], queryKey: ["backlinksAccessStatus", projectId],
@ -45,7 +83,6 @@ export function useBacklinksPageData({
}); });
const backlinksEnabled = accessGate.enabled; const backlinksEnabled = accessGate.enabled;
const retryAccessGate = accessGate.onRetry; const retryAccessGate = accessGate.onRetry;
const requestInput = buildBacklinksRequestInput(projectId, searchState);
const searchCardInitialValues = useMemo( const searchCardInitialValues = useMemo(
() => ({ () => ({
target: searchState.target, target: searchState.target,
@ -54,33 +91,118 @@ export function useBacklinksPageData({
[searchState.scope, searchState.target], [searchState.scope, searchState.target],
); );
const baseQueryKeyParts = [ const { target, scope, tab, page, pageSize, sort, order, view } = searchState;
projectId, const rowsMode = view === "all" ? "as_is" : "one_per_domain";
searchState.scope, const targetReady = backlinksEnabled && Boolean(target);
searchState.target, const baseQueryKeyParts = [projectId, scope, target] as const;
] as const; const pageInputBase = { projectId, target, scope, page, pageSize };
const overviewQuery = useQuery({ const overviewQuery = useQuery({
queryKey: ["backlinksOverview", ...baseQueryKeyParts], queryKey: ["backlinksOverview", ...baseQueryKeyParts],
enabled: backlinksEnabled && Boolean(searchState.target), enabled: targetReady,
queryFn: () => getBacklinksOverview({ data: requestInput }), staleTime: BACKLINKS_QUERY_STALE_TIME_MS,
queryFn: () => getBacklinksOverview({ data: { projectId, target, scope } }),
}); });
const rowsSort = toSort(
sort,
order,
backlinksRowsSortFieldSchema.options,
BACKLINKS_DEFAULT_SORT.backlinks,
);
const rowsFilters = useMemo(
() => toBacklinksFiltersPayload(filters.backlinks.values),
[filters.backlinks.values],
);
const rowsQuery = useQuery({
queryKey: [
"backlinksRows",
...baseQueryKeyParts,
page,
pageSize,
rowsSort.field,
rowsSort.order,
rowsFilters,
rowsMode,
],
enabled: targetReady && tab === "backlinks",
staleTime: BACKLINKS_QUERY_STALE_TIME_MS,
queryFn: () =>
getBacklinksRows({
data: {
...pageInputBase,
sortField: rowsSort.field,
sortOrder: rowsSort.order,
filters: rowsFilters,
mode: rowsMode,
},
}),
});
const domainsSort = toSort(
sort,
order,
referringDomainsSortFieldSchema.options,
BACKLINKS_DEFAULT_SORT.domains,
);
const domainsFilters = useMemo(
() => toReferringDomainsFiltersPayload(filters.domains.values),
[filters.domains.values],
);
const referringDomainsQuery = useQuery({ const referringDomainsQuery = useQuery({
queryKey: ["backlinksReferringDomains", ...baseQueryKeyParts], queryKey: [
enabled: "backlinksReferringDomains",
backlinksEnabled && ...baseQueryKeyParts,
Boolean(searchState.target) && page,
searchState.tab === "domains", pageSize,
queryFn: () => getBacklinksReferringDomains({ data: requestInput }), domainsSort.field,
domainsSort.order,
domainsFilters,
],
enabled: targetReady && tab === "domains",
staleTime: BACKLINKS_QUERY_STALE_TIME_MS,
queryFn: () =>
getBacklinksReferringDomains({
data: {
...pageInputBase,
sortField: domainsSort.field,
sortOrder: domainsSort.order,
filters: domainsFilters,
},
}),
}); });
const pagesSort = toSort(
sort,
order,
topPagesSortFieldSchema.options,
BACKLINKS_DEFAULT_SORT.pages,
);
const pagesFilters = useMemo(
() => toTopPagesFiltersPayload(filters.pages.values),
[filters.pages.values],
);
const topPagesQuery = useQuery({ const topPagesQuery = useQuery({
queryKey: ["backlinksTopPages", ...baseQueryKeyParts], queryKey: [
enabled: "backlinksTopPages",
backlinksEnabled && ...baseQueryKeyParts,
Boolean(searchState.target) && page,
searchState.tab === "pages", pageSize,
queryFn: () => getBacklinksTopPages({ data: requestInput }), pagesSort.field,
pagesSort.order,
pagesFilters,
],
enabled: targetReady && tab === "pages",
staleTime: BACKLINKS_QUERY_STALE_TIME_MS,
queryFn: () =>
getBacklinksTopPages({
data: {
...pageInputBase,
sortField: pagesSort.field,
sortOrder: pagesSort.order,
filters: pagesFilters,
},
}),
}); });
const overviewErrorMessage = getBacklinksErrorMessage( const overviewErrorMessage = getBacklinksErrorMessage(
@ -89,17 +211,18 @@ export function useBacklinksPageData({
); );
const backlinksDisabledByError = const backlinksDisabledByError =
getErrorCode(overviewQuery.error) === "BACKLINKS_NOT_ENABLED"; getErrorCode(overviewQuery.error) === "BACKLINKS_NOT_ENABLED";
const activeTabError = getActiveTabError( const activeTabQuery =
searchState, tab === "backlinks"
referringDomainsQuery.error, ? rowsQuery
topPagesQuery.error, : tab === "domains"
); ? referringDomainsQuery
: topPagesQuery;
const activeTabErrorMessage = getBacklinksErrorMessage( const activeTabErrorMessage = getBacklinksErrorMessage(
activeTabError, activeTabQuery.error,
"Could not load this tab.", "Could not load this tab.",
); );
const backlinksDisabledByTabError = const backlinksDisabledByTabError =
getErrorCode(activeTabError) === "BACKLINKS_NOT_ENABLED"; getErrorCode(activeTabQuery.error) === "BACKLINKS_NOT_ENABLED";
useEffect(() => { useEffect(() => {
if ( if (
@ -118,10 +241,12 @@ export function useBacklinksPageData({
return { return {
accessGate, accessGate,
activeTabErrorMessage, activeTabErrorMessage,
activeTabQuery,
backlinksDisabledByError, backlinksDisabledByError,
overviewErrorMessage, overviewErrorMessage,
overviewQuery, overviewQuery,
referringDomainsQuery, referringDomainsQuery,
rowsQuery,
searchCardInitialValues, searchCardInitialValues,
topPagesQuery, topPagesQuery,
}; };
@ -137,39 +262,10 @@ export function navigateToBacklinksSearch(
target: values.target, target: values.target,
scope: getPersistedBacklinksSearchScope(values.target, values.scope), scope: getPersistedBacklinksSearchScope(values.target, values.scope),
tab: undefined, tab: undefined,
page: undefined,
sort: undefined,
order: undefined,
}), }),
replace: true, replace: true,
}); });
} }
function buildBacklinksRequestInput(
projectId: string,
searchState: BacklinksSearchState,
) {
return {
projectId,
target: searchState.target,
scope: searchState.scope,
// Server-side spam filtering (hideSpam/spamThreshold) is available but
// intentionally disabled. All filtering — including spam score — is applied
// client-side so users get immediate feedback without re-fetching. This
// trades slightly larger API responses for simpler code and flexibility.
hideSpam: false,
};
}
function getActiveTabError(
searchState: BacklinksSearchState,
referringDomainsError: unknown,
topPagesError: unknown,
) {
if (searchState.tab === "domains") {
return referringDomainsError;
}
if (searchState.tab === "pages") {
return topPagesError;
}
return null;
}

View File

@ -1,4 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { AlertTriangle, RotateCcw } from "lucide-react"; import { AlertTriangle, RotateCcw } from "lucide-react";
import { import {
FilterNumberInput, FilterNumberInput,
@ -36,6 +42,11 @@ type Props<TValues extends FilterValues> = {
countConditions: (values: TValues) => number; countConditions: (values: TValues) => number;
onApply: (values: TValues) => void; onApply: (values: TValues) => void;
onClear: () => void; onClear: () => void;
/** Extra feature-specific controls (toggles etc.) bound to the draft. */
renderExtra?: (
draft: TValues,
setValue: (key: keyof TValues, value: string) => void,
) => ReactNode;
}; };
export function DomainFilterPanel<TValues extends FilterValues>({ export function DomainFilterPanel<TValues extends FilterValues>({
@ -48,6 +59,7 @@ export function DomainFilterPanel<TValues extends FilterValues>({
countConditions, countConditions,
onApply, onApply,
onClear, onClear,
renderExtra,
}: Props<TValues>) { }: Props<TValues>) {
const appliedKey = useMemo( const appliedKey = useMemo(
() => fields.map((key) => appliedFilters[key]).join("|"), () => fields.map((key) => appliedFilters[key]).join("|"),
@ -97,10 +109,19 @@ export function DomainFilterPanel<TValues extends FilterValues>({
}, [appliedFilters, debugName]); }, [appliedFilters, debugName]);
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
debugDomain(`${debugName}:clear`); debugDomain(`${debugName}:clear`);
// Also clear unapplied draft edits — when the applied filters are already
// empty, the applied-sync effect won't fire (appliedKey is unchanged).
setDraftFilters((current) => {
const next = { ...current };
for (const key of fields) Object.assign(next, { [key]: "" });
return next;
});
onClear(); onClear();
}, [debugName, onClear]); }, [debugName, fields, onClear]);
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key !== "Enter") return; if (event.key !== "Enter") return;
// Let buttons (Cancel, toggles) handle their own Enter activation.
if (event.target instanceof HTMLButtonElement) return;
if (meta.overLimit) return; if (meta.overLimit) return;
event.preventDefault(); event.preventDefault();
applyFilters(); applyFilters();
@ -177,6 +198,8 @@ export function DomainFilterPanel<TValues extends FilterValues>({
))} ))}
</div> </div>
{renderExtra ? renderExtra(draftFilters, handleValueChange) : null}
{meta.overLimit ? ( {meta.overLimit ? (
<div className="alert alert-warning py-2 text-xs"> <div className="alert alert-warning py-2 text-xs">
<AlertTriangle className="size-4 shrink-0" /> <AlertTriangle className="size-4 shrink-0" />

View File

@ -1,7 +1,10 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { BacklinksPage } from "@/client/features/backlinks/BacklinksPage"; import { BacklinksPage } from "@/client/features/backlinks/BacklinksPage";
import { inferBacklinksSearchScopeFromTarget } from "@/client/features/backlinks/backlinksSearchScope"; import { inferBacklinksSearchScopeFromTarget } from "@/client/features/backlinks/backlinksSearchScope";
import { backlinksSearchSchema } from "@/types/schemas/backlinks"; import {
DEFAULT_BACKLINKS_PAGE_SIZE,
backlinksSearchSchema,
} from "@/types/schemas/backlinks";
export const Route = createFileRoute("/_project/p/$projectId/backlinks")({ export const Route = createFileRoute("/_project/p/$projectId/backlinks")({
validateSearch: backlinksSearchSchema, validateSearch: backlinksSearchSchema,
@ -11,7 +14,16 @@ export const Route = createFileRoute("/_project/p/$projectId/backlinks")({
function BacklinksRoute() { function BacklinksRoute() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath });
const { target = "", scope: rawScope, tab = "backlinks" } = Route.useSearch(); const {
target = "",
scope: rawScope,
tab = "backlinks",
page = 1,
size = DEFAULT_BACKLINKS_PAGE_SIZE,
sort,
order,
view,
} = Route.useSearch();
const scope = rawScope ?? inferBacklinksSearchScopeFromTarget(target); const scope = rawScope ?? inferBacklinksSearchScopeFromTarget(target);
return ( return (
@ -22,6 +34,11 @@ function BacklinksRoute() {
target, target,
scope, scope,
tab, tab,
page,
pageSize: size,
sort,
order,
view,
}} }}
/> />
); );

View File

@ -37,6 +37,15 @@ const billingCustomer = {
userEmail: "team@example.com", userEmail: "team@example.com",
}; };
const pageInputDefaults = {
projectId: "project_123",
page: 1,
pageSize: 100,
sortOrder: "desc",
filters: {},
mode: "as_is",
} as const;
const cache = new Map<string, string>(); const cache = new Map<string, string>();
const service = createBacklinksService({ const service = createBacklinksService({
async get(key) { async get(key) {
@ -53,7 +62,7 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("profiles only the initial overview calls and reuses cache on repeat", async () => { it("profiles only the summary and history for the overview and reuses cache on repeat", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({ vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com", apiTarget: "example.com",
displayTarget: "example.com", displayTarget: "example.com",
@ -73,27 +82,6 @@ it("profiles only the initial overview calls and reuses cache on repeat", async
new_referring_domains: 8, new_referring_domains: 8,
lost_referring_domains: 2, lost_referring_domains: 2,
}); });
backlinksRowsMock.mockResolvedValue([
{
domain_from: "source.example",
url_from: "https://source.example/post",
url_to: "https://example.com/",
anchor: "Example",
item_type: "content",
dofollow: true,
rank: 77,
domain_from_rank: 65,
page_from_rank: 54,
backlink_spam_score: 3,
first_seen: "2026-01-01",
last_visited: "2026-03-01",
lost_date: null,
is_lost: false,
is_broken: false,
links_count: 1,
rel_attributes: ["noopener"],
},
]);
backlinksHistoryMock.mockResolvedValue([ backlinksHistoryMock.mockResolvedValue([
{ {
date: "2026-02-01", date: "2026-02-01",
@ -116,8 +104,9 @@ it("profiles only the initial overview calls and reuses cache on repeat", async
billingCustomer, billingCustomer,
); );
expect(first.overview.referringDomains).toEqual([]); expect(first.overview.summary.backlinks).toBe(1200);
expect(first.overview.topPages).toEqual([]); expect(first.overview.trends).toHaveLength(1);
expect(backlinksRowsMock).not.toHaveBeenCalled();
expect(referringDomainsMock).not.toHaveBeenCalled(); expect(referringDomainsMock).not.toHaveBeenCalled();
expect(domainPagesMock).not.toHaveBeenCalled(); expect(domainPagesMock).not.toHaveBeenCalled();
expect(backlinksSummaryMock).toHaveBeenCalledOnce(); expect(backlinksSummaryMock).toHaveBeenCalledOnce();
@ -125,13 +114,110 @@ it("profiles only the initial overview calls and reuses cache on repeat", async
expect(second).toEqual(first); expect(second).toEqual(first);
}); });
it("profiles referring domains and top pages separately", async () => { it("profiles backlink rows per page with offset and total count", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
backlinksRowsMock.mockResolvedValue({
items: [
{
domain_from: "source.example",
url_from: "https://source.example/post",
url_to: "https://example.com/",
anchor: "Example",
item_type: "content",
dofollow: true,
rank: 77,
domain_from_rank: 65,
page_from_rank: 54,
backlink_spam_score: 3,
first_seen: "2026-01-01",
last_visited: "2026-03-01",
lost_date: null,
is_lost: false,
is_broken: false,
links_count: 1,
rel_attributes: ["noopener"],
},
],
totalCount: 450,
});
const result = await service.profileBacklinksPage(
{
...pageInputDefaults,
target: "example.com",
page: 2,
sortField: "rank",
},
billingCustomer,
{ hideSpam: false },
);
expect(backlinksRowsMock).toHaveBeenCalledWith(
expect.objectContaining({
target: "example.com",
limit: 100,
offset: 100,
orderBy: ["rank,desc"],
hideSpam: false,
}),
);
expect(result.rows).toHaveLength(1);
expect(result.totalCount).toBe(450);
expect(result.hasMore).toBe(true);
expect(result.page).toBe(2);
});
it("translates filters into DataForSEO conditions for backlink rows", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
backlinksRowsMock.mockResolvedValue({ items: [], totalCount: 0 });
await service.profileBacklinksPage(
{
...pageInputDefaults,
target: "example.com",
sortField: "rank",
filters: {
include: "blog",
minDomainRank: 30,
linkType: "dofollow",
hideLost: true,
},
},
billingCustomer,
{ hideSpam: false },
);
expect(backlinksRowsMock).toHaveBeenCalledWith(
expect.objectContaining({
filters: [
["url_from", "ilike", "%blog%"],
"and",
["domain_from_rank", ">=", 30],
"and",
["dofollow", "=", true],
"and",
["is_lost", "=", false],
],
}),
);
});
it("profiles referring domains and top pages pages separately", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({ vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "https://example.com/foo", apiTarget: "https://example.com/foo",
displayTarget: "https://example.com/foo", displayTarget: "https://example.com/foo",
scope: "page", scope: "page",
}); });
referringDomainsMock.mockResolvedValue([ referringDomainsMock.mockResolvedValue({
items: [
{ {
domain: "source.example", domain: "source.example",
backlinks: 4, backlinks: 4,
@ -143,8 +229,11 @@ it("profiles referring domains and top pages separately", async () => {
backlinks_spam_score: 2, backlinks_spam_score: 2,
target_spam_score: 4, target_spam_score: 4,
}, },
]); ],
domainPagesMock.mockResolvedValue([ totalCount: 1,
});
domainPagesMock.mockResolvedValue({
items: [
{ {
page: "https://example.com/foo", page: "https://example.com/foo",
backlinks: 100, backlinks: 100,
@ -152,19 +241,30 @@ it("profiles referring domains and top pages separately", async () => {
rank: 50, rank: 50,
broken_backlinks: 0, broken_backlinks: 0,
}, },
]); ],
totalCount: 1,
});
const domains = await service.profileReferringDomains( const domains = await service.profileReferringDomainsPage(
{ target: "https://example.com/foo" }, {
...pageInputDefaults,
target: "https://example.com/foo",
sortField: "backlinks",
},
billingCustomer, billingCustomer,
); );
const pages = await service.profileTopPages( const pages = await service.profileTopPagesPage(
{ target: "https://example.com/foo" }, {
...pageInputDefaults,
target: "https://example.com/foo",
sortField: "backlinks",
},
billingCustomer, billingCustomer,
); );
expect(domains.rows).toHaveLength(1); expect(domains.rows).toHaveLength(1);
expect(domains.rows[0]?.spamScore).toBe(2); expect(domains.rows[0]?.spamScore).toBe(2);
expect(domains.hasMore).toBe(false);
expect(pages.rows).toHaveLength(1); expect(pages.rows).toHaveLength(1);
}); });
@ -174,7 +274,8 @@ it("does not fall back to target spam score for referring domains", async () =>
displayTarget: "example.com", displayTarget: "example.com",
scope: "domain", scope: "domain",
}); });
referringDomainsMock.mockResolvedValue([ referringDomainsMock.mockResolvedValue({
items: [
{ {
domain: "source.example", domain: "source.example",
backlinks: 4, backlinks: 4,
@ -186,10 +287,16 @@ it("does not fall back to target spam score for referring domains", async () =>
backlinks_spam_score: null, backlinks_spam_score: null,
target_spam_score: 4, target_spam_score: 4,
}, },
]); ],
totalCount: 1,
});
const domains = await service.profileReferringDomains( const domains = await service.profileReferringDomainsPage(
{ target: "example.com" }, {
...pageInputDefaults,
target: "example.com",
sortField: "backlinks",
},
billingCustomer, billingCustomer,
); );
@ -197,39 +304,33 @@ it("does not fall back to target spam score for referring domains", async () =>
expect(domains.rows[0]?.spamScore).toBeNull(); expect(domains.rows[0]?.spamScore).toBeNull();
}); });
it("keeps cache entries isolated per organization", async () => { it("keeps page cache entries isolated per page and per organization", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({ vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com", apiTarget: "example.com",
displayTarget: "example.com", displayTarget: "example.com",
scope: "domain", scope: "domain",
}); });
backlinksSummaryMock.mockResolvedValue({ backlinksRowsMock.mockResolvedValue({ items: [], totalCount: 0 });
rank: 42,
backlinks: 1200,
referring_pages: 900,
referring_domains: 320,
broken_backlinks: 12,
broken_pages: 3,
backlinks_spam_score: 5,
info: { target_spam_score: 4 },
new_backlinks: 25,
lost_backlinks: 10,
new_referring_domains: 8,
lost_referring_domains: 2,
});
backlinksRowsMock.mockResolvedValue([]);
backlinksHistoryMock.mockResolvedValue([]);
const input = { target: "example.com" }; const input = {
...pageInputDefaults,
target: "example.com",
sortField: "rank",
} as const;
await service.profileOverview(input, billingCustomer); await service.profileBacklinksPage(input, billingCustomer);
await service.profileOverview(input, { await service.profileBacklinksPage(input, billingCustomer);
expect(backlinksRowsMock).toHaveBeenCalledTimes(1);
await service.profileBacklinksPage({ ...input, page: 2 }, billingCustomer);
expect(backlinksRowsMock).toHaveBeenCalledTimes(2);
await service.profileBacklinksPage(input, {
organizationId: "org_456", organizationId: "org_456",
userId: "user_456", userId: "user_456",
userEmail: "other@example.com", userEmail: "other@example.com",
}); });
expect(backlinksRowsMock).toHaveBeenCalledTimes(3);
expect(backlinksSummaryMock).toHaveBeenCalledTimes(2);
}); });
function parseCachedValue(raw: string): unknown { function parseCachedValue(raw: string): unknown {

View File

@ -2,37 +2,63 @@ import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { normalizeBacklinksTarget } from "@/server/lib/dataforseo"; import { normalizeBacklinksTarget } from "@/server/lib/dataforseo";
import { import {
normalizeBacklinksSpamFilterOptions, normalizeBacklinksSpamFilterOptions,
type BacklinksLookupInput,
type BacklinksSpamFilterOptions, type BacklinksSpamFilterOptions,
} from "@/types/schemas/backlinks"; } from "@/types/schemas/backlinks";
import { import {
profileBacklinksOverview, profileBacklinksOverview,
profileReferringDomainsRows, profileBacklinksRowsPage,
profileTopPagesRows, profileReferringDomainsPage,
profileTopPagesPage,
type BacklinksCache, type BacklinksCache,
type BacklinksRowsPageServiceInput,
type ReferringDomainsPageServiceInput,
type TopPagesPageServiceInput,
} from "@/server/features/backlinks/services/backlinksServiceData"; } from "@/server/features/backlinks/services/backlinksServiceData";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import type { BacklinksLookupInput } from "@/types/schemas/backlinks";
const defaultCache: BacklinksCache = { const defaultCache: BacklinksCache = {
get: getCached, get: getCached,
set: setCached, set: setCached,
}; };
type BacklinksPageCacheInput = {
target: string;
scope?: "domain" | "page";
page: number;
pageSize: number;
sortField: string;
sortOrder: string;
filters: Record<string, unknown>;
/** Backlinks rows only: DataForSEO result grouping. */
mode?: string;
};
function createBacklinksService(cache: BacklinksCache = defaultCache) { function createBacklinksService(cache: BacklinksCache = defaultCache) {
return { return {
async profileOverview( async profileOverview(
input: BacklinksLookupInput, input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
) {
const cacheKey = await buildCacheKey("backlinks:overview", {
...buildTargetCacheInput(input, billingCustomer),
});
return profileBacklinksOverview(cache, cacheKey, input, billingCustomer);
},
async profileBacklinksPage(
input: BacklinksRowsPageServiceInput,
billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions, options?: BacklinksSpamFilterOptions,
) { ) {
const cacheKey = await buildBacklinksCacheKey( const cacheKey = await buildPageCacheKey(
"backlinks:overview", "backlinks:rows-page",
input, input,
billingCustomer, billingCustomer,
options, options,
); );
return profileBacklinksOverview( return profileBacklinksRowsPage(
cache, cache,
cacheKey, cacheKey,
input, input,
@ -40,19 +66,19 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) {
options, options,
); );
}, },
async profileReferringDomains( async profileReferringDomainsPage(
input: BacklinksLookupInput, input: ReferringDomainsPageServiceInput,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions, options?: BacklinksSpamFilterOptions,
) { ) {
const cacheKey = await buildBacklinksCacheKey( const cacheKey = await buildPageCacheKey(
"backlinks:referring-domains", "backlinks:referring-domains-page",
input, input,
billingCustomer, billingCustomer,
options, options,
); );
return profileReferringDomainsRows( return profileReferringDomainsPage(
cache, cache,
cacheKey, cacheKey,
input, input,
@ -60,44 +86,52 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) {
options, options,
); );
}, },
async profileTopPages( async profileTopPagesPage(
input: BacklinksLookupInput, input: TopPagesPageServiceInput,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
) { ) {
const cacheKey = await buildBacklinksCacheKey( const cacheKey = await buildPageCacheKey(
"backlinks:top-pages", "backlinks:top-pages-page",
input, input,
billingCustomer, billingCustomer,
); );
return profileTopPagesRows(cache, cacheKey, input, billingCustomer); return profileTopPagesPage(cache, cacheKey, input, billingCustomer);
}, },
} as const; } as const;
} }
async function buildBacklinksCacheKey( function buildTargetCacheInput(
prefix: string,
input: BacklinksLookupInput, input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions, ) {
): Promise<string> {
const normalizedTarget = normalizeBacklinksTarget(input.target, { const normalizedTarget = normalizeBacklinksTarget(input.target, {
scope: input.scope, scope: input.scope,
}); });
const cacheKeyInput = {
return {
organizationId: billingCustomer.organizationId, organizationId: billingCustomer.organizationId,
target: normalizedTarget.apiTarget, target: normalizedTarget.apiTarget,
scope: normalizedTarget.scope, scope: normalizedTarget.scope,
}; };
}
if (!options) { async function buildPageCacheKey(
return buildCacheKey(prefix, cacheKeyInput); prefix: string,
} input: BacklinksPageCacheInput,
billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions,
): Promise<string> {
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(options); const spamFilterOptions = normalizeBacklinksSpamFilterOptions(options);
return buildCacheKey(prefix, { return buildCacheKey(prefix, {
...cacheKeyInput, ...buildTargetCacheInput(input, billingCustomer),
page: input.page,
pageSize: input.pageSize,
sortField: input.sortField,
sortOrder: input.sortOrder,
filters: input.filters,
...(input.mode ? { mode: input.mode } : {}),
hideSpam: String(spamFilterOptions.hideSpam), hideSpam: String(spamFilterOptions.hideSpam),
...(spamFilterOptions.hideSpam ...(spamFilterOptions.hideSpam
? { spamThreshold: String(spamFilterOptions.spamThreshold) } ? { spamThreshold: String(spamFilterOptions.spamThreshold) }

View File

@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import {
countFilterConditions,
toBacklinksFiltersPayload,
type BacklinksTabFilterValues,
EMPTY_BACKLINKS_FILTERS,
} from "@/client/features/backlinks/backlinksFilterTypes";
import {
buildBacklinksRowsApiFilters,
buildReferringDomainsApiFilters,
buildTopPagesApiFilters,
} from "./backlinksApiFilters";
describe("buildBacklinksRowsApiFilters", () => {
it("ORs include terms in one group and ANDs everything else", () => {
expect(
buildBacklinksRowsApiFilters({
include: "blog, news",
exclude: "spam",
minDomainRank: 30,
linkType: "dofollow",
hideLost: true,
}),
).toEqual([
[["url_from", "ilike", "%blog%"], "or", ["url_from", "ilike", "%news%"]],
"and",
["url_from", "not_ilike", "%spam%"],
"and",
["domain_from_rank", ">=", 30],
"and",
["dofollow", "=", true],
"and",
["is_lost", "=", false],
]);
});
it("emits a single include term as a plain condition", () => {
expect(buildBacklinksRowsApiFilters({ include: "blog" })).toEqual([
["url_from", "ilike", "%blog%"],
]);
});
it("escapes LIKE wildcards in terms", () => {
expect(buildBacklinksRowsApiFilters({ include: "wp_content" })).toEqual([
["url_from", "ilike", "%wp\\_content%"],
]);
});
it("returns no expressions for empty filters", () => {
expect(buildBacklinksRowsApiFilters({})).toEqual([]);
});
it("throws when the condition budget is exceeded", () => {
expect(() =>
buildBacklinksRowsApiFilters({
include: "a, b, c, d, e",
exclude: "f, g, h, i",
}),
).toThrowError(/Too many filter conditions/);
});
});
describe("buildReferringDomainsApiFilters", () => {
it("filters on referring-domain fields", () => {
expect(
buildReferringDomainsApiFilters({
include: "edu",
minBacklinks: 5,
maxSpamScore: 30,
}),
).toEqual([
["domain", "ilike", "%edu%"],
"and",
["backlinks", ">=", 5],
"and",
["backlinks_spam_score", "<=", 30],
]);
});
});
describe("buildTopPagesApiFilters", () => {
it("filters on the url field", () => {
expect(
buildTopPagesApiFilters({ include: "/blog", minReferringDomains: 2 }),
).toEqual([
["url", "ilike", "%/blog%"],
"and",
["referring_domains", ">=", 2],
]);
});
});
describe("client condition count vs server condition budget", () => {
// The client gates Apply with countFilterConditions; the server enforces the
// DataForSEO budget while building. They must agree on how many conditions a
// set of filter values produces, or users get hard errors the UI accepted.
function serverConditionCount(values: BacklinksTabFilterValues): number {
const expressions = buildBacklinksRowsApiFilters(
toBacklinksFiltersPayload(values),
);
let count = 0;
for (const expression of expressions) {
if (expression === "and") continue;
// An include OR-group contains nested clauses and "or" connectors.
count +=
Array.isArray(expression) && Array.isArray(expression[0])
? Math.ceil(expression.length / 2)
: 1;
}
return count;
}
const cases: Array<[string, BacklinksTabFilterValues]> = [
["empty", { ...EMPTY_BACKLINKS_FILTERS }],
[
"terms and ranges",
{
...EMPTY_BACKLINKS_FILTERS,
include: "blog, news",
exclude: "spam",
minDomainRank: "30",
maxSpamScore: "50",
},
],
[
"toggles",
{
...EMPTY_BACKLINKS_FILTERS,
linkType: "nofollow",
hideLost: "true",
hideBroken: "true",
},
],
[
"everything",
{
...EMPTY_BACKLINKS_FILTERS,
include: "a",
exclude: "b",
minDomainRank: "1",
maxDomainRank: "90",
minLinkAuthority: "2",
linkType: "dofollow",
hideLost: "true",
},
],
];
it.each(cases)("matches for %s", (_name, values) => {
expect(serverConditionCount(values)).toBe(countFilterConditions(values));
});
});

View File

@ -0,0 +1,179 @@
import {
assertFilterConditionBudget,
buildIncludeOrGroup,
collectNumericRange,
escapeLikeTerm,
joinClauses,
parseFilterTerms,
type FilterClause,
} from "@/server/lib/dataforseo/filters";
import type {
BacklinksRowsFilters,
BacklinksRowsSortField,
BacklinksSortOrder,
ReferringDomainsFilters,
ReferringDomainsSortField,
TopPagesFilters,
TopPagesSortField,
} from "@/types/schemas/backlinks";
const BACKLINKS_ROWS_SORT_FIELDS: Record<BacklinksRowsSortField, string> = {
rank: "rank",
domainRank: "domain_from_rank",
spamScore: "backlink_spam_score",
firstSeen: "first_seen",
};
const REFERRING_DOMAINS_SORT_FIELDS: Record<ReferringDomainsSortField, string> =
{
domain: "domain",
backlinks: "backlinks",
referringPages: "referring_pages",
rank: "rank",
spamScore: "backlinks_spam_score",
firstSeen: "first_seen",
brokenBacklinks: "broken_backlinks",
};
const TOP_PAGES_SORT_FIELDS: Record<TopPagesSortField, string> = {
backlinks: "backlinks",
referringDomains: "referring_domains",
rank: "rank",
brokenBacklinks: "broken_backlinks",
};
export function buildBacklinksRowsOrderBy(
field: BacklinksRowsSortField,
order: BacklinksSortOrder,
): string[] {
return [`${BACKLINKS_ROWS_SORT_FIELDS[field]},${order}`];
}
export function buildReferringDomainsOrderBy(
field: ReferringDomainsSortField,
order: BacklinksSortOrder,
): string[] {
return [`${REFERRING_DOMAINS_SORT_FIELDS[field]},${order}`];
}
export function buildTopPagesOrderBy(
field: TopPagesSortField,
order: BacklinksSortOrder,
): string[] {
return [`${TOP_PAGES_SORT_FIELDS[field]},${order}`];
}
/**
* Translates the Backlinks tab filters into DataForSEO filter expressions.
* Include/exclude terms match the source URL (which contains the linking
* domain): include terms are OR'd (match any), exclude terms AND'd (drop all).
*/
export function buildBacklinksRowsApiFilters(
filters: BacklinksRowsFilters,
): unknown[] {
const conditions: FilterClause[] = [];
collectExcludeConditions(conditions, "url_from", filters.exclude);
collectNumericRange(
conditions,
"domain_from_rank",
filters.minDomainRank,
filters.maxDomainRank,
);
collectNumericRange(
conditions,
"rank",
filters.minLinkAuthority,
filters.maxLinkAuthority,
);
collectNumericRange(
conditions,
"backlink_spam_score",
filters.minSpamScore,
filters.maxSpamScore,
);
if (filters.linkType) {
conditions.push(["dofollow", "=", filters.linkType === "dofollow"]);
}
if (filters.hideLost) {
conditions.push(["is_lost", "=", false]);
}
if (filters.hideBroken) {
conditions.push(["is_broken", "=", false]);
}
if (filters.domainFrom) {
conditions.push(["domain_from", "=", filters.domainFrom]);
}
return finishFilters("url_from", filters.include, conditions);
}
export function buildReferringDomainsApiFilters(
filters: ReferringDomainsFilters,
): unknown[] {
const conditions: FilterClause[] = [];
collectExcludeConditions(conditions, "domain", filters.exclude);
collectNumericRange(
conditions,
"backlinks",
filters.minBacklinks,
filters.maxBacklinks,
);
collectNumericRange(conditions, "rank", filters.minRank, filters.maxRank);
collectNumericRange(
conditions,
"backlinks_spam_score",
filters.minSpamScore,
filters.maxSpamScore,
);
return finishFilters("domain", filters.include, conditions);
}
export function buildTopPagesApiFilters(filters: TopPagesFilters): unknown[] {
const conditions: FilterClause[] = [];
collectExcludeConditions(conditions, "url", filters.exclude);
collectNumericRange(
conditions,
"backlinks",
filters.minBacklinks,
filters.maxBacklinks,
);
collectNumericRange(
conditions,
"referring_domains",
filters.minReferringDomains,
filters.maxReferringDomains,
);
collectNumericRange(conditions, "rank", filters.minRank, filters.maxRank);
return finishFilters("url", filters.include, conditions);
}
function collectExcludeConditions(
out: FilterClause[],
field: string,
exclude: string | undefined,
) {
for (const term of parseFilterTerms(exclude)) {
out.push([field, "not_ilike", `%${escapeLikeTerm(term)}%`]);
}
}
/** Prepends the OR'd include group, enforces the condition budget, joins with "and". */
function finishFilters(
includeField: string,
include: string | undefined,
conditions: FilterClause[],
): unknown[] {
const includeGroup = buildIncludeOrGroup(includeField, include);
assertFilterConditionBudget(
conditions.length + (includeGroup?.conditionCount ?? 0),
);
return joinClauses(
includeGroup ? [includeGroup.clause, ...conditions] : conditions,
"and",
);
}

View File

@ -19,7 +19,7 @@ const backlinksRowSchema = z.object({
linksCount: z.number().nullable(), linksCount: z.number().nullable(),
}); });
export const referringDomainRowSchema = z.object({ const referringDomainRowSchema = z.object({
domain: z.string().nullable(), domain: z.string().nullable(),
backlinks: z.number().nullable(), backlinks: z.number().nullable(),
referringPages: z.number().nullable(), referringPages: z.number().nullable(),
@ -30,7 +30,7 @@ export const referringDomainRowSchema = z.object({
brokenPages: z.number().nullable(), brokenPages: z.number().nullable(),
}); });
export const topPageRowSchema = z.object({ const topPageRowSchema = z.object({
page: z.string().nullable(), page: z.string().nullable(),
backlinks: z.number().nullable(), backlinks: z.number().nullable(),
referringDomains: z.number().nullable(), referringDomains: z.number().nullable(),
@ -71,12 +71,35 @@ export const backlinksOverviewSchema = z.object({
newReferringDomains: z.number().nullable(), newReferringDomains: z.number().nullable(),
lostReferringDomains: z.number().nullable(), lostReferringDomains: z.number().nullable(),
}), }),
backlinks: z.array(backlinksRowSchema),
referringDomains: z.array(referringDomainRowSchema),
topPages: z.array(topPageRowSchema),
trends: z.array(backlinksTrendRowSchema), trends: z.array(backlinksTrendRowSchema),
newLostTrends: z.array(backlinksNewLostTrendRowSchema), newLostTrends: z.array(backlinksNewLostTrendRowSchema),
fetchedAt: z.string(), fetchedAt: z.string(),
}); });
export type BacklinksOverviewResult = z.infer<typeof backlinksOverviewSchema>; export type BacklinksOverviewResult = z.infer<typeof backlinksOverviewSchema>;
function buildPageResultSchema<T extends z.ZodTypeAny>(rowSchema: T) {
return z.object({
rows: z.array(rowSchema),
totalCount: z.number().nullable(),
hasMore: z.boolean(),
page: z.number(),
pageSize: z.number(),
fetchedAt: z.string(),
});
}
export const backlinksRowsPageResultSchema =
buildPageResultSchema(backlinksRowSchema);
export const referringDomainsPageResultSchema = buildPageResultSchema(
referringDomainRowSchema,
);
export const topPagesPageResultSchema = buildPageResultSchema(topPageRowSchema);
export type BacklinksRowsPageResult = z.infer<
typeof backlinksRowsPageResultSchema
>;
export type ReferringDomainsPageResult = z.infer<
typeof referringDomainsPageResultSchema
>;
export type TopPagesPageResult = z.infer<typeof topPagesPageResultSchema>;

View File

@ -9,21 +9,52 @@ import {
type DomainPageSummaryItem, type DomainPageSummaryItem,
type ReferringDomainItem, type ReferringDomainItem,
} from "@/server/lib/dataforseo"; } from "@/server/lib/dataforseo";
import { import type {
normalizeBacklinksSpamFilterOptions, BacklinksLookupInput,
type BacklinksSpamFilterOptions, BacklinksRowsPageInput,
BacklinksSpamFilterOptions,
ReferringDomainsPageInput,
TopPagesPageInput,
} from "@/types/schemas/backlinks"; } from "@/types/schemas/backlinks";
import { import {
backlinksOverviewSchema, backlinksOverviewSchema,
referringDomainRowSchema, backlinksRowsPageResultSchema,
topPageRowSchema, referringDomainsPageResultSchema,
topPagesPageResultSchema,
type BacklinksOverviewResult, type BacklinksOverviewResult,
type BacklinksRowsPageResult,
type ReferringDomainsPageResult,
type TopPagesPageResult,
} from "@/server/features/backlinks/services/backlinksOverviewSchema"; } from "@/server/features/backlinks/services/backlinksOverviewSchema";
import type { BacklinksLookupInput } from "@/types/schemas/backlinks"; import {
buildBacklinksRowsApiFilters,
buildBacklinksRowsOrderBy,
buildReferringDomainsApiFilters,
buildReferringDomainsOrderBy,
buildTopPagesApiFilters,
buildTopPagesOrderBy,
} from "@/server/features/backlinks/services/backlinksApiFilters";
// The page-request schemas carry projectId for the web middleware; the
// service layer is organization-scoped and never reads it.
export type BacklinksRowsPageServiceInput = Omit<
BacklinksRowsPageInput,
"projectId"
>;
export type ReferringDomainsPageServiceInput = Omit<
ReferringDomainsPageInput,
"projectId"
>;
export type TopPagesPageServiceInput = Omit<TopPagesPageInput, "projectId">;
const BACKLINKS_OVERVIEW_TTL_SECONDS = 6 * 60 * 60; const BACKLINKS_OVERVIEW_TTL_SECONDS = 6 * 60 * 60;
const BACKLINKS_TAB_TTL_SECONDS = 6 * 60 * 60; const BACKLINKS_TAB_TTL_SECONDS = 6 * 60 * 60;
const backlinksOverviewCacheSchema = z.object({
overview: backlinksOverviewSchema,
});
export type BacklinksCache = { export type BacklinksCache = {
get(key: string): Promise<unknown>; get(key: string): Promise<unknown>;
set(key: string, data: unknown, ttlSeconds: number): Promise<void>; set(key: string, data: unknown, ttlSeconds: number): Promise<void>;
@ -33,24 +64,6 @@ type BacklinksOverviewProfile = {
overview: BacklinksOverviewResult; overview: BacklinksOverviewResult;
}; };
type ReferringDomainsProfile = {
rows: BacklinksOverviewResult["referringDomains"];
};
type TopPagesProfile = {
rows: BacklinksOverviewResult["topPages"];
};
const backlinksOverviewCacheSchema = z.object({
overview: backlinksOverviewSchema,
});
const referringDomainsCacheSchema = z.object({
rows: z.array(referringDomainRowSchema),
});
const topPagesCacheSchema = z.object({ rows: z.array(topPageRowSchema) });
type BacklinksDateRange = { type BacklinksDateRange = {
dateFrom: string; dateFrom: string;
dateTo: string; dateTo: string;
@ -61,10 +74,10 @@ export async function profileBacklinksOverview(
cacheKey: string, cacheKey: string,
input: BacklinksLookupInput, input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions,
): Promise<BacklinksOverviewProfile> { ): Promise<BacklinksOverviewProfile> {
const cachedRaw = await cache.get(cacheKey); const cached = backlinksOverviewCacheSchema.safeParse(
const cached = backlinksOverviewCacheSchema.safeParse(cachedRaw); await cache.get(cacheKey),
);
if (cached.success) { if (cached.success) {
return { return {
overview: cached.data.overview, overview: cached.data.overview,
@ -77,16 +90,10 @@ export async function profileBacklinksOverview(
const normalizedTarget = normalizeBacklinksTarget(input.target, { const normalizedTarget = normalizeBacklinksTarget(input.target, {
scope: input.scope, scope: input.scope,
}); });
const request = buildBacklinksListRequest(
normalizedTarget.apiTarget,
100,
options,
);
const dateRange = buildBacklinksDateRange(now); const dateRange = buildBacklinksDateRange(now);
const [summary, backlinks, history] = await Promise.all([ const [summary, history] = await Promise.all([
dataforseo.backlinks.summary({ target: request.target }), dataforseo.backlinks.summary({ target: normalizedTarget.apiTarget }),
dataforseo.backlinks.rows(request),
normalizedTarget.scope === "domain" normalizedTarget.scope === "domain"
? dataforseo.backlinks.history({ ? dataforseo.backlinks.history({
target: normalizedTarget.apiTarget, target: normalizedTarget.apiTarget,
@ -99,7 +106,6 @@ export async function profileBacklinksOverview(
normalizedTarget, normalizedTarget,
now, now,
summary, summary,
backlinks,
history, history,
}); });
await cacheValue( await cacheValue(
@ -112,76 +118,131 @@ export async function profileBacklinksOverview(
return { overview }; return { overview };
} }
export async function profileReferringDomainsRows( export async function profileBacklinksRowsPage(
cache: BacklinksCache, cache: BacklinksCache,
cacheKey: string, cacheKey: string,
input: BacklinksLookupInput, input: BacklinksRowsPageServiceInput,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions, spamOptions?: BacklinksSpamFilterOptions,
): Promise<ReferringDomainsProfile> { ): Promise<BacklinksRowsPageResult> {
const cachedRaw = await cache.get(cacheKey); const cached = backlinksRowsPageResultSchema.safeParse(
const cached = referringDomainsCacheSchema.safeParse(cachedRaw); await cache.get(cacheKey),
if (cached.success) {
return {
rows: cached.data.rows,
};
}
const dataforseo = createDataforseoClient(billingCustomer);
const request = buildBacklinksListRequest(
normalizeBacklinksTarget(input.target, { scope: input.scope }).apiTarget,
100,
options,
); );
const response = await dataforseo.backlinks.referringDomains(request);
const rows = mapReferringDomainsRows(response);
await cacheValue(cache, cacheKey, { rows }, BACKLINKS_TAB_TTL_SECONDS);
return { rows };
}
export async function profileTopPagesRows(
cache: BacklinksCache,
cacheKey: string,
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
): Promise<TopPagesProfile> {
const cachedRaw = await cache.get(cacheKey);
const cached = topPagesCacheSchema.safeParse(cachedRaw);
if (cached.success) { if (cached.success) {
return { return cached.data;
rows: cached.data.rows,
};
} }
const dataforseo = createDataforseoClient(billingCustomer); const dataforseo = createDataforseoClient(billingCustomer);
const offset = (input.page - 1) * input.pageSize;
const filters = buildBacklinksRowsApiFilters(input.filters);
const request = { const response = await dataforseo.backlinks.rows({
target: normalizeBacklinksTarget(input.target, { scope: input.scope }) target: normalizeBacklinksTarget(input.target, { scope: input.scope })
.apiTarget, .apiTarget,
}; limit: input.pageSize,
const response = await dataforseo.backlinks.domainPages({ offset,
...request, orderBy: buildBacklinksRowsOrderBy(input.sortField, input.sortOrder),
limit: 100, filters: filters.length > 0 ? filters : undefined,
mode: input.mode,
...spamOptions,
}); });
const rows = mapTopPagesRows(response);
await cacheValue(cache, cacheKey, { rows }, BACKLINKS_TAB_TTL_SECONDS); const result = buildPageResult(input, offset, {
rows: mapBacklinksRows(response.items),
totalCount: response.totalCount,
});
await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS);
return { rows }; return result;
} }
function buildBacklinksListRequest( export async function profileReferringDomainsPage(
target: string, cache: BacklinksCache,
limit: number, cacheKey: string,
options?: BacklinksSpamFilterOptions, input: ReferringDomainsPageServiceInput,
billingCustomer: BillingCustomerContext,
spamOptions?: BacklinksSpamFilterOptions,
): Promise<ReferringDomainsPageResult> {
const cached = referringDomainsPageResultSchema.safeParse(
await cache.get(cacheKey),
);
if (cached.success) {
return cached.data;
}
const dataforseo = createDataforseoClient(billingCustomer);
const offset = (input.page - 1) * input.pageSize;
const filters = buildReferringDomainsApiFilters(input.filters);
const response = await dataforseo.backlinks.referringDomains({
target: normalizeBacklinksTarget(input.target, { scope: input.scope })
.apiTarget,
limit: input.pageSize,
offset,
orderBy: buildReferringDomainsOrderBy(input.sortField, input.sortOrder),
filters: filters.length > 0 ? filters : undefined,
...spamOptions,
});
const result = buildPageResult(input, offset, {
rows: mapReferringDomainsRows(response.items),
totalCount: response.totalCount,
});
await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS);
return result;
}
export async function profileTopPagesPage(
cache: BacklinksCache,
cacheKey: string,
input: TopPagesPageServiceInput,
billingCustomer: BillingCustomerContext,
): Promise<TopPagesPageResult> {
const cached = topPagesPageResultSchema.safeParse(await cache.get(cacheKey));
if (cached.success) {
return cached.data;
}
const dataforseo = createDataforseoClient(billingCustomer);
const offset = (input.page - 1) * input.pageSize;
const filters = buildTopPagesApiFilters(input.filters);
const response = await dataforseo.backlinks.domainPages({
target: normalizeBacklinksTarget(input.target, { scope: input.scope })
.apiTarget,
limit: input.pageSize,
offset,
orderBy: buildTopPagesOrderBy(input.sortField, input.sortOrder),
filters: filters.length > 0 ? filters : undefined,
});
const result = buildPageResult(input, offset, {
rows: mapTopPagesRows(response.items),
totalCount: response.totalCount,
});
await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS);
return result;
}
function buildPageResult<TRow>(
input: { page: number; pageSize: number },
offset: number,
data: { rows: TRow[]; totalCount: number | null },
) { ) {
const hasMore =
data.totalCount != null
? offset + data.rows.length < data.totalCount
: data.rows.length === input.pageSize;
return { return {
target, rows: data.rows,
limit, totalCount: data.totalCount,
...normalizeBacklinksSpamFilterOptions(options), hasMore,
page: input.page,
pageSize: input.pageSize,
fetchedAt: new Date().toISOString(),
}; };
} }
@ -205,7 +266,6 @@ function buildOverviewResult(args: {
normalizedTarget: ReturnType<typeof normalizeBacklinksTarget>; normalizedTarget: ReturnType<typeof normalizeBacklinksTarget>;
now: Date; now: Date;
summary: BacklinksSummaryItem; summary: BacklinksSummaryItem;
backlinks: BacklinksItem[];
history: BacklinksHistoryItem[]; history: BacklinksHistoryItem[];
}): BacklinksOverviewResult { }): BacklinksOverviewResult {
const historyRows = args.history const historyRows = args.history
@ -253,9 +313,6 @@ function buildOverviewResult(args: {
args.summary.lost_reffering_domains ?? args.summary.lost_reffering_domains ??
null, null,
}, },
backlinks: mapBacklinksRows(args.backlinks),
referringDomains: [],
topPages: [],
trends: historyRows.map((item) => ({ trends: historyRows.map((item) => ({
date: item.date, date: item.date,
backlinks: item.backlinks, backlinks: item.backlinks,

View File

@ -1,8 +1,12 @@
import { import {
MAX_DATAFORSEO_FILTER_CONDITIONS, assertFilterConditionBudget,
type DomainKeywordsFilters, collectNumericRange,
} from "@/types/schemas/domain"; escapeLikeTerm,
import { AppError } from "@/server/lib/errors"; joinClauses,
parseFilterTerms,
type FilterClause,
} from "@/server/lib/dataforseo/filters";
import type { DomainKeywordsFilters } from "@/types/schemas/domain";
export type DomainKeywordsSortMode = export type DomainKeywordsSortMode =
| "rank" | "rank"
@ -27,62 +31,26 @@ export function buildOrderBy(
return [`${SORT_FIELD_BY_MODE[sortMode]},${sortOrder}`]; return [`${SORT_FIELD_BY_MODE[sortMode]},${sortOrder}`];
} }
function escapeLikeTerm(term: string): string {
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
}
function pushAnd(filters: unknown[], expression: Clause) {
if (filters.length > 0) filters.push("and");
filters.push(expression);
}
function parseTerms(value: string | undefined): string[] {
if (!value) return [];
return value
.toLowerCase()
.split(/[,+]/)
.map((term) => term.trim())
.filter(Boolean);
}
function collectNumericRange(
out: Clause[],
field: string,
min: number | undefined,
max: number | undefined,
) {
if (typeof min === "number" && Number.isFinite(min)) {
out.push([field, ">=", min]);
}
if (typeof max === "number" && Number.isFinite(max)) {
out.push([field, "<=", max]);
}
}
/** /**
* DataForSEO accepts up to 8 filter conditions per request joined by * Each include/exclude term is one ilike clause; numeric ranges add one per
* "and"/"or" operators. Each include/exclude term is one ilike clause; * bound; the free-text search term adds one OR-group of two (keyword OR url).
* numeric ranges add one per bound; the free-text search term adds one OR- * The client surfaces the same condition count and disables Apply when over
* group of two (keyword OR url). The client surfaces the same condition * the DataForSEO budget.
* count and disables Apply when over budget, so reaching the cap here
* indicates a misbehaving client we throw rather than silently truncate.
*/ */
type Clause = unknown[];
export function buildKeywordFilters( export function buildKeywordFilters(
filters: DomainKeywordsFilters, filters: DomainKeywordsFilters,
searchTerm?: string, searchTerm?: string,
): unknown[] { ): unknown[] {
const conditions: Clause[] = []; const conditions: FilterClause[] = [];
for (const term of parseTerms(filters.include)) { for (const term of parseFilterTerms(filters.include)) {
conditions.push([ conditions.push([
"keyword_data.keyword", "keyword_data.keyword",
"ilike", "ilike",
`%${escapeLikeTerm(term)}%`, `%${escapeLikeTerm(term)}%`,
]); ]);
} }
for (const term of parseTerms(filters.exclude)) { for (const term of parseFilterTerms(filters.exclude)) {
conditions.push([ conditions.push([
"keyword_data.keyword", "keyword_data.keyword",
"not_ilike", "not_ilike",
@ -125,21 +93,15 @@ export function buildKeywordFilters(
const searchGroup = trimmedSearch ? buildSearchGroup(trimmedSearch) : null; const searchGroup = trimmedSearch ? buildSearchGroup(trimmedSearch) : null;
// The search OR-group costs 2 slots; everything else is 1. // The search OR-group costs 2 slots; everything else is 1.
const totalConditions = conditions.length + (searchGroup ? 2 : 0); assertFilterConditionBudget(conditions.length + (searchGroup ? 2 : 0));
if (totalConditions > MAX_DATAFORSEO_FILTER_CONDITIONS) {
throw new AppError(
"VALIDATION_ERROR",
`Too many filter conditions (${totalConditions} of ${MAX_DATAFORSEO_FILTER_CONDITIONS} max).`,
);
}
const expressions: unknown[] = []; return joinClauses(
for (const clause of conditions) pushAnd(expressions, clause); searchGroup ? [...conditions, searchGroup] : conditions,
if (searchGroup) pushAnd(expressions, searchGroup); "and",
return expressions; );
} }
function buildSearchGroup(term: string): Clause { function buildSearchGroup(term: string): FilterClause {
const escaped = escapeLikeTerm(term); const escaped = escapeLikeTerm(term);
return [ return [
["keyword_data.keyword", "ilike", `%${escaped}%`], ["keyword_data.keyword", "ilike", `%${escaped}%`],

View File

@ -209,7 +209,7 @@ describe("fetchBacklinksSummary", () => {
await expect( await expect(
fetchBacklinksRows({ target: "example.com" }), fetchBacklinksRows({ target: "example.com" }),
).resolves.toMatchObject({ data: [] }); ).resolves.toMatchObject({ data: { items: [], totalCount: null } });
await expect( await expect(
fetchBacklinksHistory({ fetchBacklinksHistory({
target: "example.com", target: "example.com",

View File

@ -17,6 +17,7 @@ import {
assertOk, assertOk,
buildTaskBilling, buildTaskBilling,
parseTaskItems, parseTaskItems,
parseTaskTotalCount,
type DataforseoApiResponse, type DataforseoApiResponse,
} from "@/server/lib/dataforseo/envelope"; } from "@/server/lib/dataforseo/envelope";
@ -24,7 +25,16 @@ export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget
type BacklinksRequest = { target: string }; type BacklinksRequest = { target: string };
type BacklinksListRequest = BacklinksRequest & type BacklinksListRequest = BacklinksRequest &
BacklinksSpamFilterOptions & { limit?: number }; BacklinksSpamFilterOptions & {
limit?: number;
offset?: number;
/** DataForSEO order_by entries, e.g. ["rank,desc"]. */
orderBy?: string[];
/** Pre-built DataForSEO filter expressions, already joined with and/or. */
filters?: unknown[];
/** Result grouping (backlinks list only): "one_per_domain" | "as_is". */
mode?: string;
};
type BacklinksTimeseriesRequest = { type BacklinksTimeseriesRequest = {
target: string; target: string;
dateFrom: string; dateFrom: string;
@ -146,6 +156,24 @@ function buildCommonPayload(input: BacklinksRequest) {
const assertOptions = (path: string) => const assertOptions = (path: string) =>
({ classify: classifyBacklinksError, classifyPath: path }) as const; ({ classify: classifyBacklinksError, classifyPath: path }) as const;
/**
* Joins caller-provided filter expressions with the spam-score condition.
* `userFilters` arrives already and/or-joined, so the spam condition is
* appended with a single top-level "and".
*/
function combineFilters(
userFilters: unknown[] | undefined,
spamCondition: unknown[] | undefined,
): unknown[] | undefined {
const merged: unknown[] = [];
if (userFilters && userFilters.length > 0) merged.push(...userFilters);
if (spamCondition) {
if (merged.length > 0) merged.push("and");
merged.push(spamCondition);
}
return merged.length > 0 ? merged : undefined;
}
export async function fetchBacklinksSummary(input: BacklinksRequest) { export async function fetchBacklinksSummary(input: BacklinksRequest) {
const response = await backlinksApi(classifyBacklinksError).summaryLive([ const response = await backlinksApi(classifyBacklinksError).summaryLive([
new BacklinksSummaryLiveRequestInfo(buildCommonPayload(input)), new BacklinksSummaryLiveRequestInfo(buildCommonPayload(input)),
@ -179,14 +207,19 @@ export async function fetchBacklinksSummary(input: BacklinksRequest) {
export async function fetchBacklinksRows(input: BacklinksListRequest) { export async function fetchBacklinksRows(input: BacklinksListRequest) {
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input); const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
const filters = spamFilterOptions.hideSpam const filters = combineFilters(
? [["backlink_spam_score", "<=", spamFilterOptions.spamThreshold]] input.filters,
: undefined; spamFilterOptions.hideSpam
? ["backlink_spam_score", "<=", spamFilterOptions.spamThreshold]
: undefined,
);
const response = await backlinksApi(classifyBacklinksError).backlinksLive([ const response = await backlinksApi(classifyBacklinksError).backlinksLive([
new BacklinksBacklinksLiveRequestInfo({ new BacklinksBacklinksLiveRequestInfo({
...buildCommonPayload(input), ...buildCommonPayload(input),
limit: input.limit ?? 100, limit: input.limit ?? 100,
order_by: ["rank,desc"], offset: input.offset,
order_by: input.orderBy ?? ["rank,desc"],
mode: input.mode,
...(filters ? { filters } : {}), ...(filters ? { filters } : {}),
}), }),
]); ]);
@ -195,23 +228,30 @@ export async function fetchBacklinksRows(input: BacklinksListRequest) {
assertOptions("/v3/backlinks/backlinks/live"), assertOptions("/v3/backlinks/backlinks/live"),
); );
return { return {
data: parseTaskItems("backlinks-live", task, backlinksItemSchema), data: {
items: parseTaskItems("backlinks-live", task, backlinksItemSchema),
totalCount: parseTaskTotalCount(task),
},
billing: buildTaskBilling(task), billing: buildTaskBilling(task),
}; };
} }
export async function fetchReferringDomains(input: BacklinksListRequest) { export async function fetchReferringDomains(input: BacklinksListRequest) {
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input); const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
const filters = spamFilterOptions.hideSpam const filters = combineFilters(
? [["backlinks_spam_score", "<=", spamFilterOptions.spamThreshold]] input.filters,
: undefined; spamFilterOptions.hideSpam
? ["backlinks_spam_score", "<=", spamFilterOptions.spamThreshold]
: undefined,
);
const response = await backlinksApi( const response = await backlinksApi(
classifyBacklinksError, classifyBacklinksError,
).referringDomainsLive([ ).referringDomainsLive([
new BacklinksReferringDomainsLiveRequestInfo({ new BacklinksReferringDomainsLiveRequestInfo({
...buildCommonPayload(input), ...buildCommonPayload(input),
limit: input.limit ?? 100, limit: input.limit ?? 100,
order_by: ["backlinks,desc"], offset: input.offset,
order_by: input.orderBy ?? ["backlinks,desc"],
...(filters ? { filters } : {}), ...(filters ? { filters } : {}),
}), }),
]); ]);
@ -220,23 +260,30 @@ export async function fetchReferringDomains(input: BacklinksListRequest) {
assertOptions("/v3/backlinks/referring_domains/live"), assertOptions("/v3/backlinks/referring_domains/live"),
); );
return { return {
data: parseTaskItems( data: {
items: parseTaskItems(
"referring-domains-live", "referring-domains-live",
task, task,
referringDomainItemSchema, referringDomainItemSchema,
), ),
totalCount: parseTaskTotalCount(task),
},
billing: buildTaskBilling(task), billing: buildTaskBilling(task),
}; };
} }
export async function fetchDomainPagesSummary(input: BacklinksListRequest) { export async function fetchDomainPagesSummary(input: BacklinksListRequest) {
const filters =
input.filters && input.filters.length > 0 ? input.filters : undefined;
const response = await backlinksApi( const response = await backlinksApi(
classifyBacklinksError, classifyBacklinksError,
).domainPagesSummaryLive([ ).domainPagesSummaryLive([
new BacklinksDomainPagesSummaryLiveRequestInfo({ new BacklinksDomainPagesSummaryLiveRequestInfo({
...buildCommonPayload(input), ...buildCommonPayload(input),
limit: input.limit ?? 100, limit: input.limit ?? 100,
order_by: ["backlinks,desc"], offset: input.offset,
order_by: input.orderBy ?? ["backlinks,desc"],
...(filters ? { filters } : {}),
}), }),
]); ]);
const task = assertOk( const task = assertOk(
@ -244,11 +291,14 @@ export async function fetchDomainPagesSummary(input: BacklinksListRequest) {
assertOptions("/v3/backlinks/domain_pages_summary/live"), assertOptions("/v3/backlinks/domain_pages_summary/live"),
); );
return { return {
data: parseTaskItems( data: {
items: parseTaskItems(
"domain-pages-summary-live", "domain-pages-summary-live",
task, task,
domainPageSummaryItemSchema, domainPageSummaryItemSchema,
), ),
totalCount: parseTaskTotalCount(task),
},
billing: buildTaskBilling(task), billing: buildTaskBilling(task),
}; };
} }

View File

@ -148,6 +148,13 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
/** Reads `task.result[0].total_count` for paginated list endpoints. */
export function parseTaskTotalCount(task: DataforseoTaskLike): number | null {
const first = task.result?.[0];
if (!isRecord(first)) return null;
return typeof first.total_count === "number" ? first.total_count : null;
}
/** Reads `task.result[0].items`, validating against a Zod schema for loosely-typed endpoints. */ /** Reads `task.result[0].items`, validating against a Zod schema for loosely-typed endpoints. */
export function parseTaskItems<T extends z.ZodTypeAny>( export function parseTaskItems<T extends z.ZodTypeAny>(
endpoint: string, endpoint: string,

View File

@ -0,0 +1,89 @@
import { AppError } from "@/server/lib/errors";
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
/**
* Building blocks for DataForSEO `filters` expressions, shared by the
* feature-specific builders (domain keywords, backlinks). A "clause" is one
* condition tuple like ["field", "ilike", "%term%"] or a nested group.
*/
export type FilterClause = unknown[];
export function escapeLikeTerm(term: string): string {
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
}
/** Splits a comma/plus separated terms string into trimmed lowercase terms. */
export function parseFilterTerms(value: string | undefined): string[] {
if (!value) return [];
return value
.toLowerCase()
.split(/[,+]/)
.map((term) => term.trim())
.filter(Boolean);
}
export function collectNumericRange(
out: FilterClause[],
field: string,
min: number | undefined,
max: number | undefined,
) {
if (typeof min === "number" && Number.isFinite(min)) {
out.push([field, ">=", min]);
}
if (typeof max === "number" && Number.isFinite(max)) {
out.push([field, "<=", max]);
}
}
/**
* One ilike condition per include term, joined with "or" into a single nested
* group (match-any semantics). Returns the group clause plus how many of the
* DataForSEO condition budget it consumes.
*/
export function buildIncludeOrGroup(
field: string,
include: string | undefined,
): { clause: FilterClause; conditionCount: number } | null {
const conditions = parseFilterTerms(include).map((term) => [
field,
"ilike",
`%${escapeLikeTerm(term)}%`,
]);
if (conditions.length === 0) return null;
const first = conditions[0];
if (conditions.length === 1 && first) {
return { clause: first, conditionCount: 1 };
}
return {
clause: joinClauses(conditions, "or"),
conditionCount: conditions.length,
};
}
/**
* DataForSEO accepts up to 8 filter conditions per request. The clients
* surface the same condition count and disable Apply when over budget, so
* reaching the cap here indicates a misbehaving client we throw rather
* than silently truncate.
*/
export function assertFilterConditionBudget(conditionCount: number): void {
if (conditionCount > MAX_DATAFORSEO_FILTER_CONDITIONS) {
throw new AppError(
"VALIDATION_ERROR",
`Too many filter conditions (${conditionCount} of ${MAX_DATAFORSEO_FILTER_CONDITIONS} max).`,
);
}
}
export function joinClauses(
clauses: FilterClause[],
operator: "and" | "or",
): unknown[] {
const expressions: unknown[] = [];
for (const clause of clauses) {
if (expressions.length > 0) expressions.push(operator);
expressions.push(clause);
}
return expressions;
}

View File

@ -57,24 +57,28 @@ export const getBacklinksOverviewTool = {
const lookup = { target: args.target, scope: args.scope }; const lookup = { target: args.target, scope: args.scope };
const spamOptions = { hideSpam: args.hideSpam ?? true }; const spamOptions = { hideSpam: args.hideSpam ?? true };
const [overview, refDomains] = await Promise.all([ const [overview, refDomains] = await Promise.all([
BacklinksService.profileOverview(lookup, context.billing, spamOptions), BacklinksService.profileOverview(lookup, context.billing),
BacklinksService.profileReferringDomains( BacklinksService.profileReferringDomainsPage(
lookup, {
...lookup,
page: 1,
pageSize: 100,
sortField: "backlinks",
sortOrder: "desc",
filters: {},
},
context.billing, context.billing,
spamOptions, spamOptions,
), ),
]); ]);
const topDomains = refDomains.rows ?? []; const topDomains = refDomains.rows ?? [];
const overviewRecord = const summary = overview.overview.summary;
overview && typeof overview === "object"
? (overview as Record<string, unknown>)
: {};
const text = [ const text = [
`Backlinks profile for ${args.target} (${args.scope ?? "domain"}):`, `Backlinks profile for ${args.target} (${args.scope ?? "domain"}):`,
`- backlinks: ${formatMetric(overviewRecord.backlinks)}`, `- backlinks: ${formatMetric(summary.backlinks)}`,
`- referring domains: ${formatMetric(overviewRecord.referring_domains)}`, `- referring domains: ${formatMetric(summary.referringDomains)}`,
`- referring pages: ${formatMetric(overviewRecord.referring_pages)}`, `- referring pages: ${formatMetric(summary.referringPages)}`,
`- rank: ${formatMetric(overviewRecord.rank)}`, `- rank: ${formatMetric(summary.rank)}`,
"", "",
`Top referring domains (${Math.min(topDomains.length, 10)} shown):`, `Top referring domains (${Math.min(topDomains.length, 10)} shown):`,
...topDomains ...topDomains

View File

@ -1,7 +1,16 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService"; import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
import { requireProjectContext } from "@/serverFunctions/middleware"; import { requireProjectContext } from "@/serverFunctions/middleware";
import { backlinksOverviewInputSchema } from "@/types/schemas/backlinks"; import {
backlinksOverviewInputSchema,
backlinksRowsPageRequestSchema,
referringDomainsPageRequestSchema,
topPagesPageRequestSchema,
} from "@/types/schemas/backlinks";
// The web UI exposes spam score as a regular user filter, so the implicit
// DataForSEO spam-score cutoff stays off for all web requests.
const WEB_SPAM_OPTIONS = { hideSpam: false };
export const getBacklinksOverview = createServerFn({ export const getBacklinksOverview = createServerFn({
method: "POST", method: "POST",
@ -9,49 +18,45 @@ export const getBacklinksOverview = createServerFn({
.middleware(requireProjectContext) .middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const input = { const profile = await BacklinksService.profileOverview(
{
target: data.target, target: data.target,
scope: data.scope, scope: data.scope,
}; },
const spamOptions = {
hideSpam: data.hideSpam,
spamThreshold: data.spamThreshold,
};
const profile = await BacklinksService.profileOverview(
input,
context, context,
spamOptions,
); );
return profile.overview; return profile.overview;
}); });
export const getBacklinksRows = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksRowsPageRequestSchema.parse(data))
.handler(({ data, context }) =>
BacklinksService.profileBacklinksPage(data, context, WEB_SPAM_OPTIONS),
);
export const getBacklinksReferringDomains = createServerFn({ export const getBacklinksReferringDomains = createServerFn({
method: "POST", method: "POST",
}) })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) .inputValidator((data: unknown) =>
.handler(async ({ data, context }) => { referringDomainsPageRequestSchema.parse(data),
const input = { )
target: data.target, .handler(({ data, context }) =>
scope: data.scope, BacklinksService.profileReferringDomainsPage(
}; data,
const profile = await BacklinksService.profileReferringDomains(
input,
context, context,
WEB_SPAM_OPTIONS,
),
); );
return profile.rows;
});
export const getBacklinksTopPages = createServerFn({ export const getBacklinksTopPages = createServerFn({
method: "POST", method: "POST",
}) })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) .inputValidator((data: unknown) => topPagesPageRequestSchema.parse(data))
.handler(async ({ data, context }) => { .handler(({ data, context }) =>
const input = { BacklinksService.profileTopPagesPage(data, context),
target: data.target, );
scope: data.scope,
};
const profile = await BacklinksService.profileTopPages(input, context);
return profile.rows;
});

View File

@ -40,23 +40,192 @@ export const backlinksProjectSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
}); });
const backlinksSpamFilterSchema = z.object({ export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({
hideSpam: z.boolean().optional(), projectId: z.string().min(1),
spamThreshold: z.number().int().min(0).max(100).optional(),
}); });
export const backlinksOverviewInputSchema = backlinksLookupSchema /* ------------------------------------------------------------------ */
.extend({ /* Paginated tab requests */
/* ------------------------------------------------------------------ */
export const BACKLINKS_PAGE_SIZES = [50, 100, 200] as const;
export const DEFAULT_BACKLINKS_PAGE_SIZE = 100;
const optionalNumber = z
.union([
z.number(),
z.string().transform((value, ctx) => {
const trimmed = value.trim();
if (trimmed === "") return undefined;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) {
ctx.addIssue({ code: "custom", message: "Invalid number" });
return z.NEVER;
}
return parsed;
}),
])
.optional();
export const backlinksRowsFiltersSchema = z.object({
include: z.string().optional(),
exclude: z.string().optional(),
minDomainRank: optionalNumber,
maxDomainRank: optionalNumber,
minLinkAuthority: optionalNumber,
maxLinkAuthority: optionalNumber,
minSpamScore: optionalNumber,
maxSpamScore: optionalNumber,
linkType: z.enum(["dofollow", "nofollow"]).optional(),
hideLost: z.boolean().optional(),
hideBroken: z.boolean().optional(),
/** Exact-match on the linking domain; used to expand one domain's links. */
domainFrom: z.string().max(255).optional(),
});
/**
* DataForSEO result grouping for the backlinks list: `one_per_domain` returns
* each referring domain's strongest link (the default, denoised view);
* `as_is` returns every individual backlink.
*/
const backlinksRowsModeSchema = z.enum(["one_per_domain", "as_is"]);
export const referringDomainsFiltersSchema = z.object({
include: z.string().optional(),
exclude: z.string().optional(),
minBacklinks: optionalNumber,
maxBacklinks: optionalNumber,
minRank: optionalNumber,
maxRank: optionalNumber,
minSpamScore: optionalNumber,
maxSpamScore: optionalNumber,
});
export const topPagesFiltersSchema = z.object({
include: z.string().optional(),
exclude: z.string().optional(),
minBacklinks: optionalNumber,
maxBacklinks: optionalNumber,
minReferringDomains: optionalNumber,
maxReferringDomains: optionalNumber,
minRank: optionalNumber,
maxRank: optionalNumber,
});
export const backlinksSortOrderSchema = z.enum(["asc", "desc"]);
// Sort field names double as table column ids on the client; the server maps
// them to DataForSEO field names.
export const backlinksRowsSortFieldSchema = z.enum([
"rank",
"domainRank",
"spamScore",
"firstSeen",
]);
export const referringDomainsSortFieldSchema = z.enum([
"domain",
"backlinks",
"referringPages",
"rank",
"spamScore",
"firstSeen",
"brokenBacklinks",
]);
export const topPagesSortFieldSchema = z.enum([
"backlinks",
"referringDomains",
"rank",
"brokenBacklinks",
]);
/** Single source for each tab's default sort, shared by the request-schema
* defaults and the client's header indicators / query fallbacks. */
export const BACKLINKS_DEFAULT_SORT = {
backlinks: { field: "rank", order: "desc" },
domains: { field: "backlinks", order: "desc" },
pages: { field: "backlinks", order: "desc" },
} as const satisfies Record<
z.infer<typeof backlinksTabSchema>,
{ field: string; order: z.infer<typeof backlinksSortOrderSchema> }
>;
const backlinksPageRequestBase = backlinksLookupSchema.extend({
projectId: z.string().min(1), projectId: z.string().min(1),
}) page: z.number().int().positive().default(1),
.merge(backlinksSpamFilterSchema); pageSize: z
.number()
.int()
.refine((value) =>
(BACKLINKS_PAGE_SIZES as readonly number[]).includes(value),
)
.default(DEFAULT_BACKLINKS_PAGE_SIZE),
sortOrder: backlinksSortOrderSchema.default("desc"),
});
export const backlinksRowsPageRequestSchema = backlinksPageRequestBase.extend({
sortField: backlinksRowsSortFieldSchema.default(
BACKLINKS_DEFAULT_SORT.backlinks.field,
),
filters: backlinksRowsFiltersSchema.default({}),
mode: backlinksRowsModeSchema.default("one_per_domain"),
});
export const referringDomainsPageRequestSchema =
backlinksPageRequestBase.extend({
sortField: referringDomainsSortFieldSchema.default(
BACKLINKS_DEFAULT_SORT.domains.field,
),
filters: referringDomainsFiltersSchema.default({}),
});
export const topPagesPageRequestSchema = backlinksPageRequestBase.extend({
sortField: topPagesSortFieldSchema.default(
BACKLINKS_DEFAULT_SORT.pages.field,
),
filters: topPagesFiltersSchema.default({}),
});
export const backlinksSearchSchema = z.object({ export const backlinksSearchSchema = z.object({
target: z.string().optional(), target: z.string().optional(),
scope: backlinksTargetScopeSchema.optional(), scope: backlinksTargetScopeSchema.optional(),
tab: backlinksTabSchema.optional(), tab: backlinksTabSchema.optional(),
page: z.coerce.number().int().positive().optional().catch(undefined),
size: z.coerce
.number()
.int()
.refine((value) =>
(BACKLINKS_PAGE_SIZES as readonly number[]).includes(value),
)
.optional()
.catch(undefined),
// Sort column id for the active tab; validated against the tab's sort-field
// enum when building the request, so a mismatched value falls back to the
// tab's default sort.
sort: z.string().optional().catch(undefined),
order: backlinksSortOrderSchema.optional().catch(undefined),
// Backlinks tab only: "all" shows every link; default is one per domain.
view: z.literal("all").optional().catch(undefined),
}); });
export type BacklinksLookupInput = z.infer<typeof backlinksLookupSchema>; export type BacklinksLookupInput = z.infer<typeof backlinksLookupSchema>;
export type BacklinksTab = z.infer<typeof backlinksTabSchema>; export type BacklinksTab = z.infer<typeof backlinksTabSchema>;
export type BacklinksTargetScope = z.infer<typeof backlinksTargetScopeSchema>; export type BacklinksTargetScope = z.infer<typeof backlinksTargetScopeSchema>;
export type BacklinksSortOrder = z.infer<typeof backlinksSortOrderSchema>;
export type BacklinksRowsSortField = z.infer<
typeof backlinksRowsSortFieldSchema
>;
export type ReferringDomainsSortField = z.infer<
typeof referringDomainsSortFieldSchema
>;
export type TopPagesSortField = z.infer<typeof topPagesSortFieldSchema>;
export type BacklinksRowsFilters = z.infer<typeof backlinksRowsFiltersSchema>;
export type ReferringDomainsFilters = z.infer<
typeof referringDomainsFiltersSchema
>;
export type TopPagesFilters = z.infer<typeof topPagesFiltersSchema>;
export type BacklinksRowsPageInput = z.infer<
typeof backlinksRowsPageRequestSchema
>;
export type ReferringDomainsPageInput = z.infer<
typeof referringDomainsPageRequestSchema
>;
export type TopPagesPageInput = z.infer<typeof topPagesPageRequestSchema>;