feat: hide spam backlinks by default (#89)

* feat: hide spam backlinks by default

* feat: add configurable backlinks spam filtering

* refactor: simplify backlinks spam filtering flow

Reduce duplicated spam-filter state and normalization so the backlinks page has one source of truth and client/server caching stays aligned.

* refactor: simplify backlinks spam filter semantics

* fix: keep spam filtering scoped to backlink rows

* feat: show backlinks spam score
This commit is contained in:
Ben Senescu 2026-04-07 18:21:58 -04:00 committed by Ben Senescu
parent 673459877e
commit a10f4d82a0
12 changed files with 402 additions and 116 deletions

View File

@ -6,6 +6,7 @@ import {
navigateToBacklinksTab,
useBacklinksPageData,
} from "./useBacklinksPageData";
import { useBacklinksSpamPreferences } from "./useBacklinksSpamPreferences";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
export function BacklinksPage({
@ -13,6 +14,8 @@ export function BacklinksPage({
searchState,
navigate,
}: BacklinksPageProps) {
const { hideSpam, setHideSpam, setSpamThreshold, spamThreshold } =
useBacklinksSpamPreferences();
const {
accessStatus,
accessStatusErrorMessage,
@ -26,7 +29,10 @@ export function BacklinksPage({
searchCardInitialValues,
testAccessMutation,
topPagesQuery,
} = useBacklinksPageData({ projectId, searchState });
} = useBacklinksPageData({
projectId,
searchState,
});
return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
@ -81,10 +87,14 @@ export function BacklinksPage({
}
testIsPending={testAccessMutation.isPending}
topPages={topPagesQuery.data}
hideSpam={hideSpam}
spamThreshold={spamThreshold}
onRetryAccess={() => void accessStatusQuery.refetch()}
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
onRetryOverview={() => void overviewQuery.refetch()}
onTestAccess={() => testAccessMutation.mutate()}
onSetHideSpam={setHideSpam}
onSetSpamThreshold={setSpamThreshold}
/>
</div>
</div>

View File

@ -25,11 +25,13 @@ type BacklinksBodyProps = {
backlinksDisabledByError: boolean;
backlinksEnabled: boolean;
isAccessStatusLoading: boolean;
hideSpam: boolean;
overviewData: BacklinksOverviewData | undefined;
overviewError: string | null;
overviewLoading: boolean;
referringDomains: BacklinksReferringDomainsData | undefined;
searchState: BacklinksSearchState;
spamThreshold: number;
tabErrorMessage: string | null;
tabLoading: boolean;
testError: string | null;
@ -39,6 +41,8 @@ type BacklinksBodyProps = {
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onRetryOverview: () => void;
onTestAccess: () => void;
onSetHideSpam: (hideSpam: boolean) => void;
onSetSpamThreshold: (threshold: number) => void;
};
export function BacklinksBody({
@ -47,11 +51,13 @@ export function BacklinksBody({
backlinksDisabledByError,
backlinksEnabled,
isAccessStatusLoading,
hideSpam,
overviewData,
overviewError,
overviewLoading,
referringDomains,
searchState,
spamThreshold,
tabErrorMessage,
tabLoading,
testError,
@ -61,7 +67,36 @@ export function BacklinksBody({
onSetActiveTab,
onRetryOverview,
onTestAccess,
onSetHideSpam,
onSetSpamThreshold,
}: BacklinksBodyProps) {
const [filterText, setFilterText] = useState("");
useEffect(() => {
setFilterText("");
}, [searchState.target, searchState.tab]);
const mergedData = useMemo(
() => mergeTabData(overviewData, referringDomains, topPages),
[overviewData, referringDomains, topPages],
);
const normalizedFilter = filterText.trim().toLowerCase();
const filteredData = useMemo(
() =>
filterOverviewData(
mergedData,
normalizedFilter,
searchState.tab,
hideSpam,
spamThreshold,
),
[hideSpam, mergedData, normalizedFilter, searchState.tab, spamThreshold],
);
const summaryStats = useMemo(
() => buildSummaryStats(mergedData),
[mergedData],
);
if (isAccessStatusLoading) {
return <BacklinksAccessLoadingState />;
}
@ -86,76 +121,20 @@ export function BacklinksBody({
);
}
return (
<BacklinksContent
data={overviewData}
errorMessage={overviewError}
isLoading={overviewLoading}
referringDomains={referringDomains}
searchState={searchState}
tabErrorMessage={tabErrorMessage}
tabLoading={tabLoading}
topPages={topPages}
onSetActiveTab={onSetActiveTab}
onRetry={onRetryOverview}
/>
);
}
function BacklinksContent({
data,
errorMessage,
isLoading,
referringDomains,
searchState,
tabErrorMessage,
tabLoading,
topPages,
onSetActiveTab,
onRetry,
}: {
data: BacklinksOverviewData | undefined;
errorMessage: string | null;
isLoading: boolean;
referringDomains: BacklinksReferringDomainsData | undefined;
searchState: BacklinksSearchState;
tabErrorMessage: string | null;
tabLoading: boolean;
topPages: BacklinksTopPagesData | undefined;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onRetry: () => void;
}) {
const [filterText, setFilterText] = useState("");
useEffect(() => {
setFilterText("");
}, [searchState.target, searchState.tab]);
const mergedData = useMemo(
() => mergeTabData(data, referringDomains, topPages),
[data, referringDomains, topPages],
);
const normalizedFilter = filterText.trim().toLowerCase();
const filteredData = useMemo(
() => filterOverviewData(mergedData, normalizedFilter),
[mergedData, normalizedFilter],
);
const summaryStats = useMemo(
() => buildSummaryStats(mergedData),
[mergedData],
);
if (!searchState.target) {
return <BacklinksEmptyState />;
}
if (isLoading) {
if (overviewLoading) {
return <BacklinksLoadingState />;
}
if (!mergedData) {
return (
<BacklinksErrorState errorMessage={errorMessage} onRetry={onRetry} />
<BacklinksErrorState
errorMessage={overviewError}
onRetry={onRetryOverview}
/>
);
}
@ -166,12 +145,16 @@ function BacklinksContent({
activeTab={searchState.tab}
filteredData={filteredData}
filterText={filterText}
hideSpam={hideSpam}
spamThreshold={spamThreshold}
isTabLoading={searchState.tab !== "backlinks" && tabLoading}
tabErrorMessage={
searchState.tab !== "backlinks" ? tabErrorMessage : null
}
onFilterTextChange={setFilterText}
onSetActiveTab={onSetActiveTab}
onSetHideSpam={onSetHideSpam}
onSetSpamThreshold={onSetSpamThreshold}
/>
</>
);
@ -196,13 +179,23 @@ function mergeTabData(
function filterOverviewData(
data: BacklinksOverviewData | undefined,
normalizedFilter: string,
activeTab: BacklinksSearchState["tab"],
hideSpam: boolean,
spamThreshold: number,
) {
if (!data) {
return { backlinks: [], referringDomains: [], topPages: [] };
}
const backlinksRows =
activeTab === "backlinks" && hideSpam
? data.backlinks.filter(
(row) => row.spamScore == null || row.spamScore <= spamThreshold,
)
: data.backlinks;
return {
backlinks: data.backlinks.filter((row) => {
backlinks: backlinksRows.filter((row) => {
if (!normalizedFilter) return true;
return [row.domainFrom, row.urlFrom, row.urlTo, row.anchor, row.itemType]
.filter((value): value is string => Boolean(value))

View File

@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { Search } from "lucide-react";
import {
@ -49,10 +50,14 @@ export function BacklinksResultsCard({
activeTab,
filteredData,
filterText,
hideSpam,
spamThreshold,
isTabLoading,
tabErrorMessage,
onFilterTextChange,
onSetActiveTab,
onSetHideSpam,
onSetSpamThreshold,
}: {
activeTab: BacklinksSearchState["tab"];
filteredData: {
@ -61,10 +66,14 @@ export function BacklinksResultsCard({
topPages: BacklinksOverviewData["topPages"];
};
filterText: string;
hideSpam: boolean;
spamThreshold: number;
isTabLoading: boolean;
tabErrorMessage: string | null;
onFilterTextChange: (value: string) => void;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onSetHideSpam: (hideSpam: boolean) => void;
onSetSpamThreshold: (threshold: number) => void;
}) {
return (
<div className="card bg-base-100 border border-base-300">
@ -72,8 +81,12 @@ export function BacklinksResultsCard({
<ResultsHeader
activeTab={activeTab}
filterText={filterText}
hideSpam={hideSpam}
spamThreshold={spamThreshold}
onFilterTextChange={onFilterTextChange}
onSetActiveTab={onSetActiveTab}
onSetHideSpam={onSetHideSpam}
onSetSpamThreshold={onSetSpamThreshold}
/>
{tabErrorMessage ? (
<div className="alert alert-error">
@ -122,14 +135,38 @@ function OverviewGrid({
function ResultsHeader({
activeTab,
filterText,
hideSpam,
spamThreshold,
onFilterTextChange,
onSetActiveTab,
onSetHideSpam,
onSetSpamThreshold,
}: {
activeTab: BacklinksSearchState["tab"];
filterText: string;
hideSpam: boolean;
spamThreshold: number;
onFilterTextChange: (value: string) => void;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onSetHideSpam: (hideSpam: boolean) => void;
onSetSpamThreshold: (threshold: number) => void;
}) {
const [draftSpamThreshold, setDraftSpamThreshold] = useState(
String(spamThreshold),
);
useEffect(() => {
setDraftSpamThreshold(String(spamThreshold));
}, [spamThreshold]);
function commitSpamThreshold() {
onSetSpamThreshold(
draftSpamThreshold.trim() === ""
? spamThreshold
: Number(draftSpamThreshold),
);
}
return (
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
@ -157,6 +194,7 @@ function ResultsHeader({
</p>
</div>
<div className="flex flex-col items-end gap-2">
<label className="input input-bordered input-sm flex w-full max-w-xs items-center gap-2">
<Search className="size-4 text-base-content/60" />
<input
@ -165,6 +203,40 @@ function ResultsHeader({
onChange={(event) => onFilterTextChange(event.target.value)}
/>
</label>
{activeTab === "backlinks" ? (
<div className="flex items-center gap-3 text-sm">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={hideSpam}
onChange={(event) => onSetHideSpam(event.target.checked)}
/>
<span className="text-base-content/70">Hide spam</span>
</label>
<label className="flex items-center gap-2 text-base-content/70">
<span>Max spam</span>
<input
type="number"
min={0}
max={100}
step={1}
value={draftSpamThreshold}
disabled={!hideSpam}
className="input input-bordered input-xs w-20"
onBlur={commitSpamThreshold}
onChange={(event) => setDraftSpamThreshold(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
commitSpamThreshold();
event.currentTarget.blur();
}
}}
/>
</label>
</div>
) : null}
</div>
</div>
);
}

View File

@ -294,6 +294,33 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
},
sortingFn: "basic",
},
{
id: "spamScore",
accessorKey: "spamScore",
header: ({ column }) => (
<SortableHeader
column={column}
label="Spam"
helpText="Estimated spam risk for the linking domain or backlink. Higher scores are more likely to be manipulative or low quality."
align="right"
/>
),
size: 70,
minSize: 50,
cell: ({ row }) => {
const value =
row.depth > 0
? row.original._backlink?.spamScore
: row.original.spamScore;
return (
<div className="text-right tabular-nums text-sm">
{value != null && value > 0 ? Math.round(value) : null}
</div>
);
},
sortingFn: "basic",
},
{
id: "firstSeen",
accessorKey: "firstSeen",

View File

@ -6,7 +6,6 @@ import {
type SortDirection,
type TopPagesTableSort,
} from "./backlinksTableSorting";
export function ReferringDomainsTableHeader({
sort,
onSortChange,

View File

@ -1,7 +1,6 @@
import type { BacklinksOverviewData } from "./backlinksPageTypes";
export type SortDirection = "asc" | "desc";
export type ReferringDomainsTableSortField =
| "domain"
| "backlinks"
@ -51,7 +50,6 @@ export function getNextSort<TField extends string>(
direction: current.direction === "asc" ? "desc" : "asc",
};
}
export function sortReferringDomainRows(
rows: BacklinksOverviewData["referringDomains"],
sort: ReferringDomainsTableSort,

View File

@ -52,10 +52,7 @@ export function useBacklinksPageData({
)
: null;
const backlinksEnabled = accessStatus?.enabled ?? false;
const requestInput = useMemo(
() => buildBacklinksRequestInput(projectId, searchState),
[projectId, searchState],
);
const requestInput = buildBacklinksRequestInput(projectId, searchState);
const searchCardInitialValues = useMemo(
() => ({
target: searchState.target,
@ -71,20 +68,19 @@ export function useBacklinksPageData({
},
});
const queryKeyParts = [
const baseQueryKeyParts = [
projectId,
searchState.scope,
searchState.target,
] as const;
const overviewQuery = useQuery({
queryKey: ["backlinksOverview", ...queryKeyParts],
queryKey: ["backlinksOverview", ...baseQueryKeyParts],
enabled: backlinksEnabled && Boolean(searchState.target),
queryFn: () => getBacklinksOverview({ data: requestInput }),
});
const referringDomainsQuery = useQuery({
queryKey: ["backlinksReferringDomains", ...queryKeyParts],
queryKey: ["backlinksReferringDomains", ...baseQueryKeyParts],
enabled:
backlinksEnabled &&
Boolean(searchState.target) &&
@ -93,7 +89,7 @@ export function useBacklinksPageData({
});
const topPagesQuery = useQuery({
queryKey: ["backlinksTopPages", ...queryKeyParts],
queryKey: ["backlinksTopPages", ...baseQueryKeyParts],
enabled:
backlinksEnabled &&
Boolean(searchState.target) &&

View File

@ -0,0 +1,100 @@
import { useEffect, useState } from "react";
import {
DEFAULT_BACKLINKS_SPAM_THRESHOLD,
normalizeBacklinksSpamThreshold,
} from "@/types/schemas/backlinks";
const STORAGE_KEY = "backlinks-spam-preferences";
type BacklinksSpamPreferences = {
hideSpam: boolean;
spamThreshold: number;
};
const DEFAULT_PREFERENCES: BacklinksSpamPreferences = {
hideSpam: true,
spamThreshold: DEFAULT_BACKLINKS_SPAM_THRESHOLD,
};
function normalizeBacklinksSpamPreferences(
value: unknown,
): BacklinksSpamPreferences {
if (!value || typeof value !== "object") {
return DEFAULT_PREFERENCES;
}
const preferences = value as {
hideSpam?: unknown;
spamThreshold?: unknown;
};
return {
hideSpam:
typeof preferences.hideSpam === "boolean"
? preferences.hideSpam
: DEFAULT_PREFERENCES.hideSpam,
spamThreshold: normalizeBacklinksSpamThreshold(
typeof preferences.spamThreshold === "number"
? preferences.spamThreshold
: DEFAULT_PREFERENCES.spamThreshold,
),
};
}
function loadBacklinksSpamPreferences() {
if (typeof window === "undefined") {
return DEFAULT_PREFERENCES;
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_PREFERENCES;
return normalizeBacklinksSpamPreferences(JSON.parse(raw));
} catch {
return DEFAULT_PREFERENCES;
}
}
function saveBacklinksSpamPreferences(preferences: BacklinksSpamPreferences) {
if (typeof window === "undefined") {
return;
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
} catch {
// storage full or unavailable - silently ignore
}
}
export function useBacklinksSpamPreferences() {
const [preferences, setPreferences] = useState<BacklinksSpamPreferences>(
loadBacklinksSpamPreferences,
);
useEffect(() => {
saveBacklinksSpamPreferences(preferences);
}, [preferences]);
function setHideSpam(nextHideSpam: boolean) {
setPreferences((current) => ({
...current,
hideSpam: nextHideSpam,
}));
}
function setSpamThreshold(nextSpamThreshold: number) {
setPreferences((current) => ({
...current,
spamThreshold: normalizeBacklinksSpamThreshold(nextSpamThreshold),
}));
}
return {
hideSpam: preferences.hideSpam,
setHideSpam,
setSpamThreshold,
spamThreshold: preferences.spamThreshold,
};
}

View File

@ -1,5 +1,9 @@
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinks";
import {
normalizeBacklinksSpamFilterOptions,
type BacklinksSpamFilterOptions,
} from "@/types/schemas/backlinks";
import {
profileBacklinksOverview,
profileReferringDomainsRows,
@ -19,19 +23,33 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) {
async profileOverview(
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions,
) {
const cacheKey = await buildOverviewCacheKey(input, billingCustomer);
const cacheKey = await buildBacklinksCacheKey(
"backlinks:overview",
input,
billingCustomer,
options,
);
return profileBacklinksOverview(cache, cacheKey, input, billingCustomer);
return profileBacklinksOverview(
cache,
cacheKey,
input,
billingCustomer,
options,
);
},
async profileReferringDomains(
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions,
) {
const cacheKey = await buildTabCacheKey(
const cacheKey = await buildBacklinksCacheKey(
"backlinks:referring-domains",
input,
billingCustomer,
options,
);
return profileReferringDomainsRows(
@ -39,13 +57,14 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) {
cacheKey,
input,
billingCustomer,
options,
);
},
async profileTopPages(
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
) {
const cacheKey = await buildTabCacheKey(
const cacheKey = await buildBacklinksCacheKey(
"backlinks:top-pages",
input,
billingCustomer,
@ -56,32 +75,33 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) {
} as const;
}
async function buildOverviewCacheKey(
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
): Promise<string> {
const normalizedTarget = normalizeBacklinksTarget(input.target, {
scope: input.scope,
});
return buildCacheKey("backlinks:overview", {
organizationId: billingCustomer.organizationId,
target: normalizedTarget.apiTarget,
scope: normalizedTarget.scope,
});
}
async function buildTabCacheKey(
async function buildBacklinksCacheKey(
prefix: string,
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions,
): Promise<string> {
const normalizedTarget = normalizeBacklinksTarget(input.target, {
scope: input.scope,
});
return buildCacheKey(prefix, {
const cacheKeyInput = {
organizationId: billingCustomer.organizationId,
target: normalizedTarget.apiTarget,
scope: normalizedTarget.scope,
};
if (!options) {
return buildCacheKey(prefix, cacheKeyInput);
}
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(options);
return buildCacheKey(prefix, {
...cacheKeyInput,
hideSpam: String(spamFilterOptions.hideSpam),
...(spamFilterOptions.hideSpam
? { spamThreshold: String(spamFilterOptions.spamThreshold) }
: {}),
});
}

View File

@ -10,6 +10,10 @@ import {
normalizeBacklinksTarget,
} from "@/server/lib/dataforseoBacklinks";
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import {
normalizeBacklinksSpamFilterOptions,
type BacklinksSpamFilterOptions,
} from "@/types/schemas/backlinks";
import {
backlinksOverviewSchema,
referringDomainRowSchema,
@ -58,6 +62,7 @@ export async function profileBacklinksOverview(
cacheKey: string,
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions,
): Promise<BacklinksOverviewProfile> {
const cachedRaw = await cache.get(cacheKey);
const cached = backlinksOverviewCacheSchema.safeParse(cachedRaw);
@ -73,12 +78,16 @@ export async function profileBacklinksOverview(
const normalizedTarget = normalizeBacklinksTarget(input.target, {
scope: input.scope,
});
const request = buildBacklinksRequest(normalizedTarget.apiTarget);
const request = buildBacklinksListRequest(
normalizedTarget.apiTarget,
100,
options,
);
const dateRange = buildBacklinksDateRange(now);
const [summary, backlinks, history] = await Promise.all([
dataforseo.backlinks.summary(request),
dataforseo.backlinks.rows({ ...request, limit: 100 }),
dataforseo.backlinks.summary({ target: request.target }),
dataforseo.backlinks.rows(request),
normalizedTarget.scope === "domain"
? dataforseo.backlinks.history({
target: normalizedTarget.apiTarget,
@ -109,6 +118,7 @@ export async function profileReferringDomainsRows(
cacheKey: string,
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions,
): Promise<ReferringDomainsProfile> {
const cachedRaw = await cache.get(cacheKey);
const cached = referringDomainsCacheSchema.safeParse(cachedRaw);
@ -120,13 +130,12 @@ export async function profileReferringDomainsRows(
const dataforseo = createDataforseoClient(billingCustomer);
const request = buildBacklinksRequest(
const request = buildBacklinksListRequest(
normalizeBacklinksTarget(input.target, { scope: input.scope }).apiTarget,
100,
options,
);
const response = await dataforseo.backlinks.referringDomains({
...request,
limit: 100,
});
const response = await dataforseo.backlinks.referringDomains(request);
const rows = mapReferringDomainsRows(response);
await cacheValue(cache, cacheKey, { rows }, BACKLINKS_TAB_TTL_SECONDS);
@ -168,6 +177,18 @@ function buildBacklinksRequest(target: string): BacklinksRequest {
return { target };
}
function buildBacklinksListRequest(
target: string,
limit: number,
options?: BacklinksSpamFilterOptions,
) {
return {
...buildBacklinksRequest(target),
limit,
...normalizeBacklinksSpamFilterOptions(options),
};
}
function buildBacklinksDateRange(now: Date): BacklinksDateRange {
const todayUtc = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),

View File

@ -1,4 +1,8 @@
import { AppError } from "@/server/lib/errors";
import {
normalizeBacklinksSpamFilterOptions,
type BacklinksSpamFilterOptions,
} from "@/types/schemas/backlinks";
import type {
DataforseoApiCallCost,
DataforseoApiResponse,
@ -24,9 +28,10 @@ export type BacklinksRequest = {
target: string;
};
export type BacklinksListRequest = BacklinksRequest & {
export type BacklinksListRequest = BacklinksRequest &
BacklinksSpamFilterOptions & {
limit?: number;
};
};
export type BacklinksTimeseriesRequest = {
target: string;
@ -186,11 +191,16 @@ export async function fetchBacklinksSummaryRaw(input: BacklinksRequest) {
}
export async function fetchBacklinksRowsRaw(input: BacklinksListRequest) {
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
const filters = spamFilterOptions.hideSpam
? [["backlink_spam_score", "<=", spamFilterOptions.spamThreshold]]
: undefined;
const response = await postBacklinks("/v3/backlinks/backlinks/live", [
{
...buildCommonPayload(input),
limit: input.limit ?? 100,
order_by: ["rank,desc"],
...(filters ? { filters } : {}),
},
]);
const data = parseItems(
@ -205,11 +215,16 @@ export async function fetchBacklinksRowsRaw(input: BacklinksListRequest) {
}
export async function fetchReferringDomainsRaw(input: BacklinksListRequest) {
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
const filters = spamFilterOptions.hideSpam
? [["backlinks_spam_score", "<=", spamFilterOptions.spamThreshold]]
: undefined;
const response = await postBacklinks("/v3/backlinks/referring_domains/live", [
{
...buildCommonPayload(input),
limit: input.limit ?? 100,
order_by: ["backlinks,desc"],
...(filters ? { filters } : {}),
},
]);
const data = parseItems(

View File

@ -2,7 +2,35 @@ import { z } from "zod";
export const backlinksTabSchema = z.enum(["backlinks", "domains", "pages"]);
export const backlinksTargetScopeSchema = z.enum(["domain", "page"]);
export const DEFAULT_BACKLINKS_SPAM_THRESHOLD = 40;
export function normalizeBacklinksSpamThreshold(value: number) {
if (!Number.isFinite(value)) {
return DEFAULT_BACKLINKS_SPAM_THRESHOLD;
}
return Math.min(100, Math.max(0, Math.trunc(value)));
}
export type BacklinksSpamFilterOptions = {
hideSpam?: boolean;
spamThreshold?: number;
};
export function normalizeBacklinksSpamFilterOptions(
options?: BacklinksSpamFilterOptions,
) {
const hideSpam = options?.hideSpam ?? true;
return {
hideSpam,
spamThreshold: hideSpam
? normalizeBacklinksSpamThreshold(
options?.spamThreshold ?? DEFAULT_BACKLINKS_SPAM_THRESHOLD,
)
: undefined,
};
}
export const backlinksLookupSchema = z.object({
target: z.string().min(1, "Target is required").max(2048),
scope: backlinksTargetScopeSchema.optional(),
@ -12,10 +40,17 @@ export const backlinksProjectSchema = z.object({
projectId: z.string().min(1),
});
export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({
projectId: z.string().min(1),
const backlinksSpamFilterSchema = z.object({
hideSpam: z.boolean().optional(),
spamThreshold: z.number().int().min(0).max(100).optional(),
});
export const backlinksOverviewInputSchema = backlinksLookupSchema
.extend({
projectId: z.string().min(1),
})
.merge(backlinksSpamFilterSchema);
export const backlinksSearchSchema = z.object({
target: z.string().optional(),
scope: backlinksTargetScopeSchema.optional(),