diff --git a/src/client/features/backlinks/BacklinksPage.tsx b/src/client/features/backlinks/BacklinksPage.tsx
index 2f68616..e931aa6 100644
--- a/src/client/features/backlinks/BacklinksPage.tsx
+++ b/src/client/features/backlinks/BacklinksPage.tsx
@@ -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 (
@@ -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}
/>
diff --git a/src/client/features/backlinks/BacklinksPageContent.tsx b/src/client/features/backlinks/BacklinksPageContent.tsx
index 7bdef34..4229772 100644
--- a/src/client/features/backlinks/BacklinksPageContent.tsx
+++ b/src/client/features/backlinks/BacklinksPageContent.tsx
@@ -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 ;
}
@@ -86,76 +121,20 @@ export function BacklinksBody({
);
}
- return (
-
- );
-}
-
-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 ;
}
- if (isLoading) {
+ if (overviewLoading) {
return ;
}
if (!mergedData) {
return (
-
+
);
}
@@ -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))
diff --git a/src/client/features/backlinks/BacklinksPageSections.tsx b/src/client/features/backlinks/BacklinksPageSections.tsx
index 2f66094..6d39827 100644
--- a/src/client/features/backlinks/BacklinksPageSections.tsx
+++ b/src/client/features/backlinks/BacklinksPageSections.tsx
@@ -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 (
@@ -72,8 +81,12 @@ export function BacklinksResultsCard({
{tabErrorMessage ? (
@@ -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 (
@@ -157,14 +194,49 @@ function ResultsHeader({
-
+
);
}
diff --git a/src/client/features/backlinks/BacklinksTableColumns.tsx b/src/client/features/backlinks/BacklinksTableColumns.tsx
index 8981259..e567301 100644
--- a/src/client/features/backlinks/BacklinksTableColumns.tsx
+++ b/src/client/features/backlinks/BacklinksTableColumns.tsx
@@ -294,6 +294,33 @@ export const backlinksColumns: ColumnDef
[] = [
},
sortingFn: "basic",
},
+ {
+ id: "spamScore",
+ accessorKey: "spamScore",
+ header: ({ column }) => (
+
+ ),
+ size: 70,
+ minSize: 50,
+ cell: ({ row }) => {
+ const value =
+ row.depth > 0
+ ? row.original._backlink?.spamScore
+ : row.original.spamScore;
+
+ return (
+
+ {value != null && value > 0 ? Math.round(value) : null}
+
+ );
+ },
+ sortingFn: "basic",
+ },
{
id: "firstSeen",
accessorKey: "firstSeen",
diff --git a/src/client/features/backlinks/BacklinksTableHeaders.tsx b/src/client/features/backlinks/BacklinksTableHeaders.tsx
index e323878..94b9ea6 100644
--- a/src/client/features/backlinks/BacklinksTableHeaders.tsx
+++ b/src/client/features/backlinks/BacklinksTableHeaders.tsx
@@ -6,7 +6,6 @@ import {
type SortDirection,
type TopPagesTableSort,
} from "./backlinksTableSorting";
-
export function ReferringDomainsTableHeader({
sort,
onSortChange,
diff --git a/src/client/features/backlinks/backlinksTableSorting.ts b/src/client/features/backlinks/backlinksTableSorting.ts
index 2597aae..f036f33 100644
--- a/src/client/features/backlinks/backlinksTableSorting.ts
+++ b/src/client/features/backlinks/backlinksTableSorting.ts
@@ -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(
direction: current.direction === "asc" ? "desc" : "asc",
};
}
-
export function sortReferringDomainRows(
rows: BacklinksOverviewData["referringDomains"],
sort: ReferringDomainsTableSort,
diff --git a/src/client/features/backlinks/useBacklinksPageData.ts b/src/client/features/backlinks/useBacklinksPageData.ts
index 5138f41..6a7b697 100644
--- a/src/client/features/backlinks/useBacklinksPageData.ts
+++ b/src/client/features/backlinks/useBacklinksPageData.ts
@@ -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) &&
diff --git a/src/client/features/backlinks/useBacklinksSpamPreferences.ts b/src/client/features/backlinks/useBacklinksSpamPreferences.ts
new file mode 100644
index 0000000..f5ddfae
--- /dev/null
+++ b/src/client/features/backlinks/useBacklinksSpamPreferences.ts
@@ -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(
+ 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,
+ };
+}
diff --git a/src/server/features/backlinks/services/BacklinksService.ts b/src/server/features/backlinks/services/BacklinksService.ts
index a3d4013..2c7fab6 100644
--- a/src/server/features/backlinks/services/BacklinksService.ts
+++ b/src/server/features/backlinks/services/BacklinksService.ts
@@ -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 {
- 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 {
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) }
+ : {}),
});
}
diff --git a/src/server/features/backlinks/services/backlinksServiceData.ts b/src/server/features/backlinks/services/backlinksServiceData.ts
index 578fd88..6ece1b3 100644
--- a/src/server/features/backlinks/services/backlinksServiceData.ts
+++ b/src/server/features/backlinks/services/backlinksServiceData.ts
@@ -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 {
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 {
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()),
diff --git a/src/server/lib/dataforseoBacklinks.ts b/src/server/lib/dataforseoBacklinks.ts
index 27c8a0f..96630c0 100644
--- a/src/server/lib/dataforseoBacklinks.ts
+++ b/src/server/lib/dataforseoBacklinks.ts
@@ -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 & {
- limit?: number;
-};
+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(
diff --git a/src/types/schemas/backlinks.ts b/src/types/schemas/backlinks.ts
index 308f99b..48a8ffe 100644
--- a/src/types/schemas/backlinks.ts
+++ b/src/types/schemas/backlinks.ts
@@ -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(),