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:
parent
673459877e
commit
a10f4d82a0
@ -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>
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
type SortDirection,
|
||||
type TopPagesTableSort,
|
||||
} from "./backlinksTableSorting";
|
||||
|
||||
export function ReferringDomainsTableHeader({
|
||||
sort,
|
||||
onSortChange,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) &&
|
||||
|
||||
100
src/client/features/backlinks/useBacklinksSpamPreferences.ts
Normal file
100
src/client/features/backlinks/useBacklinksSpamPreferences.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import {
|
||||
normalizeBacklinksSpamFilterOptions,
|
||||
type BacklinksSpamFilterOptions,
|
||||
} from "@/types/schemas/backlinks";
|
||||
import type {
|
||||
DataforseoApiCallCost,
|
||||
DataforseoApiResponse,
|
||||
@ -24,7 +28,8 @@ export type BacklinksRequest = {
|
||||
target: string;
|
||||
};
|
||||
|
||||
export type BacklinksListRequest = BacklinksRequest & {
|
||||
export type BacklinksListRequest = BacklinksRequest &
|
||||
BacklinksSpamFilterOptions & {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user