From a10f4d82a014bdb1ce02ce7289a6d9113e3c9b8b Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:21:58 -0400 Subject: [PATCH] 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 --- .../features/backlinks/BacklinksPage.tsx | 12 +- .../backlinks/BacklinksPageContent.tsx | 117 ++++++++---------- .../backlinks/BacklinksPageSections.tsx | 88 +++++++++++-- .../backlinks/BacklinksTableColumns.tsx | 27 ++++ .../backlinks/BacklinksTableHeaders.tsx | 1 - .../backlinks/backlinksTableSorting.ts | 2 - .../backlinks/useBacklinksPageData.ts | 14 +-- .../backlinks/useBacklinksSpamPreferences.ts | 100 +++++++++++++++ .../backlinks/services/BacklinksService.ts | 60 ++++++--- .../services/backlinksServiceData.ts | 37 ++++-- src/server/lib/dataforseoBacklinks.ts | 21 +++- src/types/schemas/backlinks.ts | 39 +++++- 12 files changed, 402 insertions(+), 116 deletions(-) create mode 100644 src/client/features/backlinks/useBacklinksSpamPreferences.ts 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({

- +
+ + {activeTab === "backlinks" ? ( +
+ + +
+ ) : null} +
); } 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(),