feat: Add Backlinks page (#23)

* feat: add backlinks setup and overview workflow

* save

* fix backlinks timeseries and refine overview layout

* update backlinks table guidance

* refactor backlinks CI cleanup

* refactor backlinks page state colocation

* fix backlinks access error handling

Differentiate DataForSEO subscription access failures from billing issues and preserve exact page hosts for page-level backlink lookups.

* fix backlinks project scoping and error states

* simplify backlinks billing and lazy loading

* harden backlinks profiling and access flow

* refine backlinks access status typing

* update backlinks scope selection and access handling

* fix backlinks scope fallback and cache scoping

* fix backlinks ci checks

* docs: simplify backlinks pricing

* refine backlinks search: move scope toggle below input, remove redundant description

* refine backlinks summaries and help text

* refine backlinks table sorting

* fix backlinks invalid target handling

* delete docs

* fix backlinks scope inference and domain normalization

* docs: mark backlinks as a supported workflow

* fix backlinks links badge wrapping

* test: split backlinks test suites
This commit is contained in:
Ben Senescu 2026-03-16 17:48:13 -04:00 committed by GitHub
parent b859fae8dc
commit 8405564925
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 5225 additions and 6 deletions

View File

@ -32,6 +32,8 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
- Find topics worth targeting, estimate demand, and prioritize what to write next.
- Domain insights
- Understand where your domain is gaining or losing visibility so you can focus on the pages that move revenue.
- Backlinks
- See who links to your site, which pages attract links, and where links are newly won or lost.
- Site Audits
- Catch technical issues early so your site is easier for search engines to crawl and rank.
@ -39,7 +41,6 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
Top priorities:
- Backlinks
- Rank tracking
- AI content workflows
@ -70,6 +71,8 @@ For cost estimates, see [DataForSEO API Cost Reference](#seo-api-cost-reference)
OpenSEO uses DataForSEO to fetch SEO data. You need an API key to connect OpenSEO to the service.
Backlinks requires one more step beyond the API key: you also need DataForSEO Backlinks enabled on your account (trial or paid subscription), then confirm access from the Backlinks page in OpenSEO.
1. Go to [DataForSEO API Access](https://app.dataforseo.com/api-access).
2. Request API credentials by email (`API key by email` or `API password by email`).
3. Use your DataForSEO login + API password, then base64 encode `login:password`:
@ -246,6 +249,7 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
### Pricing sources
- DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
- Google PageSpeed Insights API docs: https://developers.google.com/speed/docs/insights/v5/get-started
### 1) Site audit
@ -268,8 +272,18 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
- General formula if needed:
- `0.0201 + (0.0001 x ranked_keywords_returned)` USD
### 4) Backlinks search
- Backlinks search costs about `$0.08` for a domain or `$0.04` for a page.
- Opening extra tabs like `Referring Domains` or `Top Pages` adds about `+$0.02` each.
- Exact cost can vary slightly based on returned rows and DataForSEO pricing.
### Planning examples
- 100 keyword research requests at the default 150 results: `$3.50`
- 100 keyword research requests at 500 results each: `$7.00`
- 100 domain overviews (200 ranked keywords each): `$4.01`
- 100 backlinks domain searches at current defaults before opening extra tabs: about `$8.38`
- 100 backlinks page searches at current defaults before opening extra tabs: about `$4.30`
- 100 fully explored backlinks domain searches: about `$12.98`
- 100 fully explored backlinks page searches: about `$8.61`

View File

@ -7,6 +7,7 @@
"scripts": {
"dev": "AUTH_MODE=local_noauth vite dev",
"dev:agents": "mkdir -p .logs && AUTH_MODE=local_noauth portless run vite dev 2>&1 | tee .logs/dev-server.log",
"dev:agents:force": "mkdir -p .logs && AUTH_MODE=local_noauth portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
"build": "vite build && tsc --noEmit",
"lint": "oxlint . --type-aware",
"lint:fix": "oxlint . --type-aware --fix",
@ -24,6 +25,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:ci": "vitest run --reporter=dot",
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
},
"cloudflare": {
@ -87,6 +89,7 @@
"oxlint-tsgolint": "^0.15.0",
"portless": "^0.5.2",
"prettier": "^3.6.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.1.2",
"vite-tsconfig-paths": "^5.1.4",

3
pnpm-lock.yaml generated
View File

@ -135,6 +135,9 @@ importers:
prettier:
specifier: ^3.6.2
version: 3.8.1
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5.9.3
version: 5.9.3

View File

@ -0,0 +1,195 @@
import { existsSync, readFileSync } from "node:fs";
import process from "node:process";
import { createBacklinksService } from "@/server/features/backlinks/services/BacklinksService";
import type { BacklinksLookupInput } from "@/types/schemas/backlinks";
loadLocalEnv();
const args = parseArgs(process.argv.slice(2));
const inMemoryCache = new Map<string, string>();
const service = createBacklinksService({
async get(key) {
const raw = inMemoryCache.get(key);
return raw ? parseCachedValue(raw) : null;
},
async set(key, data) {
inMemoryCache.set(key, JSON.stringify(data));
},
});
await main();
async function main() {
if (process.env.CI === "true" && args.allowCi !== "true") {
printUsageAndExit(
"Refusing to run live billing checks in CI without --allowCi=true.",
);
}
if (args.confirmLive !== "true") {
printUsageAndExit(
"This command makes live, billable DataForSEO requests. Re-run with --confirmLive=true.",
);
}
const input = buildInput(args);
const repeat = parsePositiveInteger(args.repeat, 1);
const includeTabs = parseBoolean(args.includeTabs, true);
const runs = [];
for (let index = 0; index < repeat; index += 1) {
const overview = await service.profileOverview(input);
const domains = includeTabs
? await service.profileReferringDomains(input)
: null;
const pages = includeTabs ? await service.profileTopPages(input) : null;
runs.push({
run: index + 1,
overview: {
fromCache: overview.billing.fromCache,
totalCostUsd: overview.billing.totalCostUsd,
calls: overview.billing.calls,
},
domainsTab: domains
? {
fromCache: domains.billing.fromCache,
totalCostUsd: domains.billing.totalCostUsd,
calls: domains.billing.calls,
}
: null,
pagesTab: pages
? {
fromCache: pages.billing.fromCache,
totalCostUsd: pages.billing.totalCostUsd,
calls: pages.billing.calls,
}
: null,
fullyExploredCostUsd: roundUsd(
overview.billing.totalCostUsd +
(domains?.billing.totalCostUsd ?? 0) +
(pages?.billing.totalCostUsd ?? 0),
),
});
}
console.log(
JSON.stringify(
{
input,
repeat,
includeTabs,
runs,
},
null,
2,
),
);
}
function buildInput(cliArgs: Record<string, string>): BacklinksLookupInput {
const target = cliArgs.target;
if (!target) {
printUsageAndExit("Missing target.");
}
if (!process.env.DATAFORSEO_API_KEY) {
printUsageAndExit("Missing DATAFORSEO_API_KEY.");
}
return {
target,
includeSubdomains: parseBoolean(cliArgs.subdomains, true),
includeIndirectLinks: parseBoolean(cliArgs.indirect, true),
excludeInternalBacklinks: parseBoolean(cliArgs.excludeInternal, true),
status: parseStatus(cliArgs.status),
};
}
function parseArgs(argv: string[]) {
const parsed: Record<string, string> = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith("--")) continue;
const withoutPrefix = token.slice(2);
const separatorIndex = withoutPrefix.indexOf("=");
if (separatorIndex >= 0) {
parsed[withoutPrefix.slice(0, separatorIndex)] = withoutPrefix.slice(
separatorIndex + 1,
);
continue;
}
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
parsed[withoutPrefix] = "true";
continue;
}
parsed[withoutPrefix] = next;
index += 1;
}
return parsed;
}
function parseBoolean(value: string | undefined, fallback: boolean) {
if (value == null) return fallback;
return value === "true";
}
function parseStatus(
value: string | undefined,
): BacklinksLookupInput["status"] {
if (value === "live" || value === "lost" || value === "all") {
return value;
}
return "live";
}
function parsePositiveInteger(value: string | undefined, fallback: number) {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function loadLocalEnv() {
for (const path of [".env.local", ".env"]) {
if (!existsSync(path)) continue;
const content = readFileSync(path, "utf8");
for (const line of content.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex < 0) continue;
const key = trimmed.slice(0, separatorIndex).trim();
const rawValue = trimmed.slice(separatorIndex + 1).trim();
if (!key || process.env[key]) continue;
process.env[key] = rawValue.replace(/^['"]|['"]$/g, "");
}
}
}
function roundUsd(value: number) {
return Math.round(value * 100000) / 100000;
}
function printUsageAndExit(message: string): never {
console.error(message);
console.error(
"Usage: pnpm billing:backlinks --target=example.com --confirmLive=true [--status=live|lost|all] [--subdomains=true|false] [--indirect=true|false] [--excludeInternal=true|false] [--repeat=1] [--includeTabs=true|false] [--allowCi=true]",
);
process.exit(1);
}
function parseCachedValue(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}

View File

@ -0,0 +1,92 @@
import { BacklinksSearchCard } from "./BacklinksSearchCard";
import { BacklinksBody } from "./BacklinksPageContent";
import type { BacklinksPageProps } from "./backlinksPageTypes";
import {
navigateToBacklinksSearch,
navigateToBacklinksTab,
useBacklinksPageData,
} from "./useBacklinksPageData";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
export function BacklinksPage({
projectId,
searchState,
navigate,
}: BacklinksPageProps) {
const {
accessStatus,
accessStatusErrorMessage,
accessStatusQuery,
activeTabErrorMessage,
backlinksDisabledByError,
backlinksEnabled,
overviewErrorMessage,
overviewQuery,
referringDomainsQuery,
searchCardInitialValues,
testAccessMutation,
topPagesQuery,
} = useBacklinksPageData({ projectId, searchState });
return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4">
<div>
<h1 className="text-2xl font-semibold">Backlinks</h1>
<p className="text-sm text-base-content/70">
Understand who links to a site, what changed recently, and which
pages attract links.
</p>
</div>
{!accessStatusQuery.isLoading &&
backlinksEnabled &&
!backlinksDisabledByError ? (
<BacklinksSearchCard
errorMessage={overviewErrorMessage}
initialValues={searchCardInitialValues}
isFetching={
overviewQuery.isFetching ||
referringDomainsQuery.isFetching ||
topPagesQuery.isFetching
}
onSubmit={(values) => navigateToBacklinksSearch(navigate, values)}
/>
) : null}
<BacklinksBody
accessStatus={accessStatus}
accessStatusError={accessStatusErrorMessage}
backlinksDisabledByError={backlinksDisabledByError}
backlinksEnabled={backlinksEnabled}
isAccessStatusLoading={accessStatusQuery.isLoading}
overviewData={overviewQuery.data}
overviewError={overviewErrorMessage}
overviewLoading={overviewQuery.isLoading}
referringDomains={referringDomainsQuery.data}
searchState={searchState}
tabErrorMessage={activeTabErrorMessage}
tabLoading={
(searchState.tab === "domains" &&
referringDomainsQuery.isLoading) ||
(searchState.tab === "pages" && topPagesQuery.isLoading)
}
testError={
testAccessMutation.error
? getStandardErrorMessage(
testAccessMutation.error,
"Could not test Backlinks access.",
)
: null
}
testIsPending={testAccessMutation.isPending}
topPages={topPagesQuery.data}
onRetryAccess={() => void accessStatusQuery.refetch()}
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
onRetryOverview={() => void overviewQuery.refetch()}
onTestAccess={() => testAccessMutation.mutate()}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,140 @@
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import {
formatFullDate,
formatMonthLabel,
formatTooltipValue,
} from "./backlinksPageUtils";
export function BacklinksTrendChart({
data,
}: {
data: BacklinksOverviewData["trends"];
}) {
if (data.length === 0) {
return <EmptyChartState />;
}
return (
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ left: 8, right: 8, top: 8, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="currentColor"
opacity={0.12}
/>
<XAxis
dataKey="date"
tickFormatter={formatChartTick}
minTickGap={24}
/>
<YAxis />
<Tooltip
formatter={formatTooltipValue}
labelFormatter={formatChartLabel}
/>
<Legend />
<Line
type="monotone"
dataKey="backlinks"
stroke="#2563eb"
strokeWidth={2}
dot={false}
name="Backlinks"
/>
<Line
type="monotone"
dataKey="referringDomains"
stroke="#14b8a6"
strokeWidth={2}
dot={false}
name="Referring domains"
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
export function BacklinksNewLostChart({
data,
}: {
data: BacklinksOverviewData["newLostTrends"];
}) {
if (data.length === 0) {
return <EmptyChartState />;
}
return (
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ left: 8, right: 8, top: 8, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="currentColor"
opacity={0.12}
/>
<XAxis
dataKey="date"
tickFormatter={formatChartTick}
minTickGap={24}
/>
<YAxis />
<Tooltip
formatter={formatTooltipValue}
labelFormatter={formatChartLabel}
/>
<Legend />
<Line
type="monotone"
dataKey="newBacklinks"
stroke="#16a34a"
strokeWidth={2}
dot={false}
name="New backlinks"
/>
<Line
type="monotone"
dataKey="lostBacklinks"
stroke="#dc2626"
strokeWidth={2}
dot={false}
name="Lost backlinks"
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
function EmptyChartState() {
return (
<div className="flex h-56 items-center justify-center rounded-xl border border-dashed border-base-300 text-sm text-base-content/55">
Not enough historical data yet.
</div>
);
}
function formatChartTick(value: unknown) {
return typeof value === "string" ? formatMonthLabel(value) : "";
}
function formatChartLabel(value: unknown) {
return typeof value === "string" ? formatFullDate(value) : "";
}

View File

@ -0,0 +1,220 @@
import { useEffect, useMemo, useState } from "react";
import {
BacklinksOverviewPanels,
BacklinksResultsCard,
} from "./BacklinksPageSections";
import {
BacklinksAccessLoadingState,
BacklinksEmptyState,
BacklinksErrorState,
BacklinksLoadingState,
BacklinksSetupGate,
} from "./BacklinksPageStates";
import type {
BacklinksAccessStatusData,
BacklinksOverviewData,
BacklinksReferringDomainsData,
BacklinksSearchState,
BacklinksTopPagesData,
} from "./backlinksPageTypes";
import { buildSummaryStats } from "./backlinksPageUtils";
type BacklinksBodyProps = {
accessStatus: BacklinksAccessStatusData | undefined;
accessStatusError: string | null;
backlinksDisabledByError: boolean;
backlinksEnabled: boolean;
isAccessStatusLoading: boolean;
overviewData: BacklinksOverviewData | undefined;
overviewError: string | null;
overviewLoading: boolean;
referringDomains: BacklinksReferringDomainsData | undefined;
searchState: BacklinksSearchState;
tabErrorMessage: string | null;
tabLoading: boolean;
testError: string | null;
testIsPending: boolean;
topPages: BacklinksTopPagesData | undefined;
onRetryAccess: () => void;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onRetryOverview: () => void;
onTestAccess: () => void;
};
export function BacklinksBody({
accessStatus,
accessStatusError,
backlinksDisabledByError,
backlinksEnabled,
isAccessStatusLoading,
overviewData,
overviewError,
overviewLoading,
referringDomains,
searchState,
tabErrorMessage,
tabLoading,
testError,
testIsPending,
topPages,
onRetryAccess,
onSetActiveTab,
onRetryOverview,
onTestAccess,
}: BacklinksBodyProps) {
if (isAccessStatusLoading) {
return <BacklinksAccessLoadingState />;
}
if (accessStatusError) {
return (
<BacklinksErrorState
errorMessage={accessStatusError}
onRetry={onRetryAccess}
/>
);
}
if (!backlinksEnabled || backlinksDisabledByError) {
return (
<BacklinksSetupGate
status={accessStatus}
isTesting={testIsPending}
testError={testError}
onTest={onTestAccess}
/>
);
}
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.status, 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) {
return <BacklinksLoadingState />;
}
if (!mergedData) {
return (
<BacklinksErrorState errorMessage={errorMessage} onRetry={onRetry} />
);
}
return (
<>
<BacklinksOverviewPanels data={mergedData} summaryStats={summaryStats} />
<BacklinksResultsCard
activeTab={searchState.tab}
filteredData={filteredData}
filterText={filterText}
isTabLoading={searchState.tab !== "backlinks" && tabLoading}
tabErrorMessage={
searchState.tab !== "backlinks" ? tabErrorMessage : null
}
onFilterTextChange={setFilterText}
onSetActiveTab={onSetActiveTab}
/>
</>
);
}
function mergeTabData(
data: BacklinksOverviewData | undefined,
referringDomains: BacklinksReferringDomainsData | undefined,
topPages: BacklinksTopPagesData | undefined,
) {
if (!data) {
return undefined;
}
return {
...data,
referringDomains: referringDomains ?? data.referringDomains,
topPages: topPages ?? data.topPages,
};
}
function filterOverviewData(
data: BacklinksOverviewData | undefined,
normalizedFilter: string,
) {
if (!data) {
return { backlinks: [], referringDomains: [], topPages: [] };
}
return {
backlinks: data.backlinks.filter((row) => {
if (!normalizedFilter) return true;
return [row.domainFrom, row.urlFrom, row.urlTo, row.anchor, row.itemType]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLowerCase().includes(normalizedFilter));
}),
referringDomains: data.referringDomains.filter((row) => {
if (!normalizedFilter) return true;
return row.domain?.toLowerCase().includes(normalizedFilter) ?? false;
}),
topPages: data.topPages.filter((row) => {
if (!normalizedFilter) return true;
return row.page?.toLowerCase().includes(normalizedFilter) ?? false;
}),
};
}

View File

@ -0,0 +1,7 @@
export function EmptyTableState({ label }: { label: string }) {
return (
<div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55">
{label}
</div>
);
}

View File

@ -0,0 +1,53 @@
import { ExternalLink } from "lucide-react";
import { extractUrlPath, truncateMiddle } from "./backlinksPageUtils";
export function BacklinksExternalLink({
url,
label,
className,
}: {
url: string;
label: string;
className: string;
}) {
const safeUrl = getSafeExternalUrl(url);
if (!safeUrl) {
return <span className={className}>{label}</span>;
}
return (
<a className={className} href={safeUrl} target="_blank" rel="noreferrer">
{label}
<ExternalLink className="size-3 shrink-0" />
</a>
);
}
export function BacklinksSourceLink({
url,
maxLength,
muted = false,
}: {
url: string;
maxLength: number;
muted?: boolean;
}) {
return (
<BacklinksExternalLink
url={url}
label={truncateMiddle(extractUrlPath(url), maxLength)}
className={`link link-hover break-all inline-flex items-center gap-1 ${muted ? "text-xs text-base-content/55" : "text-sm"}`}
/>
);
}
function getSafeExternalUrl(value: string) {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString()
: null;
} catch {
return null;
}
}

View File

@ -0,0 +1,276 @@
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { Search } from "lucide-react";
import {
BacklinksNewLostChart,
BacklinksTrendChart,
} from "./BacklinksPageCharts";
import {
BacklinksTable,
ReferringDomainsTable,
TopPagesTable,
} from "./BacklinksPageTables";
import type {
BacklinksOverviewData,
BacklinksSearchState,
} from "./backlinksPageTypes";
import {
TAB_DESCRIPTIONS,
formatRelativeTimestamp,
} from "./backlinksPageUtils";
export function BacklinksOverviewPanels({
data,
summaryStats,
}: {
data: BacklinksOverviewData;
summaryStats: Array<{ label: string; value: string; description: string }>;
}) {
return (
<>
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
<span className="badge badge-outline">{data.scope}</span>
<span>Target: {data.displayTarget}</span>
<span>-</span>
<span>Updated {formatRelativeTimestamp(data.fetchedAt)}</span>
</div>
<OverviewGrid data={data} summaryStats={summaryStats} />
{data.scope === "page" ? (
<div className="alert alert-info">
<span>
Showing backlinks for this exact page. Enter a bare domain for
site-wide results. Trend charts are only shown for domain-level
lookups.
</span>
</div>
) : null}
</>
);
}
export function BacklinksResultsCard({
activeTab,
filteredData,
filterText,
isTabLoading,
tabErrorMessage,
onFilterTextChange,
onSetActiveTab,
}: {
activeTab: BacklinksSearchState["tab"];
filteredData: {
backlinks: BacklinksOverviewData["backlinks"];
referringDomains: BacklinksOverviewData["referringDomains"];
topPages: BacklinksOverviewData["topPages"];
};
filterText: string;
isTabLoading: boolean;
tabErrorMessage: string | null;
onFilterTextChange: (value: string) => void;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
}) {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<ResultsHeader
activeTab={activeTab}
filterText={filterText}
onFilterTextChange={onFilterTextChange}
onSetActiveTab={onSetActiveTab}
/>
{tabErrorMessage ? (
<div className="alert alert-error">
<span>{tabErrorMessage}</span>
</div>
) : null}
{activeTab === "backlinks" ? (
<BacklinksTable rows={filteredData.backlinks} />
) : null}
{activeTab === "domains" && isTabLoading && !tabErrorMessage ? (
<TabLoadingState label="Loading referring domains" />
) : null}
{activeTab === "domains" && !isTabLoading && !tabErrorMessage ? (
<ReferringDomainsTable rows={filteredData.referringDomains} />
) : null}
{activeTab === "pages" && isTabLoading && !tabErrorMessage ? (
<TabLoadingState label="Loading top pages" />
) : null}
{activeTab === "pages" && !isTabLoading && !tabErrorMessage ? (
<TopPagesTable rows={filteredData.topPages} />
) : null}
</div>
</div>
);
}
function OverviewGrid({
data,
summaryStats,
}: {
data: BacklinksOverviewData;
summaryStats: Array<{ label: string; value: string; description: string }>;
}) {
const domainScope = data.scope === "domain";
return (
<div
className={`grid grid-cols-1 gap-3 ${domainScope ? "md:grid-cols-2 xl:grid-cols-3" : ""}`}
>
<SummaryStatsGrid data={data} summaryStats={summaryStats} />
{domainScope ? <TrendPanels data={data} /> : null}
</div>
);
}
function ResultsHeader({
activeTab,
filterText,
onFilterTextChange,
onSetActiveTab,
}: {
activeTab: BacklinksSearchState["tab"];
filterText: string;
onFilterTextChange: (value: string) => void;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
}) {
return (
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div role="tablist" className="tabs tabs-box w-fit">
<TabButton
activeTab={activeTab}
tab="backlinks"
onClick={onSetActiveTab}
>
Backlinks
</TabButton>
<TabButton
activeTab={activeTab}
tab="domains"
onClick={onSetActiveTab}
>
Referring Domains
</TabButton>
<TabButton activeTab={activeTab} tab="pages" onClick={onSetActiveTab}>
Top Pages
</TabButton>
</div>
<p className="max-w-xl text-sm text-base-content/60">
{TAB_DESCRIPTIONS[activeTab]}
</p>
</div>
<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
placeholder="Filter current tab"
value={filterText}
onChange={(event) => onFilterTextChange(event.target.value)}
/>
</label>
</div>
);
}
function SummaryStatsGrid({
data,
summaryStats,
}: {
data: BacklinksOverviewData;
summaryStats: Array<{ label: string; value: string; description: string }>;
}) {
const cardClassName = `card bg-base-100 border border-base-300 ${data.scope === "domain" ? "md:col-span-2 xl:col-span-1" : ""}`;
return (
<div className={cardClassName}>
<div className="card-body p-4 xl:h-full">
<div className="grid grid-cols-2 gap-x-6 gap-y-5 xl:gap-y-6">
{summaryStats.map((item) => (
<div key={item.label}>
<div className="text-xs uppercase tracking-wide text-base-content/55">
<HeaderHelpLabel
label={item.label}
helpText={item.description}
/>
</div>
<p className="text-2xl font-semibold">{item.value}</p>
</div>
))}
</div>
</div>
</div>
);
}
function TrendPanels({ data }: { data: BacklinksOverviewData }) {
return (
<>
<TrendCard
title="Backlink growth"
description="Backlinks and referring domains over the last year"
>
<BacklinksTrendChart data={data.trends} />
</TrendCard>
<TrendCard
title="New vs lost"
description="Backlink acquisition and attrition"
>
<BacklinksNewLostChart data={data.newLostTrends} />
</TrendCard>
</>
);
}
function TrendCard({
children,
description,
title,
}: {
children: React.ReactNode;
description: string;
title: string;
}) {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-2 p-4">
<div>
<h2 className="text-sm font-medium">{title}</h2>
<p className="text-xs text-base-content/55">{description}</p>
</div>
{children}
</div>
</div>
);
}
function TabButton({
activeTab,
children,
onClick,
tab,
}: {
activeTab: BacklinksSearchState["tab"];
children: string;
onClick: (tab: BacklinksSearchState["tab"]) => void;
tab: BacklinksSearchState["tab"];
}) {
return (
<button
role="tab"
className={`tab ${activeTab === tab ? "tab-active" : ""}`}
onClick={() => onClick(tab)}
>
{children}
</button>
);
}
function TabLoadingState({ label }: { label: string }) {
return (
<div className="space-y-3 py-2">
<p className="text-sm text-base-content/60">{label}...</p>
<div className="skeleton h-10 w-full" />
<div className="skeleton h-10 w-full" />
<div className="skeleton h-10 w-full" />
</div>
);
}

View File

@ -0,0 +1,19 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { BacklinksErrorState } from "./BacklinksPageStates";
describe("BacklinksErrorState", () => {
it("renders a visible retry state", () => {
const markup = renderToStaticMarkup(
createElement(BacklinksErrorState, {
errorMessage: "Could not load backlinks data.",
onRetry: vi.fn(),
}),
);
expect(markup).toContain("Could not load backlinks");
expect(markup).toContain("Could not load backlinks data.");
expect(markup).toContain("Retry");
});
});

View File

@ -0,0 +1,235 @@
import {
Link2,
ShieldAlert,
Sparkles,
TrendingUp,
Wrench,
type LucideIcon,
} from "lucide-react";
import type { BacklinksAccessStatusData } from "./backlinksPageTypes";
import { formatRelativeTimestamp } from "./backlinksPageUtils";
export function BacklinksAccessLoadingState() {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<div className="skeleton h-6 w-48" />
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-4/5" />
<div className="skeleton h-10 w-48" />
</div>
</div>
);
}
export function BacklinksSetupGate({
status,
isTesting,
testError,
onTest,
}: {
status: BacklinksAccessStatusData | undefined;
isTesting: boolean;
testError: string | null;
onTest: () => void;
}) {
return (
<section>
<div className="rounded-2xl border border-base-300 bg-base-100 p-6 md:p-7 space-y-5">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-warning/15 p-2.5 text-warning shrink-0">
<Wrench className="size-5" />
</div>
<div className="max-w-3xl space-y-1.5">
<h2 className="text-xl font-semibold">Enable Backlinks</h2>
<p className="text-sm text-base-content/68">
Backlinks is not enabled for your DataForSEO account yet. Turn it
on in DataForSEO, then test access here.
</p>
<p className="text-xs text-base-content/50">
DataForSEO offers a free 14-day trial for Backlinks. Then, it's
$100/month. We're gauging interest in building out a lower-cost
alternative, <InlineMailingListLink /> if you're interested.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
className="btn btn-primary"
onClick={onTest}
disabled={isTesting}
>
{isTesting ? "Confirming..." : "Confirm Backlinks Access"}
</button>
<a
className="btn btn-outline"
href="https://app.dataforseo.com/api-access-subscriptions"
target="_blank"
rel="noreferrer"
>
Open DataForSEO Backlinks
</a>
</div>
<BacklinksSetupFeedback status={status} testError={testError} />
</div>
</section>
);
}
export function BacklinksEmptyState() {
return (
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-8 text-center space-y-3">
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Link2 className="size-6" />
</div>
<div className="space-y-2">
<p className="text-lg font-medium">Start with a domain or page URL</p>
<p className="mx-auto max-w-2xl text-sm text-base-content/65">
Keep backlink research simple: see who links to you, check what pages
attract links, and spot recent wins or losses without getting buried
in enterprise SEO dashboards.
</p>
</div>
<div className="grid gap-3 pt-2 text-left md:grid-cols-3">
<BeginnerCard
icon={TrendingUp}
title="Check link health"
body="Use the overview to see backlink totals, referring domains, and broken links worth fixing."
/>
<BeginnerCard
icon={Sparkles}
title="Review real links"
body="Start with the backlinks tab to see the pages linking to you and the strongest sources first."
/>
<BeginnerCard
icon={ShieldAlert}
title="Find easy actions"
body="Look for broken targets, recently lost links, and your most-linked pages to decide what to do next."
/>
</div>
</section>
);
}
export function BacklinksLoadingState() {
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="card bg-base-100 border border-base-300">
<div className="card-body gap-3 p-4">
<div className="skeleton h-3 w-24" />
<div className="skeleton h-8 w-28" />
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{Array.from({ length: 2 }).map((_, index) => (
<div key={index} className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<div className="skeleton h-4 w-32" />
<div className="skeleton h-64 w-full" />
</div>
</div>
))}
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<div className="skeleton h-8 w-60" />
<div className="skeleton h-80 w-full" />
</div>
</div>
</div>
);
}
export function BacklinksErrorState({
errorMessage,
onRetry,
}: {
errorMessage: string | null;
onRetry: () => void;
}) {
return (
<section className="rounded-2xl border border-error/30 bg-error/5 p-6 space-y-3">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-error/10 p-2.5 text-error shrink-0">
<ShieldAlert className="size-5" />
</div>
<div className="space-y-1">
<h2 className="text-lg font-semibold">Could not load backlinks</h2>
<p className="text-sm text-base-content/70">
{errorMessage ?? "Please try again in a moment."}
</p>
</div>
</div>
<button className="btn btn-outline btn-sm" onClick={onRetry}>
Retry
</button>
</section>
);
}
function BacklinksSetupFeedback({
status,
testError,
}: {
status: BacklinksAccessStatusData | undefined;
testError: string | null;
}) {
return (
<div className="space-y-3">
{status?.lastCheckedAt ? (
<div className="text-sm text-base-content/60">
Last checked {formatRelativeTimestamp(status.lastCheckedAt)}.
</div>
) : null}
{status?.lastErrorMessage ? (
<div className="alert alert-warning">
<ShieldAlert className="size-4 shrink-0" />
<span>{status.lastErrorMessage}</span>
</div>
) : null}
{testError ? (
<div className="alert alert-error">
<ShieldAlert className="size-4 shrink-0" />
<span>{testError}</span>
</div>
) : null}
</div>
);
}
function BeginnerCard({
icon: Icon,
title,
body,
}: {
icon: LucideIcon;
title: string;
body: string;
}) {
return (
<div className="rounded-xl border border-base-300 bg-base-100 p-4 space-y-2">
<Icon className="size-4 text-primary" />
<p className="font-medium">{title}</p>
<p className="text-sm text-base-content/65">{body}</p>
</div>
);
}
function InlineMailingListLink() {
return (
<a
className="underline underline-offset-2 hover:text-base-content/70"
href="https://openrank.io"
target="_blank"
rel="noreferrer"
>
join the openrank.io mailing list
</a>
);
}

View File

@ -0,0 +1,232 @@
import { useMemo, useState } from "react";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import {
BacklinksTableHeader,
ReferringDomainsTableHeader,
TopPagesTableHeader,
} from "./BacklinksTableHeaders";
import {
BacklinksExternalLink,
BacklinksSourceLink,
} from "./BacklinksPageLinks";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import {
DEFAULT_BACKLINKS_SORT,
DEFAULT_REFERRING_DOMAINS_SORT,
DEFAULT_TOP_PAGES_SORT,
sortBacklinkRows,
sortReferringDomainRows,
sortTopPageRows,
} from "./backlinksTableSorting";
import {
formatCompactDate,
formatDecimal,
formatNumber,
} from "./backlinksPageUtils";
export function BacklinksTable({
rows,
}: {
rows: BacklinksOverviewData["backlinks"];
}) {
const [sort, setSort] = useState(DEFAULT_BACKLINKS_SORT);
const sortedRows = useMemo(() => sortBacklinkRows(rows, sort), [rows, sort]);
if (rows.length === 0) {
return <EmptyTableState label="No backlinks match this filter." />;
}
return (
<div className="overflow-x-auto">
<table className="table table-sm table-zebra">
<BacklinksTableHeader sort={sort} onSortChange={setSort} />
<tbody>
{sortedRows.map((row, index) => (
<BacklinksTableRow
key={`${row.urlFrom ?? row.domainFrom ?? "row"}-${index}`}
row={row}
/>
))}
</tbody>
</table>
</div>
);
}
export function ReferringDomainsTable({
rows,
}: {
rows: BacklinksOverviewData["referringDomains"];
}) {
const [sort, setSort] = useState(DEFAULT_REFERRING_DOMAINS_SORT);
const sortedRows = useMemo(
() => sortReferringDomainRows(rows, sort),
[rows, sort],
);
if (rows.length === 0) {
return <EmptyTableState label="No referring domains match this filter." />;
}
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<ReferringDomainsTableHeader sort={sort} onSortChange={setSort} />
<tbody>
{sortedRows.map((row, index) => (
<tr key={`${row.domain ?? "domain"}-${index}`}>
<td className="font-medium break-all">{row.domain ?? "-"}</td>
<td>{formatNumber(row.backlinks)}</td>
<td>{formatNumber(row.referringPages)}</td>
<td>{formatNumber(row.rank)}</td>
<td>{formatDecimal(row.spamScore)}</td>
<td>{formatCompactDate(row.firstSeen)}</td>
<td>
<div className="text-sm">
<div>Broken links: {formatNumber(row.brokenBacklinks)}</div>
<div className="text-base-content/55">
Broken pages: {formatNumber(row.brokenPages)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function TopPagesTable({
rows,
}: {
rows: BacklinksOverviewData["topPages"];
}) {
const [sort, setSort] = useState(DEFAULT_TOP_PAGES_SORT);
const sortedRows = useMemo(() => sortTopPageRows(rows, sort), [rows, sort]);
if (rows.length === 0) {
return <EmptyTableState label="No top pages match this filter." />;
}
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<TopPagesTableHeader sort={sort} onSortChange={setSort} />
<tbody>
{sortedRows.map((row, index) => (
<tr key={`${row.page ?? "page"}-${index}`}>
<td className="min-w-80">
{row.page ? (
<BacklinksExternalLink
url={row.page}
label={row.page}
className="link link-hover break-all inline-flex items-center gap-1"
/>
) : (
"-"
)}
</td>
<td>{formatNumber(row.backlinks)}</td>
<td>{formatNumber(row.referringDomains)}</td>
<td>{formatNumber(row.rank)}</td>
<td>{formatNumber(row.brokenBacklinks)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function BacklinksTableRow({
row,
}: {
row: BacklinksOverviewData["backlinks"][number];
}) {
const hasNotableFlags =
row.isLost ||
row.isBroken ||
row.isDofollow === false ||
(row.linksCount != null && row.linksCount > 1);
return (
<tr>
<td className="min-w-52">
<div className="space-y-0.5">
<div className="font-medium">
{row.domainFrom?.replace(/^www\./, "") ?? "-"}
</div>
{row.urlFrom ? (
<BacklinksSourceLink url={row.urlFrom} maxLength={48} muted />
) : null}
</div>
</td>
<td className="min-w-44">
{row.urlTo ? (
<BacklinksSourceLink url={row.urlTo} maxLength={40} />
) : (
"-"
)}
</td>
<td className="min-w-36">
<div className="space-y-0.5">
<span className="text-sm">{row.anchor || "No anchor text"}</span>
{row.itemType ? (
<div className="text-xs text-base-content/55">{row.itemType}</div>
) : null}
</div>
</td>
<td>{hasNotableFlags ? <BacklinkFlags row={row} /> : null}</td>
<td className="text-right tabular-nums text-sm">
<span
title={
row.spamScore != null
? `Spam score: ${formatDecimal(row.spamScore)}`
: undefined
}
>
{formatNumber(row.rank)}
</span>
</td>
<td className="text-right tabular-nums text-sm">
{formatNumber(row.domainFromRank)}
</td>
<td className="whitespace-nowrap text-sm">
<div>{formatCompactDate(row.firstSeen)}</div>
{row.lastSeen ? (
<div className="text-xs text-base-content/55">
Last {formatCompactDate(row.lastSeen)}
</div>
) : null}
</td>
</tr>
);
}
function BacklinkFlags({
row,
}: {
row: BacklinksOverviewData["backlinks"][number];
}) {
return (
<div className="flex flex-wrap gap-1">
{row.isLost ? (
<span className="badge badge-sm badge-error badge-outline">Lost</span>
) : null}
{row.isBroken ? (
<span className="badge badge-sm badge-warning badge-outline">
Broken
</span>
) : null}
{row.isDofollow === false ? (
<span className="badge badge-sm badge-outline">Nofollow</span>
) : null}
{row.linksCount != null && row.linksCount > 1 ? (
<span className="badge badge-sm badge-outline inline-flex min-w-fit items-center whitespace-nowrap">
{row.linksCount} links
</span>
) : null}
</div>
);
}

View File

@ -0,0 +1,288 @@
import { Search } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import type { BacklinksSearchState } from "./backlinksPageTypes";
import { resolveBacklinksSearchScope } from "./backlinksSearchScope";
type SearchDraft = Pick<
BacklinksSearchState,
"target" | "scope" | "subdomains" | "indirect" | "excludeInternal" | "status"
>;
function toBacklinksStatus(value: string): SearchDraft["status"] {
if (value === "live" || value === "lost" || value === "all") {
return value;
}
return "live";
}
export function BacklinksSearchCard({
errorMessage,
initialValues,
isFetching,
onSubmit,
}: {
errorMessage: string | null;
initialValues: SearchDraft;
isFetching: boolean;
onSubmit: (values: SearchDraft) => void;
}) {
const [targetInput, setTargetInput] = useState(initialValues.target);
const [scope, setScope] = useState(initialValues.scope);
const [includeSubdomains, setIncludeSubdomains] = useState(
initialValues.subdomains,
);
const [includeIndirectLinks, setIncludeIndirectLinks] = useState(
initialValues.indirect,
);
const [excludeInternalBacklinks, setExcludeInternalBacklinks] = useState(
initialValues.excludeInternal,
);
const [status, setStatus] = useState(initialValues.status);
const [showAdvanced, setShowAdvanced] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [userSelectedScope, setUserSelectedScope] = useState(false);
useEffect(() => {
setTargetInput(initialValues.target);
setScope(initialValues.scope);
setIncludeSubdomains(initialValues.subdomains);
setIncludeIndirectLinks(initialValues.indirect);
setExcludeInternalBacklinks(initialValues.excludeInternal);
setStatus(initialValues.status);
setFormError(null);
setUserSelectedScope(false);
}, [initialValues]);
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
const target = targetInput.trim();
if (!target) {
setFormError("Enter a domain or URL to analyze.");
return;
}
setFormError(null);
onSubmit({
target,
scope: resolveBacklinksSearchScope({
target,
selectedScope: scope,
userSelectedScope,
}),
subdomains: includeSubdomains,
indirect: includeIndirectLinks,
excludeInternal: excludeInternalBacklinks,
status,
});
};
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<form className="space-y-3" onSubmit={handleSubmit}>
<SearchControls
formError={formError}
isFetching={isFetching}
onScopeChange={setScope}
onStatusChange={(value) => setStatus(toBacklinksStatus(value))}
onTargetInputChange={setTargetInput}
setFormError={setFormError}
scope={scope}
status={status}
targetInput={targetInput}
userSelectedScope={userSelectedScope}
onUserSelectedScopeChange={setUserSelectedScope}
/>
<SearchToggles
includeSubdomains={includeSubdomains}
onIncludeSubdomainsChange={setIncludeSubdomains}
showAdvanced={showAdvanced}
toggleAdvanced={() => setShowAdvanced((current) => !current)}
/>
{showAdvanced ? (
<AdvancedSearchOptions
excludeInternalBacklinks={excludeInternalBacklinks}
includeIndirectLinks={includeIndirectLinks}
onExcludeInternalChange={setExcludeInternalBacklinks}
onIncludeIndirectChange={setIncludeIndirectLinks}
/>
) : null}
</form>
{formError ? <p className="text-sm text-error">{formError}</p> : null}
{errorMessage ? (
<div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error">
{errorMessage}
</div>
) : null}
</div>
</div>
);
}
function SearchControls({
formError,
isFetching,
onScopeChange,
onStatusChange,
onTargetInputChange,
onUserSelectedScopeChange,
setFormError,
scope,
status,
targetInput,
userSelectedScope,
}: {
formError: string | null;
isFetching: boolean;
onScopeChange: (value: BacklinksSearchState["scope"]) => void;
onStatusChange: (value: string) => void;
onTargetInputChange: (value: string) => void;
onUserSelectedScopeChange: (value: boolean) => void;
setFormError: (value: string | null) => void;
scope: BacklinksSearchState["scope"];
status: BacklinksSearchState["status"];
targetInput: string;
userSelectedScope: boolean;
}) {
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
<label
className={`input input-bordered lg:col-span-8 flex items-center gap-2 ${formError ? "input-error" : ""}`}
>
<Search className="size-4 text-base-content/60" />
<input
placeholder={
scope === "page"
? "Enter a page URL or domain"
: "Enter a domain or URL"
}
value={targetInput}
onChange={(event) => {
const nextTarget = event.target.value;
onTargetInputChange(nextTarget);
if (!userSelectedScope) {
onScopeChange(
resolveBacklinksSearchScope({
target: nextTarget,
selectedScope: scope,
userSelectedScope: false,
}),
);
}
if (formError) setFormError(null);
}}
/>
</label>
<select
className="select select-bordered lg:col-span-2"
value={status}
onChange={(event) => onStatusChange(event.target.value)}
>
<option value="live">Live links</option>
<option value="lost">Lost links</option>
<option value="all">All links</option>
</select>
<button
type="submit"
className="btn btn-primary lg:col-span-2"
disabled={isFetching}
>
{isFetching ? "Loading..." : "Search"}
</button>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className={`btn btn-xs ${scope === "domain" ? "btn-soft" : "btn-ghost"}`}
onClick={() => {
onUserSelectedScopeChange(true);
onScopeChange("domain");
}}
>
Site-wide
</button>
<button
type="button"
className={`btn btn-xs ${scope === "page" ? "btn-soft" : "btn-ghost"}`}
onClick={() => {
onUserSelectedScopeChange(true);
onScopeChange("page");
}}
>
Exact page
</button>
</div>
</div>
);
}
function SearchToggles({
includeSubdomains,
onIncludeSubdomainsChange,
showAdvanced,
toggleAdvanced,
}: {
includeSubdomains: boolean;
onIncludeSubdomainsChange: (checked: boolean) => void;
showAdvanced: boolean;
toggleAdvanced: () => void;
}) {
return (
<div className="flex flex-wrap items-center gap-3">
<label className="label cursor-pointer gap-2 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={includeSubdomains}
onChange={(event) => onIncludeSubdomainsChange(event.target.checked)}
/>
<span className="label-text">Include subdomains</span>
</label>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={toggleAdvanced}
>
{showAdvanced ? "Hide advanced" : "Show advanced"}
</button>
</div>
);
}
function AdvancedSearchOptions({
excludeInternalBacklinks,
includeIndirectLinks,
onExcludeInternalChange,
onIncludeIndirectChange,
}: {
excludeInternalBacklinks: boolean;
includeIndirectLinks: boolean;
onExcludeInternalChange: (checked: boolean) => void;
onIncludeIndirectChange: (checked: boolean) => void;
}) {
return (
<div className="grid grid-cols-1 gap-3 rounded-xl border border-base-300 bg-base-200/40 p-4 text-sm md:grid-cols-2">
<label className="label cursor-pointer justify-start gap-3 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={includeIndirectLinks}
onChange={(event) => onIncludeIndirectChange(event.target.checked)}
/>
<span className="label-text">Include indirect links</span>
</label>
<label className="label cursor-pointer justify-start gap-3 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={excludeInternalBacklinks}
onChange={(event) => onExcludeInternalChange(event.target.checked)}
/>
<span className="label-text">Exclude internal backlinks</span>
</label>
</div>
);
}

View File

@ -0,0 +1,254 @@
import { ArrowDown, ArrowUp } from "lucide-react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import {
getNextSort,
type BacklinksTableSort,
type ReferringDomainsTableSort,
type SortDirection,
type TopPagesTableSort,
} from "./backlinksTableSorting";
export function BacklinksTableHeader({
sort,
onSortChange,
}: {
sort: BacklinksTableSort;
onSortChange: (sort: BacklinksTableSort) => void;
}) {
return (
<thead>
<tr>
<SortableHeaderCell
label="Source"
helpText="Page or domain linking to you"
field="source"
defaultDirection="asc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Target"
helpText="Destination on your site"
field="target"
defaultDirection="asc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Anchor"
helpText="Text or format of the link"
field="anchor"
defaultDirection="asc"
sort={sort}
onSortChange={onSortChange}
/>
<th>
<HeaderHelpLabel
label="Flags"
helpText="Special backlink attributes, such as lost, broken, nofollow, or multiple links from the same source."
/>
</th>
<SortableHeaderCell
align="right"
label="Link"
helpText="Authority of the linking page"
field="linkAuthority"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
align="right"
label="DA"
helpText="Authority of the linking domain"
field="domainAuthority"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="First Seen"
helpText="When this link was first discovered by the crawler"
field="firstSeen"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
</tr>
</thead>
);
}
export function ReferringDomainsTableHeader({
sort,
onSortChange,
}: {
sort: ReferringDomainsTableSort;
onSortChange: (sort: ReferringDomainsTableSort) => void;
}) {
return (
<thead>
<tr>
<SortableHeaderCell
label="Domain"
helpText="The referring site linking to your target."
field="domain"
defaultDirection="asc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Backlinks"
helpText="Total backlinks found from this domain."
field="backlinks"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Referring Pages"
helpText="Unique pages on this domain that link to your target."
field="referringPages"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Rank"
helpText="Authority score for the referring domain."
field="rank"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Spam"
helpText="Spam risk score for this referring domain."
field="spamScore"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="First Seen"
helpText="When this domain was first discovered linking to your target."
field="firstSeen"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Issues"
helpText="Broken link and broken page counts tied to this domain."
field="issues"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
</tr>
</thead>
);
}
export function TopPagesTableHeader({
sort,
onSortChange,
}: {
sort: TopPagesTableSort;
onSortChange: (sort: TopPagesTableSort) => void;
}) {
return (
<thead>
<tr>
<SortableHeaderCell
label="Page"
helpText="Page on the target site receiving backlinks."
field="page"
defaultDirection="asc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Backlinks"
helpText="Total backlinks pointing to this page."
field="backlinks"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Referring Domains"
helpText="Unique domains linking to this page."
field="referringDomains"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Rank"
helpText="Authority score for this target page."
field="rank"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Broken Backlinks"
helpText="Backlinks pointing here that are currently broken."
field="brokenBacklinks"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
</tr>
</thead>
);
}
function SortableHeaderCell<TField extends string>({
align,
label,
helpText,
field,
defaultDirection,
sort,
onSortChange,
}: {
align?: "left" | "right";
label: string;
helpText: string;
field: TField;
defaultDirection: SortDirection;
sort: { field: TField; direction: SortDirection };
onSortChange: (sort: { field: TField; direction: SortDirection }) => void;
}) {
const isActive = sort.field === field;
const content = (
<button
type="button"
className="inline-flex items-center gap-1 font-medium transition-colors hover:text-base-content"
onClick={() => onSortChange(getNextSort(sort, field, defaultDirection))}
aria-label={`Sort by ${label}`}
aria-pressed={isActive}
>
<HeaderHelpLabel label={label} helpText={helpText} />
{isActive ? (
sort.direction === "asc" ? (
<ArrowUp className="size-3 shrink-0" />
) : (
<ArrowDown className="size-3 shrink-0" />
)
) : null}
</button>
);
return (
<th className={align === "right" ? "text-right" : undefined}>
{align === "right" ? (
<span className="inline-flex justify-end">{content}</span>
) : (
content
)}
</th>
);
}

View File

@ -0,0 +1,45 @@
import type {
BacklinksStatus,
BacklinksTab,
BacklinksTargetScope,
} from "@/types/schemas/backlinks";
import type {
getBacklinksOverview,
getBacklinksReferringDomains,
getBacklinksTopPages,
} from "@/serverFunctions/backlinks";
import type { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
export type BacklinksOverviewData = Awaited<
ReturnType<typeof getBacklinksOverview>
>;
export type BacklinksAccessStatusData = Awaited<
ReturnType<typeof getBacklinksAccessSetupStatus>
>;
export type BacklinksReferringDomainsData = Awaited<
ReturnType<typeof getBacklinksReferringDomains>
>;
export type BacklinksTopPagesData = Awaited<
ReturnType<typeof getBacklinksTopPages>
>;
export type BacklinksSearchState = {
target: string;
scope: BacklinksTargetScope;
subdomains: boolean;
indirect: boolean;
excludeInternal: boolean;
status: BacklinksStatus;
tab: BacklinksTab;
};
export type BacklinksNavigate = (args: {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace: boolean;
}) => void;
export type BacklinksPageProps = {
projectId: string;
searchState: BacklinksSearchState;
navigate: BacklinksNavigate;
};

View File

@ -0,0 +1,131 @@
import type { BacklinksTab } from "@/types/schemas/backlinks";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
export const TAB_DESCRIPTIONS: Record<BacklinksTab, string> = {
backlinks:
"See the individual links pointing to your target, including source page, anchor text, and link quality signals.",
domains:
"View the unique domains linking to your target, grouped at the site level instead of by individual link.",
pages:
"See which pages on the target site attract the most backlinks and referring domains.",
};
export function buildSummaryStats(data: BacklinksOverviewData | undefined) {
if (!data) return [];
return [
{
label: "Backlinks",
value: formatNumber(data.summary.backlinks),
description: "Total links pointing to this site or page.",
},
{
label: "Referring Domains",
value: formatNumber(data.summary.referringDomains),
description: "Unique domains linking to this site or page.",
},
{
label: "Referring Pages",
value: formatNumber(data.summary.referringPages),
description: "Unique pages linking to this site or page.",
},
{
label: "Rank",
value: formatNumber(data.summary.rank),
description: "DataForSEO's 0-100 authority score.",
},
{
label: "Backlink Spam Score",
value: formatDecimal(data.summary.backlinksSpamScore),
description: "Estimated spam risk of links pointing here.",
},
{
label: "Broken Backlinks",
value: formatNumber(data.summary.brokenBacklinks),
description: "Links pointing to broken pages here.",
},
{
label: "Broken Pages",
value: formatNumber(data.summary.brokenPages),
description: "Broken pages here that still have backlinks.",
},
{
label: "Target Spam Score",
value: formatDecimal(data.summary.targetSpamScore),
description: "Estimated spam risk of this site or page.",
},
];
}
export function formatNumber(value: number | null | undefined) {
if (value == null) return "-";
return new Intl.NumberFormat().format(Math.round(value));
}
export function formatDecimal(value: number | null | undefined) {
if (value == null) return "-";
return value.toFixed(value >= 100 ? 0 : 1);
}
export function formatTooltipValue(value: unknown) {
if (Array.isArray(value)) return value.join(", ");
if (typeof value === "number") return formatNumber(value);
if (typeof value === "string") return value;
return "-";
}
export function formatCompactDate(value: string | null | undefined) {
if (!value) return "-";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function formatFullDate(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function formatMonthLabel(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleDateString(undefined, {
month: "short",
year: "2-digit",
});
}
export function formatRelativeTimestamp(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "recently";
return parsed.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function extractUrlPath(url: string) {
try {
const parsed = new URL(url);
return parsed.pathname + parsed.search + parsed.hash;
} catch {
return url;
}
}
export function truncateMiddle(value: string, maxLength: number) {
if (value.length <= maxLength) return value;
const sideLength = Math.floor((maxLength - 1) / 2);
return `${value.slice(0, sideLength)}...${value.slice(-sideLength)}`;
}

View File

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
getPersistedBacklinksSearchScope,
inferBacklinksSearchScopeFromTarget,
resolveBacklinksSearchScope,
} from "./backlinksSearchScope";
describe("inferBacklinksSearchScopeFromTarget", () => {
it("treats bare hostnames as domain lookups", () => {
expect(inferBacklinksSearchScopeFromTarget("example.com")).toBe("domain");
});
it("treats path-based targets without a protocol as page lookups", () => {
expect(inferBacklinksSearchScopeFromTarget("example.com/pricing")).toBe(
"page",
);
});
it("treats explicit urls as page lookups", () => {
expect(inferBacklinksSearchScopeFromTarget("https://example.com/")).toBe(
"page",
);
});
it("uses inferred scope until the user overrides it", () => {
expect(
resolveBacklinksSearchScope({
target: "example.com/pricing",
selectedScope: "domain",
userSelectedScope: false,
}),
).toBe("page");
});
it("preserves a manual scope override", () => {
expect(
resolveBacklinksSearchScope({
target: "https://example.com/pricing?utm_source=newsletter",
selectedScope: "domain",
userSelectedScope: true,
}),
).toBe("domain");
});
it("omits persisted scope when it matches the inferred target scope", () => {
expect(
getPersistedBacklinksSearchScope("example.com/pricing", "page"),
).toBe(undefined);
});
it("persists explicit scope overrides", () => {
expect(
getPersistedBacklinksSearchScope(
"https://example.com/pricing?utm_source=newsletter",
"domain",
),
).toBe("domain");
});
});

View File

@ -0,0 +1,46 @@
import type { BacklinksTargetScope } from "@/types/schemas/backlinks";
export function inferBacklinksSearchScopeFromTarget(
target: string,
): BacklinksTargetScope {
const trimmed = target.trim();
if (!trimmed) {
return "domain";
}
const hasExplicitProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed);
try {
const parsed = new URL(
hasExplicitProtocol ? trimmed : `https://${trimmed}`,
);
return hasExplicitProtocol || parsed.pathname !== "/" ? "page" : "domain";
} catch {
return "domain";
}
}
export function resolveBacklinksSearchScope({
target,
selectedScope,
userSelectedScope,
}: {
target: string;
selectedScope: BacklinksTargetScope;
userSelectedScope: boolean;
}): BacklinksTargetScope {
if (userSelectedScope) {
return selectedScope;
}
return inferBacklinksSearchScopeFromTarget(target);
}
export function getPersistedBacklinksSearchScope(
target: string,
scope: BacklinksTargetScope,
): BacklinksTargetScope | undefined {
return scope === inferBacklinksSearchScopeFromTarget(target)
? undefined
: scope;
}

View File

@ -0,0 +1,217 @@
import type { BacklinksOverviewData } from "./backlinksPageTypes";
export type SortDirection = "asc" | "desc";
export type BacklinksTableSortField =
| "source"
| "target"
| "anchor"
| "linkAuthority"
| "domainAuthority"
| "firstSeen";
export type ReferringDomainsTableSortField =
| "domain"
| "backlinks"
| "referringPages"
| "rank"
| "spamScore"
| "firstSeen"
| "issues";
export type TopPagesTableSortField =
| "page"
| "backlinks"
| "referringDomains"
| "rank"
| "brokenBacklinks";
export type TableSort<TField extends string> = {
field: TField;
direction: SortDirection;
};
export type BacklinksTableSort = TableSort<BacklinksTableSortField>;
export type ReferringDomainsTableSort =
TableSort<ReferringDomainsTableSortField>;
export type TopPagesTableSort = TableSort<TopPagesTableSortField>;
export const DEFAULT_BACKLINKS_SORT: BacklinksTableSort = {
field: "firstSeen",
direction: "desc",
};
export const DEFAULT_REFERRING_DOMAINS_SORT: ReferringDomainsTableSort = {
field: "backlinks",
direction: "desc",
};
export const DEFAULT_TOP_PAGES_SORT: TopPagesTableSort = {
field: "backlinks",
direction: "desc",
};
export function getNextSort<TField extends string>(
current: TableSort<TField>,
field: TField,
defaultDirection: SortDirection,
): TableSort<TField> {
if (current.field !== field) {
return { field, direction: defaultDirection };
}
return {
field,
direction: current.direction === "asc" ? "desc" : "asc",
};
}
export function sortBacklinkRows(
rows: BacklinksOverviewData["backlinks"],
sort: BacklinksTableSort,
) {
return rows.toSorted((left, right) => {
switch (sort.field) {
case "source":
return compareStrings(
left.domainFrom?.replace(/^www\./, "") ?? left.urlFrom,
right.domainFrom?.replace(/^www\./, "") ?? right.urlFrom,
sort.direction,
);
case "target":
return compareStrings(left.urlTo, right.urlTo, sort.direction);
case "anchor":
return compareStrings(
left.anchor ?? left.itemType,
right.anchor ?? right.itemType,
sort.direction,
);
case "linkAuthority":
return compareNumbers(left.rank, right.rank, sort.direction);
case "domainAuthority":
return compareNumbers(
left.domainFromRank,
right.domainFromRank,
sort.direction,
);
case "firstSeen":
return compareDates(left.firstSeen, right.firstSeen, sort.direction);
default:
return 0;
}
});
}
export function sortReferringDomainRows(
rows: BacklinksOverviewData["referringDomains"],
sort: ReferringDomainsTableSort,
) {
return rows.toSorted((left, right) => {
switch (sort.field) {
case "domain":
return compareStrings(left.domain, right.domain, sort.direction);
case "backlinks":
return compareNumbers(left.backlinks, right.backlinks, sort.direction);
case "referringPages":
return compareNumbers(
left.referringPages,
right.referringPages,
sort.direction,
);
case "rank":
return compareNumbers(left.rank, right.rank, sort.direction);
case "spamScore":
return compareNumbers(left.spamScore, right.spamScore, sort.direction);
case "firstSeen":
return compareDates(left.firstSeen, right.firstSeen, sort.direction);
case "issues":
return compareIssues(left, right, sort.direction);
default:
return 0;
}
});
}
export function sortTopPageRows(
rows: BacklinksOverviewData["topPages"],
sort: TopPagesTableSort,
) {
return rows.toSorted((left, right) => {
switch (sort.field) {
case "page":
return compareStrings(left.page, right.page, sort.direction);
case "backlinks":
return compareNumbers(left.backlinks, right.backlinks, sort.direction);
case "referringDomains":
return compareNumbers(
left.referringDomains,
right.referringDomains,
sort.direction,
);
case "rank":
return compareNumbers(left.rank, right.rank, sort.direction);
case "brokenBacklinks":
return compareNumbers(
left.brokenBacklinks,
right.brokenBacklinks,
sort.direction,
);
default:
return 0;
}
});
}
function compareNumbers(
left: number | null | undefined,
right: number | null | undefined,
direction: SortDirection,
) {
if (left == null && right == null) return 0;
if (left == null) return 1;
if (right == null) return -1;
return direction === "asc" ? left - right : right - left;
}
function compareStrings(
left: string | null | undefined,
right: string | null | undefined,
direction: SortDirection,
) {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
const result = left.toLowerCase().localeCompare(right.toLowerCase());
return direction === "asc" ? result : -result;
}
function compareDates(
left: string | null | undefined,
right: string | null | undefined,
direction: SortDirection,
) {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
const leftValue = Date.parse(left);
const rightValue = Date.parse(right);
return direction === "asc" ? leftValue - rightValue : rightValue - leftValue;
}
function compareIssues(
left: BacklinksOverviewData["referringDomains"][number],
right: BacklinksOverviewData["referringDomains"][number],
direction: SortDirection,
) {
const backlinkComparison = compareNumbers(
left.brokenBacklinks,
right.brokenBacklinks,
direction,
);
if (backlinkComparison !== 0) {
return backlinkComparison;
}
return compareNumbers(left.brokenPages, right.brokenPages, direction);
}

View File

@ -0,0 +1,236 @@
import { useEffect, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import type {
BacklinksPageProps,
BacklinksSearchState,
} from "./backlinksPageTypes";
import {
getErrorCode,
getStandardErrorMessage,
} from "@/client/lib/error-messages";
import {
getBacklinksOverview,
getBacklinksReferringDomains,
getBacklinksTopPages,
} from "@/serverFunctions/backlinks";
import {
getBacklinksAccessSetupStatus,
testBacklinksAccess,
} from "@/serverFunctions/backlinksAccess";
import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope";
type UseBacklinksPageDataArgs = {
projectId: string;
searchState: BacklinksSearchState;
};
function getBacklinksErrorMessage(
error: unknown,
fallback: string,
): string | null {
if (!error) return null;
if (getErrorCode(error) === "VALIDATION_ERROR") {
return "Enter a valid domain or page URL.";
}
return getStandardErrorMessage(error, fallback);
}
export function useBacklinksPageData({
projectId,
searchState,
}: UseBacklinksPageDataArgs) {
const accessStatusQuery = useQuery({
queryKey: ["backlinksAccessStatus", projectId],
queryFn: () => getBacklinksAccessSetupStatus({ data: { projectId } }),
});
const accessStatus = accessStatusQuery.data;
const accessStatusErrorMessage = accessStatusQuery.error
? getStandardErrorMessage(
accessStatusQuery.error,
"Could not load Backlinks setup status.",
)
: null;
const backlinksEnabled = accessStatus?.enabled ?? false;
const requestInput = useMemo(
() => buildBacklinksRequestInput(projectId, searchState),
[projectId, searchState],
);
const searchCardInitialValues = useMemo(
() => ({
target: searchState.target,
scope: searchState.scope,
subdomains: searchState.subdomains,
indirect: searchState.indirect,
excludeInternal: searchState.excludeInternal,
status: searchState.status,
}),
[
searchState.excludeInternal,
searchState.indirect,
searchState.scope,
searchState.status,
searchState.subdomains,
searchState.target,
],
);
const testAccessMutation = useMutation({
mutationFn: () => testBacklinksAccess({ data: { projectId } }),
onSuccess: async () => {
await accessStatusQuery.refetch();
},
});
const queryKeyParts = [
projectId,
searchState.scope,
searchState.target,
searchState.subdomains,
searchState.indirect,
searchState.excludeInternal,
searchState.status,
] as const;
const overviewQuery = useQuery({
queryKey: ["backlinksOverview", ...queryKeyParts],
enabled: backlinksEnabled && Boolean(searchState.target),
queryFn: () => getBacklinksOverview({ data: requestInput }),
});
const referringDomainsQuery = useQuery({
queryKey: ["backlinksReferringDomains", ...queryKeyParts],
enabled:
backlinksEnabled &&
Boolean(searchState.target) &&
searchState.tab === "domains",
queryFn: () => getBacklinksReferringDomains({ data: requestInput }),
});
const topPagesQuery = useQuery({
queryKey: ["backlinksTopPages", ...queryKeyParts],
enabled:
backlinksEnabled &&
Boolean(searchState.target) &&
searchState.tab === "pages",
queryFn: () => getBacklinksTopPages({ data: requestInput }),
});
const overviewErrorMessage = getBacklinksErrorMessage(
overviewQuery.error,
"Could not load backlinks data.",
);
const backlinksDisabledByError =
getErrorCode(overviewQuery.error) === "BACKLINKS_NOT_ENABLED";
const activeTabError = getActiveTabError(
searchState,
referringDomainsQuery.error,
topPagesQuery.error,
);
const activeTabErrorMessage = getBacklinksErrorMessage(
activeTabError,
"Could not load this tab.",
);
const backlinksDisabledByTabError =
getErrorCode(activeTabError) === "BACKLINKS_NOT_ENABLED";
useEffect(() => {
if (
(backlinksDisabledByError || backlinksDisabledByTabError) &&
accessStatus?.enabled
) {
void accessStatusQuery.refetch();
}
}, [
accessStatus?.enabled,
accessStatusQuery,
backlinksDisabledByError,
backlinksDisabledByTabError,
]);
return {
accessStatus,
accessStatusErrorMessage,
accessStatusQuery,
activeTabErrorMessage,
backlinksDisabledByError,
backlinksEnabled,
overviewErrorMessage,
overviewQuery,
referringDomainsQuery,
searchCardInitialValues,
testAccessMutation,
topPagesQuery,
};
}
export function navigateToBacklinksSearch(
navigate: BacklinksPageProps["navigate"],
values: Pick<
BacklinksSearchState,
| "target"
| "scope"
| "subdomains"
| "indirect"
| "excludeInternal"
| "status"
>,
) {
navigate({
search: (prev) => ({
...prev,
target: values.target,
scope: getPersistedBacklinksSearchScope(values.target, values.scope),
subdomains: values.subdomains ? undefined : false,
indirect: values.indirect ? undefined : false,
excludeInternal: values.excludeInternal ? undefined : false,
status: values.status === "live" ? undefined : values.status,
tab: undefined,
}),
replace: true,
});
}
export function navigateToBacklinksTab(
navigate: BacklinksPageProps["navigate"],
tab: BacklinksSearchState["tab"],
) {
navigate({
search: (prev) => ({
...prev,
tab: tab === "backlinks" ? undefined : tab,
}),
replace: true,
});
}
function buildBacklinksRequestInput(
projectId: string,
searchState: BacklinksSearchState,
) {
return {
projectId,
target: searchState.target,
scope: searchState.scope,
includeSubdomains: searchState.subdomains,
includeIndirectLinks: searchState.indirect,
excludeInternalBacklinks: searchState.excludeInternal,
status: searchState.status,
};
}
function getActiveTabError(
searchState: BacklinksSearchState,
referringDomainsError: unknown,
topPagesError: unknown,
) {
if (searchState.tab === "domains") {
return referringDomainsError;
}
if (searchState.tab === "pages") {
return topPagesError;
}
return null;
}

View File

@ -10,6 +10,10 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
"You've reached audit capacity for your account. Delete old audits from your projects to start a new one.",
VALIDATION_ERROR: "Please check your input and try again.",
CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.",
BACKLINKS_NOT_ENABLED:
"Backlinks is not enabled for the connected DataForSEO account yet.",
BACKLINKS_BILLING_ISSUE:
"The connected DataForSEO account has a billing or balance issue.",
RATE_LIMITED: "Too many requests. Please wait and try again.",
CONFLICT: "This request conflicts with existing data.",
INTERNAL_ERROR:

View File

@ -1,4 +1,11 @@
import { Bookmark, Bot, ClipboardCheck, Globe, Search } from "lucide-react";
import {
Bookmark,
Bot,
ClipboardCheck,
Globe,
Link2,
Search,
} from "lucide-react";
export const projectNavItems = [
{
@ -19,6 +26,12 @@ export const projectNavItems = [
icon: Globe,
matchSegment: "/domain",
},
{
to: "/p/$projectId/backlinks" as const,
label: "Backlinks",
icon: Link2,
matchSegment: "/backlinks",
},
{
to: "/p/$projectId/audit" as const,
label: "Site Audit",

View File

@ -16,6 +16,7 @@ import { Route as PProjectIdIndexRouteImport } from './routes/p/$projectId/index
import { Route as PProjectIdSavedRouteImport } from './routes/p/$projectId/saved'
import { Route as PProjectIdKeywordsRouteImport } from './routes/p/$projectId/keywords'
import { Route as PProjectIdDomainRouteImport } from './routes/p/$projectId/domain'
import { Route as PProjectIdBacklinksRouteImport } from './routes/p/$projectId/backlinks'
import { Route as PProjectIdAuditRouteImport } from './routes/p/$projectId/audit'
import { Route as PProjectIdAiRouteImport } from './routes/p/$projectId/ai'
import { Route as PProjectIdAuditIndexRouteImport } from './routes/p/$projectId/audit/index'
@ -57,6 +58,11 @@ const PProjectIdDomainRoute = PProjectIdDomainRouteImport.update({
path: '/domain',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const PProjectIdBacklinksRoute = PProjectIdBacklinksRouteImport.update({
id: '/backlinks',
path: '/backlinks',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const PProjectIdAuditRoute = PProjectIdAuditRouteImport.update({
id: '/audit',
path: '/audit',
@ -91,6 +97,7 @@ export interface FileRoutesByFullPath {
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
'/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
'/p/$projectId/domain': typeof PProjectIdDomainRoute
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
'/p/$projectId/saved': typeof PProjectIdSavedRoute
@ -103,6 +110,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
'/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
'/p/$projectId/domain': typeof PProjectIdDomainRoute
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
'/p/$projectId/saved': typeof PProjectIdSavedRoute
@ -118,6 +126,7 @@ export interface FileRoutesById {
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
'/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
'/p/$projectId/domain': typeof PProjectIdDomainRoute
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
'/p/$projectId/saved': typeof PProjectIdSavedRoute
@ -134,6 +143,7 @@ export interface FileRouteTypes {
| '/help/dataforseo-api-key'
| '/p/$projectId/ai'
| '/p/$projectId/audit'
| '/p/$projectId/backlinks'
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
| '/p/$projectId/saved'
@ -146,6 +156,7 @@ export interface FileRouteTypes {
| '/'
| '/help/dataforseo-api-key'
| '/p/$projectId/ai'
| '/p/$projectId/backlinks'
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
| '/p/$projectId/saved'
@ -160,6 +171,7 @@ export interface FileRouteTypes {
| '/help/dataforseo-api-key'
| '/p/$projectId/ai'
| '/p/$projectId/audit'
| '/p/$projectId/backlinks'
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
| '/p/$projectId/saved'
@ -226,6 +238,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PProjectIdDomainRouteImport
parentRoute: typeof PProjectIdRouteRoute
}
'/p/$projectId/backlinks': {
id: '/p/$projectId/backlinks'
path: '/backlinks'
fullPath: '/p/$projectId/backlinks'
preLoaderRoute: typeof PProjectIdBacklinksRouteImport
parentRoute: typeof PProjectIdRouteRoute
}
'/p/$projectId/audit': {
id: '/p/$projectId/audit'
path: '/audit'
@ -281,6 +300,7 @@ const PProjectIdAuditRouteWithChildren = PProjectIdAuditRoute._addFileChildren(
interface PProjectIdRouteRouteChildren {
PProjectIdAiRoute: typeof PProjectIdAiRoute
PProjectIdAuditRoute: typeof PProjectIdAuditRouteWithChildren
PProjectIdBacklinksRoute: typeof PProjectIdBacklinksRoute
PProjectIdDomainRoute: typeof PProjectIdDomainRoute
PProjectIdKeywordsRoute: typeof PProjectIdKeywordsRoute
PProjectIdSavedRoute: typeof PProjectIdSavedRoute
@ -291,6 +311,7 @@ interface PProjectIdRouteRouteChildren {
const PProjectIdRouteRouteChildren: PProjectIdRouteRouteChildren = {
PProjectIdAiRoute: PProjectIdAiRoute,
PProjectIdAuditRoute: PProjectIdAuditRouteWithChildren,
PProjectIdBacklinksRoute: PProjectIdBacklinksRoute,
PProjectIdDomainRoute: PProjectIdDomainRoute,
PProjectIdKeywordsRoute: PProjectIdKeywordsRoute,
PProjectIdSavedRoute: PProjectIdSavedRoute,

View File

@ -0,0 +1,40 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { BacklinksPage } from "@/client/features/backlinks/BacklinksPage";
import { inferBacklinksSearchScopeFromTarget } from "@/client/features/backlinks/backlinksSearchScope";
import { backlinksSearchSchema } from "@/types/schemas/backlinks";
export const Route = createFileRoute("/p/$projectId/backlinks")({
validateSearch: backlinksSearchSchema,
component: BacklinksRoute,
});
function BacklinksRoute() {
const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath });
const {
target = "",
scope: rawScope,
subdomains = true,
indirect = true,
excludeInternal = true,
status = "live",
tab = "backlinks",
} = Route.useSearch();
const scope = rawScope ?? inferBacklinksSearchScopeFromTarget(target);
return (
<BacklinksPage
projectId={projectId}
navigate={navigate}
searchState={{
target,
scope,
subdomains,
indirect,
excludeInternal,
status,
tab,
}}
/>
);
}

View File

@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildVerifiedBacklinksAccessStatus,
getBacklinksAccessStatus,
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
const { kvState } = vi.hoisted(() => ({
kvState: new Map<string, string>(),
}));
vi.mock("@/server/lib/runtime-env", () => ({
getWorkersBinding: vi.fn(async () => ({
get: vi.fn(async (key: string) => kvState.get(key) ?? null),
put: vi.fn(async (key: string, value: string) => {
kvState.set(key, value);
}),
})),
}));
describe("backlinksAccess", () => {
beforeEach(() => {
kvState.clear();
});
it("stores access status globally", async () => {
const checkedAt = "2026-03-14T00:00:00.000Z";
await setBacklinksAccessStatus(
buildVerifiedBacklinksAccessStatus(checkedAt),
);
await expect(getBacklinksAccessStatus()).resolves.toMatchObject({
enabled: true,
verifiedAt: checkedAt,
});
});
});

View File

@ -0,0 +1,107 @@
import { z } from "zod";
import { getWorkersBinding } from "@/server/lib/runtime-env";
const BACKLINKS_ACCESS_STATUS_KEY = "settings:backlinks-access:v2:global";
const backlinksAccessStatusSchema = z.object({
enabled: z.boolean(),
verifiedAt: z.string().nullable(),
lastCheckedAt: z.string().nullable(),
lastErrorCode: z.string().nullable(),
lastErrorMessage: z.string().nullable(),
});
type BacklinksAccessStatus = z.infer<typeof backlinksAccessStatusSchema>;
const BACKLINKS_NOT_ENABLED_MESSAGE =
"Backlinks access check failed - it's still not enabled for your DataForSEO account. Enable it in DataForSEO, then try again.";
export async function getBacklinksAccessStatus(): Promise<BacklinksAccessStatus> {
const kv = await getKvNamespace();
const raw = await kv.get(BACKLINKS_ACCESS_STATUS_KEY, "text");
if (!raw) {
return getDefaultBacklinksAccessStatus();
}
const json = parseJsonUnknown(raw);
if (json === null) {
return getDefaultBacklinksAccessStatus();
}
const parsed = backlinksAccessStatusSchema.safeParse(json);
if (!parsed.success) {
return getDefaultBacklinksAccessStatus();
}
return parsed.data;
}
export async function setBacklinksAccessStatus(
status: BacklinksAccessStatus,
): Promise<void> {
const kv = await getKvNamespace();
await kv.put(BACKLINKS_ACCESS_STATUS_KEY, JSON.stringify(status));
}
export function buildVerifiedBacklinksAccessStatus(
checkedAt: string,
): BacklinksAccessStatus {
return {
enabled: true,
verifiedAt: checkedAt,
lastCheckedAt: checkedAt,
lastErrorCode: null,
lastErrorMessage: null,
};
}
export function buildBacklinksDisabledAccessStatus(
checkedAt: string,
errorCode: string,
): BacklinksAccessStatus {
return {
enabled: false,
verifiedAt: null,
lastCheckedAt: checkedAt,
lastErrorCode: errorCode,
lastErrorMessage: BACKLINKS_NOT_ENABLED_MESSAGE,
};
}
function getDefaultBacklinksAccessStatus(): BacklinksAccessStatus {
return {
enabled: false,
verifiedAt: null,
lastCheckedAt: null,
lastErrorCode: null,
lastErrorMessage: null,
};
}
async function getKvNamespace(): Promise<KVNamespace> {
const binding = await getWorkersBinding("KV");
if (isKvNamespace(binding)) {
return binding;
}
throw new Error("KV binding is not configured correctly");
}
function isKvNamespace(value: unknown): value is KVNamespace {
return (
typeof value === "object" &&
value !== null &&
"get" in value &&
typeof value.get === "function" &&
"put" in value &&
typeof value.put === "function"
);
}
function parseJsonUnknown(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}

View File

@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AppError } from "@/server/lib/errors";
import { assertBacklinksProjectAccess } from "@/server/features/backlinks/backlinksProjectAccess";
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
vi.mock(
"@/server/features/keywords/repositories/KeywordResearchRepository",
() => ({
KeywordResearchRepository: {
getProject: vi.fn(),
},
}),
);
describe("assertBacklinksProjectAccess", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns the project when the user has access", async () => {
const project = {
id: "project-1",
userId: "user-1",
name: "Project 1",
domain: null,
pagespeedApiKey: null,
createdAt: "2026-03-14T00:00:00.000Z",
};
vi.mocked(KeywordResearchRepository.getProject).mockResolvedValue(project);
await expect(
assertBacklinksProjectAccess("user-1", "project-1"),
).resolves.toBe(project);
});
it("throws when the user does not have project access", async () => {
vi.mocked(KeywordResearchRepository.getProject).mockResolvedValue(
undefined,
);
await expect(
assertBacklinksProjectAccess("user-1", "project-1"),
).rejects.toMatchObject({
code: "NOT_FOUND",
} satisfies Partial<AppError>);
});
});

View File

@ -0,0 +1,14 @@
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import { AppError } from "@/server/lib/errors";
export async function assertBacklinksProjectAccess(
userId: string,
projectId: string,
) {
const project = await KeywordResearchRepository.getProject(projectId, userId);
if (!project) {
throw new AppError("NOT_FOUND");
}
return project;
}

View File

@ -0,0 +1,262 @@
import { beforeEach, expect, it, vi } from "vitest";
vi.mock("@/server/lib/dataforseoBacklinks", () => ({
normalizeBacklinksTarget: vi.fn(),
fetchBacklinksSummaryRaw: vi.fn(),
fetchBacklinksRowsRaw: vi.fn(),
fetchReferringDomainsRaw: vi.fn(),
fetchDomainPagesSummaryRaw: vi.fn(),
fetchTimeseriesSummaryRaw: vi.fn(),
fetchNewLostTimeseriesRaw: vi.fn(),
}));
import { createBacklinksService } from "./BacklinksService";
import {
fetchBacklinksRowsRaw,
fetchBacklinksSummaryRaw,
fetchDomainPagesSummaryRaw,
fetchNewLostTimeseriesRaw,
fetchReferringDomainsRaw,
fetchTimeseriesSummaryRaw,
normalizeBacklinksTarget,
} from "@/server/lib/dataforseoBacklinks";
const cache = new Map<string, string>();
const service = createBacklinksService({
async get(key) {
const raw = cache.get(key);
return raw ? parseCachedValue(raw) : null;
},
async set(key, data) {
cache.set(key, JSON.stringify(data));
},
});
beforeEach(() => {
cache.clear();
vi.clearAllMocks();
});
it("profiles only the initial overview calls and reuses cache on repeat", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
vi.mocked(fetchBacklinksSummaryRaw).mockResolvedValue({
data: {
rank: 42,
backlinks: 1200,
referring_pages: 900,
referring_domains: 320,
broken_backlinks: 12,
broken_pages: 3,
backlinks_spam_score: 5,
info: { target_spam_score: 4 },
new_backlinks: 25,
lost_backlinks: 10,
new_referring_domains: 8,
lost_referring_domains: 2,
},
billing: createBilling("/v3/backlinks/summary/live", 0.02003, 1),
});
vi.mocked(fetchBacklinksRowsRaw).mockResolvedValue({
data: [
{
domain_from: "source.example",
url_from: "https://source.example/post",
url_to: "https://example.com/",
anchor: "Example",
item_type: "content",
dofollow: true,
rank: 77,
domain_from_rank: 65,
page_from_rank: 54,
backlink_spam_score: 3,
first_seen: "2026-01-01",
last_visited: "2026-03-01",
lost_date: null,
is_lost: false,
is_broken: false,
links_count: 1,
rel_attributes: ["noopener"],
},
],
billing: createBilling("/v3/backlinks/backlinks/live", 0.023, 1),
});
vi.mocked(fetchTimeseriesSummaryRaw).mockResolvedValue({
data: [
{
date: "2026-02-01",
backlinks: 1100,
referring_domains: 300,
rank: 40,
},
],
billing: createBilling("/v3/backlinks/timeseries_summary/live", 0.02039, 1),
});
vi.mocked(fetchNewLostTimeseriesRaw).mockResolvedValue({
data: [
{
date: "2026-02-01",
new_backlinks: 20,
lost_backlinks: 5,
new_referring_domains: 3,
lost_referring_domains: 1,
},
],
billing: createBilling(
"/v3/backlinks/timeseries_new_lost_summary/live",
0.02039,
1,
),
});
const first = await service.profileOverview({
target: "example.com",
includeSubdomains: true,
includeIndirectLinks: true,
excludeInternalBacklinks: true,
status: "live",
});
const second = await service.profileOverview({
target: "example.com",
includeSubdomains: true,
includeIndirectLinks: true,
excludeInternalBacklinks: true,
status: "live",
});
expect(first.billing.fromCache).toBe(false);
expect(first.billing.totalCostUsd).toBe(0.08381);
expect(first.billing.calls.map((call) => call.endpoint)).toEqual([
"/v3/backlinks/summary/live",
"/v3/backlinks/backlinks/live",
"/v3/backlinks/timeseries_summary/live",
"/v3/backlinks/timeseries_new_lost_summary/live",
]);
expect(first.overview.referringDomains).toEqual([]);
expect(first.overview.topPages).toEqual([]);
expect(second.billing.fromCache).toBe(true);
expect(fetchReferringDomainsRaw).not.toHaveBeenCalled();
expect(fetchDomainPagesSummaryRaw).not.toHaveBeenCalled();
expect(fetchBacklinksSummaryRaw).toHaveBeenCalledOnce();
});
it("profiles referring domains and top pages separately", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "https://example.com/foo",
displayTarget: "https://example.com/foo",
scope: "page",
});
vi.mocked(fetchReferringDomainsRaw).mockResolvedValue({
data: [
{
domain: "source.example",
backlinks: 4,
referring_pages: 2,
rank: 65,
first_seen: "2026-01-01",
broken_backlinks: 0,
broken_pages: 0,
backlinks_spam_score: 2,
target_spam_score: 4,
},
],
billing: createBilling("/v3/backlinks/referring_domains/live", 0.023, 1),
});
vi.mocked(fetchDomainPagesSummaryRaw).mockResolvedValue({
data: [
{
page: "https://example.com/foo",
backlinks: 100,
referring_domains: 20,
rank: 50,
broken_backlinks: 0,
},
],
billing: createBilling(
"/v3/backlinks/domain_pages_summary/live",
0.02003,
1,
),
});
const domains = await service.profileReferringDomains({
target: "https://example.com/foo",
includeSubdomains: true,
includeIndirectLinks: true,
excludeInternalBacklinks: true,
status: "live",
});
const pages = await service.profileTopPages({
target: "https://example.com/foo",
includeSubdomains: true,
includeIndirectLinks: true,
excludeInternalBacklinks: true,
status: "live",
});
expect(domains.billing.totalCostUsd).toBe(0.023);
expect(domains.rows).toHaveLength(1);
expect(domains.rows[0]?.spamScore).toBe(2);
expect(pages.billing.totalCostUsd).toBe(0.02003);
expect(pages.rows).toHaveLength(1);
});
it("does not fall back to target spam score for referring domains", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
vi.mocked(fetchReferringDomainsRaw).mockResolvedValue({
data: [
{
domain: "source.example",
backlinks: 4,
referring_pages: 2,
rank: 65,
first_seen: "2026-01-01",
broken_backlinks: 0,
broken_pages: 0,
backlinks_spam_score: null,
target_spam_score: 4,
},
],
billing: createBilling("/v3/backlinks/referring_domains/live", 0.023, 1),
});
const domains = await service.profileReferringDomains({
target: "example.com",
includeSubdomains: true,
includeIndirectLinks: true,
excludeInternalBacklinks: true,
status: "live",
});
expect(domains.rows).toHaveLength(1);
expect(domains.rows[0]?.spamScore).toBeNull();
});
function createBilling(
endpoint: string,
costUsd: number,
rowsReturned: number,
) {
return {
endpoint,
path: endpoint.split("/").filter(Boolean),
costUsd,
resultCount: 1,
rowsReturned,
};
}
function parseCachedValue(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}

View File

@ -0,0 +1,98 @@
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinks";
import {
profileBacklinksOverview,
profileReferringDomainsRows,
profileTopPagesRows,
type BacklinksCache,
} from "@/server/features/backlinks/services/backlinksServiceData";
import type { BacklinksOverviewResult } from "@/server/features/backlinks/services/backlinksOverviewSchema";
import type { BacklinksLookupInput } from "@/types/schemas/backlinks";
const defaultCache: BacklinksCache = {
get: getCached,
set: setCached,
};
function createBacklinksService(cache: BacklinksCache = defaultCache) {
return {
async getOverview(
input: BacklinksLookupInput,
): Promise<BacklinksOverviewResult> {
const profile = await profileBacklinksOverview(
cache,
buildOverviewCacheKey(input),
input,
);
return profile.overview;
},
async profileOverview(input: BacklinksLookupInput) {
return profileBacklinksOverview(
cache,
buildOverviewCacheKey(input),
input,
);
},
async getReferringDomains(input: BacklinksLookupInput) {
const profile = await profileReferringDomainsRows(
cache,
buildTabCacheKey("backlinks:referring-domains", input),
input,
);
return profile.rows;
},
async profileReferringDomains(input: BacklinksLookupInput) {
return profileReferringDomainsRows(
cache,
buildTabCacheKey("backlinks:referring-domains", input),
input,
);
},
async getTopPages(input: BacklinksLookupInput) {
const profile = await profileTopPagesRows(
cache,
buildTabCacheKey("backlinks:top-pages", input),
input,
);
return profile.rows;
},
async profileTopPages(input: BacklinksLookupInput) {
return profileTopPagesRows(
cache,
buildTabCacheKey("backlinks:top-pages", input),
input,
);
},
} as const;
}
function buildOverviewCacheKey(input: BacklinksLookupInput) {
const normalizedTarget = normalizeBacklinksTarget(input.target, {
scope: input.scope,
});
return buildCacheKey("backlinks:overview", {
target: normalizedTarget.apiTarget,
scope: normalizedTarget.scope,
includeSubdomains: input.includeSubdomains,
includeIndirectLinks: input.includeIndirectLinks,
excludeInternalBacklinks: input.excludeInternalBacklinks,
status: input.status,
});
}
function buildTabCacheKey(prefix: string, input: BacklinksLookupInput) {
const normalizedTarget = normalizeBacklinksTarget(input.target, {
scope: input.scope,
});
return buildCacheKey(prefix, {
target: normalizedTarget.apiTarget,
scope: normalizedTarget.scope,
includeSubdomains: input.includeSubdomains,
includeIndirectLinks: input.includeIndirectLinks,
excludeInternalBacklinks: input.excludeInternalBacklinks,
status: input.status,
});
}
export const BacklinksService = createBacklinksService();
export { createBacklinksService };

View File

@ -0,0 +1,50 @@
import { z } from "zod";
export type BacklinksApiCallCost = {
endpoint: string;
path: string[];
costUsd: number;
resultCount: number | null;
rowsReturned: number;
};
export type BacklinksCostSummary = {
provider: "dataforseo";
currency: "USD";
fromCache: boolean;
totalCostUsd: number;
calls: BacklinksApiCallCost[];
};
export const backlinksCostSummarySchema = z.object({
provider: z.literal("dataforseo"),
currency: z.literal("USD"),
fromCache: z.boolean(),
totalCostUsd: z.number(),
calls: z.array(
z.object({
endpoint: z.string(),
path: z.array(z.string()),
costUsd: z.number(),
resultCount: z.number().nullable(),
rowsReturned: z.number(),
}),
),
});
export function summarizeBacklinksCosts(
calls: BacklinksApiCallCost[],
fromCache: boolean,
): BacklinksCostSummary {
return {
provider: "dataforseo",
currency: "USD",
fromCache,
totalCostUsd: roundUsd(calls.reduce((sum, call) => sum + call.costUsd, 0)),
calls,
};
}
function roundUsd(value: number) {
return Math.round(value * 100000) / 100000;
}

View File

@ -0,0 +1,86 @@
import { z } from "zod";
const backlinksRowSchema = z.object({
domainFrom: z.string().nullable(),
urlFrom: z.string().nullable(),
urlTo: z.string().nullable(),
anchor: z.string().nullable(),
itemType: z.string().nullable(),
isDofollow: z.boolean().nullable(),
relAttributes: z.array(z.string()),
rank: z.number().nullable(),
domainFromRank: z.number().nullable(),
pageFromRank: z.number().nullable(),
spamScore: z.number().nullable(),
firstSeen: z.string().nullable(),
lastSeen: z.string().nullable(),
isLost: z.boolean(),
isBroken: z.boolean(),
linksCount: z.number().nullable(),
});
export const referringDomainRowSchema = z.object({
domain: z.string().nullable(),
backlinks: z.number().nullable(),
referringPages: z.number().nullable(),
rank: z.number().nullable(),
spamScore: z.number().nullable(),
firstSeen: z.string().nullable(),
brokenBacklinks: z.number().nullable(),
brokenPages: z.number().nullable(),
});
export const topPageRowSchema = z.object({
page: z.string().nullable(),
backlinks: z.number().nullable(),
referringDomains: z.number().nullable(),
rank: z.number().nullable(),
brokenBacklinks: z.number().nullable(),
});
const backlinksTrendRowSchema = z.object({
date: z.string(),
backlinks: z.number().nullable(),
referringDomains: z.number().nullable(),
rank: z.number().nullable(),
});
const backlinksNewLostTrendRowSchema = z.object({
date: z.string(),
newBacklinks: z.number().nullable(),
lostBacklinks: z.number().nullable(),
newReferringDomains: z.number().nullable(),
lostReferringDomains: z.number().nullable(),
});
export const backlinksOverviewSchema = z.object({
target: z.string(),
displayTarget: z.string(),
scope: z.enum(["domain", "page"]),
includeSubdomains: z.boolean(),
includeIndirectLinks: z.boolean(),
excludeInternalBacklinks: z.boolean(),
status: z.enum(["live", "lost", "all"]),
summary: z.object({
rank: z.number().nullable(),
backlinks: z.number().nullable(),
referringPages: z.number().nullable(),
referringDomains: z.number().nullable(),
brokenBacklinks: z.number().nullable(),
brokenPages: z.number().nullable(),
backlinksSpamScore: z.number().nullable(),
targetSpamScore: z.number().nullable(),
newBacklinks: z.number().nullable(),
lostBacklinks: z.number().nullable(),
newReferringDomains: z.number().nullable(),
lostReferringDomains: z.number().nullable(),
}),
backlinks: z.array(backlinksRowSchema),
referringDomains: z.array(referringDomainRowSchema),
topPages: z.array(topPageRowSchema),
trends: z.array(backlinksTrendRowSchema),
newLostTrends: z.array(backlinksNewLostTrendRowSchema),
fetchedAt: z.string(),
});
export type BacklinksOverviewResult = z.infer<typeof backlinksOverviewSchema>;

View File

@ -0,0 +1,367 @@
import { z } from "zod";
import {
type BacklinksApiResponse,
type BacklinksRequest,
fetchBacklinksRowsRaw,
fetchBacklinksSummaryRaw,
fetchDomainPagesSummaryRaw,
fetchNewLostTimeseriesRaw,
fetchReferringDomainsRaw,
fetchTimeseriesSummaryRaw,
normalizeBacklinksTarget,
} from "@/server/lib/dataforseoBacklinks";
import {
backlinksOverviewSchema,
referringDomainRowSchema,
topPageRowSchema,
type BacklinksOverviewResult,
} from "@/server/features/backlinks/services/backlinksOverviewSchema";
import {
backlinksCostSummarySchema,
summarizeBacklinksCosts,
type BacklinksApiCallCost,
type BacklinksCostSummary,
} from "@/server/features/backlinks/services/backlinksCost";
import type { BacklinksLookupInput } from "@/types/schemas/backlinks";
const BACKLINKS_OVERVIEW_TTL_SECONDS = 6 * 60 * 60;
const BACKLINKS_TAB_TTL_SECONDS = 6 * 60 * 60;
export type BacklinksCache = {
get(key: string): Promise<unknown>;
set(key: string, data: unknown, ttlSeconds: number): Promise<void>;
};
type BacklinksOverviewProfile = {
overview: BacklinksOverviewResult;
billing: BacklinksCostSummary;
};
type ReferringDomainsProfile = {
rows: BacklinksOverviewResult["referringDomains"];
billing: BacklinksCostSummary;
};
type TopPagesProfile = {
rows: BacklinksOverviewResult["topPages"];
billing: BacklinksCostSummary;
};
const backlinksOverviewCacheSchema = z.object({
overview: backlinksOverviewSchema,
billing: backlinksCostSummarySchema,
});
const referringDomainsCacheSchema = z.object({
rows: z.array(referringDomainRowSchema),
billing: backlinksCostSummarySchema,
});
const topPagesCacheSchema = z.object({
rows: z.array(topPageRowSchema),
billing: backlinksCostSummarySchema,
});
type BacklinksDateRange = {
dateFrom: string;
dateTo: string;
};
export async function profileBacklinksOverview(
cache: BacklinksCache,
cacheKey: string,
input: BacklinksLookupInput,
): Promise<BacklinksOverviewProfile> {
const cachedRaw = await cache.get(cacheKey);
const cached = backlinksOverviewCacheSchema.safeParse(cachedRaw);
if (cached.success) {
return {
overview: cached.data.overview,
billing: withCacheFlag(cached.data.billing),
};
}
const now = new Date();
const normalizedTarget = normalizeBacklinksTarget(input.target, {
scope: input.scope,
});
const request = buildBacklinksRequest(input, normalizedTarget.apiTarget);
const dateRange = buildBacklinksDateRange(now);
const [summary, backlinks, trends, newLostTrends] = await Promise.all([
fetchBacklinksSummaryRaw(request),
fetchBacklinksRowsRaw({ ...request, limit: 100 }),
normalizedTarget.scope === "domain"
? fetchTimeseriesSummaryRaw({ ...request, ...dateRange })
: Promise.resolve(emptyResponse([])),
normalizedTarget.scope === "domain"
? fetchNewLostTimeseriesRaw({ ...request, ...dateRange })
: Promise.resolve(emptyResponse([])),
]);
const overview = buildOverviewResult({
input,
normalizedTarget,
now,
summary,
backlinks,
trends,
newLostTrends,
});
const billing = summarizeBacklinksCosts(
collectCostCalls([
summary.billing,
backlinks.billing,
trends.billing,
newLostTrends.billing,
]),
false,
);
await cacheValue(
cache,
cacheKey,
{ overview, billing },
BACKLINKS_OVERVIEW_TTL_SECONDS,
);
return { overview, billing };
}
export async function profileReferringDomainsRows(
cache: BacklinksCache,
cacheKey: string,
input: BacklinksLookupInput,
): Promise<ReferringDomainsProfile> {
const cachedRaw = await cache.get(cacheKey);
const cached = referringDomainsCacheSchema.safeParse(cachedRaw);
if (cached.success) {
return {
rows: cached.data.rows,
billing: withCacheFlag(cached.data.billing),
};
}
const request = buildBacklinksRequest(
input,
normalizeBacklinksTarget(input.target, { scope: input.scope }).apiTarget,
);
const response = await fetchReferringDomainsRaw({ ...request, limit: 100 });
const rows = mapReferringDomainsRows(response.data);
const billing = summarizeBacklinksCosts([response.billing], false);
await cacheValue(
cache,
cacheKey,
{ rows, billing },
BACKLINKS_TAB_TTL_SECONDS,
);
return { rows, billing };
}
export async function profileTopPagesRows(
cache: BacklinksCache,
cacheKey: string,
input: BacklinksLookupInput,
): Promise<TopPagesProfile> {
const cachedRaw = await cache.get(cacheKey);
const cached = topPagesCacheSchema.safeParse(cachedRaw);
if (cached.success) {
return {
rows: cached.data.rows,
billing: withCacheFlag(cached.data.billing),
};
}
const request = buildBacklinksRequest(
input,
normalizeBacklinksTarget(input.target, { scope: input.scope }).apiTarget,
);
const response = await fetchDomainPagesSummaryRaw({ ...request, limit: 100 });
const rows = mapTopPagesRows(response.data);
const billing = summarizeBacklinksCosts([response.billing], false);
await cacheValue(
cache,
cacheKey,
{ rows, billing },
BACKLINKS_TAB_TTL_SECONDS,
);
return { rows, billing };
}
function buildBacklinksRequest(
input: BacklinksLookupInput,
target: string,
): BacklinksRequest {
return {
target,
includeSubdomains: input.includeSubdomains,
includeIndirectLinks: input.includeIndirectLinks,
excludeInternalBacklinks: input.excludeInternalBacklinks,
status: input.status,
};
}
function buildBacklinksDateRange(now: Date): BacklinksDateRange {
const todayUtc = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
);
const dateToUtc = new Date(todayUtc);
dateToUtc.setUTCDate(dateToUtc.getUTCDate() - 1);
const dateFromUtc = new Date(dateToUtc);
dateFromUtc.setUTCFullYear(dateFromUtc.getUTCFullYear() - 1);
return {
dateFrom: dateFromUtc.toISOString().slice(0, 10),
dateTo: dateToUtc.toISOString().slice(0, 10),
};
}
function buildOverviewResult(args: {
input: BacklinksLookupInput;
normalizedTarget: ReturnType<typeof normalizeBacklinksTarget>;
now: Date;
summary: Awaited<ReturnType<typeof fetchBacklinksSummaryRaw>>;
backlinks: Awaited<ReturnType<typeof fetchBacklinksRowsRaw>>;
trends: Awaited<ReturnType<typeof fetchTimeseriesSummaryRaw>>;
newLostTrends: Awaited<ReturnType<typeof fetchNewLostTimeseriesRaw>>;
}): BacklinksOverviewResult {
return {
target: args.normalizedTarget.apiTarget,
displayTarget: args.normalizedTarget.displayTarget,
scope: args.normalizedTarget.scope,
includeSubdomains: args.input.includeSubdomains,
includeIndirectLinks: args.input.includeIndirectLinks,
excludeInternalBacklinks: args.input.excludeInternalBacklinks,
status: args.input.status,
summary: {
rank: args.summary.data.rank ?? null,
backlinks: args.summary.data.backlinks ?? null,
referringPages: args.summary.data.referring_pages ?? null,
referringDomains: args.summary.data.referring_domains ?? null,
brokenBacklinks: args.summary.data.broken_backlinks ?? null,
brokenPages: args.summary.data.broken_pages ?? null,
backlinksSpamScore: args.summary.data.backlinks_spam_score ?? null,
targetSpamScore: args.summary.data.info?.target_spam_score ?? null,
newBacklinks: args.summary.data.new_backlinks ?? null,
lostBacklinks: args.summary.data.lost_backlinks ?? null,
newReferringDomains:
args.summary.data.new_referring_domains ??
args.summary.data.new_reffering_domains ??
null,
lostReferringDomains:
args.summary.data.lost_referring_domains ??
args.summary.data.lost_reffering_domains ??
null,
},
backlinks: mapBacklinksRows(args.backlinks.data),
referringDomains: [],
topPages: [],
trends: args.trends.data
.filter((item) => Boolean(item.date))
.map((item) => ({
date: item.date ?? "",
backlinks: item.backlinks ?? null,
referringDomains: item.referring_domains ?? null,
rank: item.rank ?? null,
})),
newLostTrends: args.newLostTrends.data
.filter((item) => Boolean(item.date))
.map((item) => ({
date: item.date ?? "",
newBacklinks: item.new_backlinks ?? null,
lostBacklinks: item.lost_backlinks ?? null,
newReferringDomains:
item.new_referring_domains ?? item.new_reffering_domains ?? null,
lostReferringDomains:
item.lost_referring_domains ?? item.lost_reffering_domains ?? null,
})),
fetchedAt: args.now.toISOString(),
};
}
function mapBacklinksRows(
rows: Awaited<ReturnType<typeof fetchBacklinksRowsRaw>>["data"],
) {
return rows.map((item) => ({
domainFrom: item.domain_from ?? null,
urlFrom: item.url_from ?? null,
urlTo: item.url_to ?? null,
anchor: item.anchor ?? null,
itemType: item.item_type ?? null,
isDofollow: item.dofollow ?? null,
relAttributes: item.rel_attributes ?? item.attributes ?? [],
rank: item.rank ?? null,
domainFromRank: item.domain_from_rank ?? null,
pageFromRank: item.page_from_rank ?? null,
spamScore: item.backlink_spam_score ?? item.backlinks_spam_score ?? null,
firstSeen: item.first_seen ?? null,
lastSeen: item.lost_date ?? item.last_visited ?? null,
isLost: item.is_lost ?? Boolean(item.lost_date),
isBroken: item.is_broken ?? false,
linksCount: item.links_count ?? null,
}));
}
function mapReferringDomainsRows(
rows: Awaited<ReturnType<typeof fetchReferringDomainsRaw>>["data"],
) {
return rows.map((item) => ({
domain: item.domain ?? null,
backlinks: item.backlinks ?? null,
referringPages: item.referring_pages ?? null,
rank: item.rank ?? null,
spamScore: item.backlinks_spam_score ?? null,
firstSeen: item.first_seen ?? null,
brokenBacklinks: item.broken_backlinks ?? null,
brokenPages: item.broken_pages ?? null,
}));
}
function mapTopPagesRows(
rows: Awaited<ReturnType<typeof fetchDomainPagesSummaryRaw>>["data"],
) {
return rows.map((item) => ({
page: item.page ?? item.url ?? null,
backlinks: item.backlinks ?? null,
referringDomains: item.referring_domains ?? null,
rank: item.rank ?? null,
brokenBacklinks: item.broken_backlinks ?? null,
}));
}
function collectCostCalls(calls: BacklinksApiCallCost[]) {
return calls.filter((call) => call.costUsd > 0 || call.rowsReturned > 0);
}
function withCacheFlag(summary: BacklinksCostSummary): BacklinksCostSummary {
return { ...summary, fromCache: true };
}
async function cacheValue(
cache: BacklinksCache,
key: string,
data: unknown,
ttlSeconds: number,
) {
await cache.set(key, data, ttlSeconds).catch((error: unknown) => {
console.error("backlinks.cache-write failed:", error);
});
}
function emptyResponse<T>(data: T): BacklinksApiResponse<T> {
return {
data,
billing: {
endpoint: "",
path: [],
costUsd: 0,
resultCount: null,
rowsReturned: 0,
},
};
}

View File

@ -0,0 +1,174 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppError } from "@/server/lib/errors";
vi.mock("@/server/lib/runtime-env", () => ({
getRequiredEnvValue: vi.fn(async () => "test-api-key"),
}));
vi.mock("@/server/lib/dataforseoBacklinksAccount", () => ({
classifyBacklinksErrorWithAccountState: vi.fn(),
}));
import {
fetchBacklinksSummaryRaw,
normalizeBacklinksTarget,
} from "@/server/lib/dataforseoBacklinks";
import { classifyBacklinksErrorWithAccountState } from "@/server/lib/dataforseoBacklinksAccount";
describe("normalizeBacklinksTarget", () => {
it("treats explicit homepage URLs as page lookups", () => {
expect(normalizeBacklinksTarget("https://Example.com/")).toEqual({
apiTarget: "https://example.com/",
displayTarget: "https://example.com/",
scope: "page",
});
});
it("treats bare hostnames as domain lookups", () => {
expect(normalizeBacklinksTarget("Example.com")).toEqual({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
});
it("lets callers force domain scope for full URLs", () => {
expect(
normalizeBacklinksTarget("https://Example.com/pricing", {
scope: "domain",
}),
).toEqual({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
});
it("normalizes domain scope for URLs with query strings or fragments", () => {
expect(
normalizeBacklinksTarget(
"https://Example.com/pricing?utm_source=newsletter#hero",
{
scope: "domain",
},
),
).toEqual({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
});
it("lets callers force page scope for bare hostnames", () => {
expect(normalizeBacklinksTarget("Example.com", { scope: "page" })).toEqual({
apiTarget: "https://example.com/",
displayTarget: "https://example.com/",
scope: "page",
});
});
it("rejects page targets with query strings or fragments", () => {
expectValidationError(() =>
normalizeBacklinksTarget("https://example.com/pricing?token=secret#hero"),
);
});
it("rejects page targets with embedded credentials", () => {
expectValidationError(() =>
normalizeBacklinksTarget("https://user:pass@example.com/private"),
);
});
});
describe("fetchBacklinksSummaryRaw", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("classifies top-level DataForSEO body errors using status_code", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
status_code: 40204,
status_message: "Backlinks subscription required",
tasks: [],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.mocked(classifyBacklinksErrorWithAccountState).mockImplementation(
async (status: number | undefined) => {
if (status === 40204) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled",
);
}
return null;
},
);
await expect(
fetchBacklinksSummaryRaw({
target: "example.com",
includeSubdomains: true,
includeIndirectLinks: true,
excludeInternalBacklinks: true,
status: "live",
}),
).rejects.toMatchObject({ code: "BACKLINKS_NOT_ENABLED" });
expect(classifyBacklinksErrorWithAccountState).toHaveBeenCalledWith(
40204,
expect.stringContaining("Backlinks subscription required"),
"/v3/backlinks/summary/live",
);
});
it("treats null summary results as validation errors", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
status_code: 20000,
status_message: "Ok.",
tasks: [
{
status_code: 20000,
status_message: "Ok.",
result: [null],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null);
await expect(
fetchBacklinksSummaryRaw({
target: "not-a-real-input.example",
includeSubdomains: true,
includeIndirectLinks: true,
excludeInternalBacklinks: true,
status: "live",
}),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
});
});
function expectValidationError(fn: () => unknown) {
try {
fn();
} catch (error) {
expect(error).toMatchObject({ code: "VALIDATION_ERROR" });
return;
}
throw new Error("Expected normalizeBacklinksTarget to throw");
}

View File

@ -0,0 +1,315 @@
import { AppError } from "@/server/lib/errors";
import type { BacklinksApiCallCost } from "@/server/features/backlinks/services/backlinksCost";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
import type { BacklinksLookupInput } from "@/types/schemas/backlinks";
import {
backlinksItemSchema,
backlinksSummaryItemSchema,
domainPageSummaryItemSchema,
newLostTimeseriesItemSchema,
parseFirstResult,
parseItems,
referringDomainItemSchema,
responseSchema,
timeseriesSummaryItemSchema,
} from "@/server/lib/dataforseoBacklinksSupport";
import { classifyBacklinksErrorWithAccountState } from "@/server/lib/dataforseoBacklinksAccount";
export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
const API_BASE = "https://api.dataforseo.com";
export type BacklinksRequest = BacklinksLookupInput & {
target: string;
};
type BacklinksListRequest = BacklinksRequest & {
limit?: number;
};
type BacklinksTimeseriesRequest = BacklinksRequest & {
dateFrom: string;
dateTo: string;
};
type DataforseoTaskResponse = {
results: unknown[];
billing: Omit<BacklinksApiCallCost, "rowsReturned">;
};
export type BacklinksApiResponse<T> = {
data: T;
billing: BacklinksApiCallCost;
};
async function createAuthenticatedFetch() {
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
return (url: RequestInfo, init?: RequestInit): Promise<Response> => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Basic ${apiKey}`);
return fetch(url, {
...init,
headers,
});
};
}
async function postBacklinks(path: string, payload: unknown) {
const authenticatedFetch = await createAuthenticatedFetch();
const response = await authenticatedFetch(`${API_BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const rawText = await response.text();
if (!response.ok) {
const classifiedError = await classifyBacklinksErrorWithAccountState(
response.status,
rawText,
path,
);
if (classifiedError) throw classifiedError;
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO HTTP ${response.status} on ${path}`,
);
}
let raw: unknown;
try {
raw = JSON.parse(rawText);
} catch {
const classifiedError = await classifyBacklinksErrorWithAccountState(
response.status,
rawText,
path,
);
if (classifiedError) throw classifiedError;
console.error(
`dataforseo.${path}.non-json-response`,
rawText.slice(0, 800),
);
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${path} returned a non-JSON response`,
);
}
const parsed = responseSchema.safeParse(raw);
if (!parsed.success) {
const classifiedError = await classifyBacklinksErrorWithAccountState(
response.status,
rawText,
path,
);
if (classifiedError) throw classifiedError;
console.error(
`dataforseo.${path}.invalid-top-level-shape`,
rawText.slice(0, 800),
);
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${path} returned an invalid response shape`,
);
}
const responseData = parsed.data;
if (responseData.status_code !== 20000) {
const classifiedError = await classifyBacklinksErrorWithAccountState(
responseData.status_code,
`${responseData.status_message ?? ""} ${rawText}`,
path,
);
if (classifiedError) throw classifiedError;
throw new AppError(
"INTERNAL_ERROR",
responseData.status_message || "DataForSEO request failed",
);
}
const task = responseData.tasks?.[0];
if (!task) {
throw new AppError("INTERNAL_ERROR", "DataForSEO response missing task");
}
if (task.status_code !== 20000) {
const classifiedError = await classifyBacklinksErrorWithAccountState(
task.status_code,
`${task.status_message ?? ""} ${rawText}`,
path,
);
if (classifiedError) throw classifiedError;
throw new AppError(
"INTERNAL_ERROR",
task.status_message || "DataForSEO task failed",
);
}
return {
results: task.result ?? [],
billing: {
endpoint: path,
path: task.path ?? [],
costUsd: task.cost ?? responseData.cost ?? 0,
resultCount: task.result_count ?? null,
},
} satisfies DataforseoTaskResponse;
}
function buildCommonPayload(input: BacklinksRequest) {
return {
target: input.target,
include_subdomains: input.includeSubdomains,
include_indirect_links: input.includeIndirectLinks,
exclude_internal_backlinks: input.excludeInternalBacklinks,
backlinks_status_type: input.status,
rank_scale: "one_hundred",
};
}
export async function fetchBacklinksSummaryRaw(input: BacklinksRequest) {
const response = await postBacklinks("/v3/backlinks/summary/live", [
buildCommonPayload(input),
]);
const data = parseFirstResult(
"backlinks-summary-live",
response.results,
backlinksSummaryItemSchema,
);
return {
data,
billing: {
...response.billing,
rowsReturned: data ? 1 : 0,
},
} satisfies BacklinksApiResponse<typeof data>;
}
export async function fetchBacklinksRowsRaw(input: BacklinksListRequest) {
const response = await postBacklinks("/v3/backlinks/backlinks/live", [
{
...buildCommonPayload(input),
limit: input.limit ?? 100,
order_by: ["rank,desc"],
},
]);
const data = parseItems(
"backlinks-live",
response.results,
backlinksItemSchema,
);
return {
data,
billing: {
...response.billing,
rowsReturned: data.length,
},
} satisfies BacklinksApiResponse<typeof data>;
}
export async function fetchReferringDomainsRaw(input: BacklinksListRequest) {
const response = await postBacklinks("/v3/backlinks/referring_domains/live", [
{
...buildCommonPayload(input),
limit: input.limit ?? 100,
order_by: ["backlinks,desc"],
},
]);
const data = parseItems(
"referring-domains-live",
response.results,
referringDomainItemSchema,
);
return {
data,
billing: {
...response.billing,
rowsReturned: data.length,
},
} satisfies BacklinksApiResponse<typeof data>;
}
export async function fetchDomainPagesSummaryRaw(input: BacklinksListRequest) {
const response = await postBacklinks(
"/v3/backlinks/domain_pages_summary/live",
[
{
...buildCommonPayload(input),
limit: input.limit ?? 100,
order_by: ["backlinks,desc"],
},
],
);
const data = parseItems(
"domain-pages-summary-live",
response.results,
domainPageSummaryItemSchema,
);
return {
data,
billing: {
...response.billing,
rowsReturned: data.length,
},
} satisfies BacklinksApiResponse<typeof data>;
}
export async function fetchTimeseriesSummaryRaw(
input: BacklinksTimeseriesRequest,
) {
const response = await postBacklinks(
"/v3/backlinks/timeseries_summary/live",
[
{
...buildCommonPayload(input),
date_from: input.dateFrom,
date_to: input.dateTo,
group_range: "month",
},
],
);
const data = parseItems(
"timeseries-summary-live",
response.results,
timeseriesSummaryItemSchema,
);
return {
data,
billing: {
...response.billing,
rowsReturned: data.length,
},
} satisfies BacklinksApiResponse<typeof data>;
}
export async function fetchNewLostTimeseriesRaw(
input: BacklinksTimeseriesRequest,
) {
const response = await postBacklinks(
"/v3/backlinks/timeseries_new_lost_summary/live",
[
{
...buildCommonPayload(input),
date_from: input.dateFrom,
date_to: input.dateTo,
group_range: "month",
},
],
);
const data = parseItems(
"timeseries-new-lost-summary-live",
response.results,
newLostTimeseriesItemSchema,
);
return {
data,
billing: {
...response.billing,
rowsReturned: data.length,
},
} satisfies BacklinksApiResponse<typeof data>;
}

View File

@ -0,0 +1,154 @@
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import { classifyBacklinksError } from "@/server/lib/dataforseoBacklinksSupport";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
const API_BASE = "https://api.dataforseo.com";
const userDataResponseSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
tasks: z
.array(
z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
result: z
.array(
z
.object({
money: z
.object({
balance: z.number().nullable().optional(),
})
.passthrough()
.optional(),
backlinks_subscription_expiry_date: z
.string()
.nullable()
.optional(),
})
.passthrough(),
)
.nullable()
.optional(),
})
.passthrough(),
)
.optional(),
})
.passthrough();
async function createAuthenticatedFetch() {
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
return (url: RequestInfo, init?: RequestInit): Promise<Response> => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Basic ${apiKey}`);
return fetch(url, {
...init,
headers,
});
};
}
async function getDataforseo(path: string) {
const authenticatedFetch = await createAuthenticatedFetch();
const response = await authenticatedFetch(`${API_BASE}${path}`, {
method: "GET",
});
if (!response.ok) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO HTTP ${response.status} on ${path}`,
);
}
return await response.json();
}
async function fetchBacklinksAccountState() {
const raw = await getDataforseo("/v3/appendix/user_data");
const parsed = userDataResponseSchema.safeParse(raw);
if (!parsed.success || parsed.data.status_code !== 20000) {
return null;
}
const task = parsed.data.tasks?.[0];
if (!task || task.status_code !== 20000) {
return null;
}
const result = task.result?.[0];
if (!result) {
return null;
}
return {
balance: result.money?.balance ?? null,
backlinksSubscriptionExpiryDate:
result.backlinks_subscription_expiry_date ?? null,
};
}
function hasActiveBacklinksSubscription(value: string | null) {
if (!value) return false;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return true;
return parsed.getTime() > Date.now();
}
export async function classifyBacklinksErrorWithAccountState(
status: number | undefined,
details: string,
path: string,
) {
const classifiedError = classifyBacklinksError(status, details, path);
if (classifiedError) {
return classifiedError;
}
const text = details.toLowerCase();
const needsAccountLookup =
path.includes("/backlinks/") &&
(status === 402 ||
status === 403 ||
text.includes("backlinks") ||
text.includes("subscription") ||
text.includes("billing") ||
text.includes("balance") ||
text.includes("payment"));
if (!needsAccountLookup) {
return null;
}
const accountState = await fetchBacklinksAccountState().catch(() => null);
if (!accountState) {
return null;
}
if (
!hasActiveBacklinksSubscription(
accountState.backlinksSubscriptionExpiryDate,
)
) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled for the connected DataForSEO account",
);
}
if (typeof accountState.balance === "number" && accountState.balance <= 0) {
return new AppError(
"BACKLINKS_BILLING_ISSUE",
"The connected DataForSEO account has a billing or balance issue",
);
}
return null;
}

View File

@ -0,0 +1,262 @@
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
const taskSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
cost: z.number().nullable().optional(),
result_count: z.number().nullable().optional(),
path: z.array(z.string()).optional(),
result: z.array(z.unknown()).nullable().optional(),
})
.passthrough();
export const responseSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
cost: z.number().nullable().optional(),
tasks: z.array(taskSchema).optional(),
})
.passthrough();
export const backlinksSummaryItemSchema = z
.object({
target: z.string().optional(),
rank: z.number().nullable().optional(),
backlinks: z.number().nullable().optional(),
referring_pages: z.number().nullable().optional(),
referring_domains: z.number().nullable().optional(),
broken_backlinks: z.number().nullable().optional(),
broken_pages: z.number().nullable().optional(),
new_backlinks: z.number().nullable().optional(),
lost_backlinks: z.number().nullable().optional(),
new_reffering_domains: z.number().nullable().optional(),
lost_reffering_domains: z.number().nullable().optional(),
new_referring_domains: z.number().nullable().optional(),
lost_referring_domains: z.number().nullable().optional(),
backlinks_spam_score: z.number().nullable().optional(),
info: z
.object({
target_spam_score: z.number().nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
})
.passthrough();
export const backlinksItemSchema = z
.object({
domain_from: z.string().nullable().optional(),
url_from: z.string().nullable().optional(),
url_to: z.string().nullable().optional(),
anchor: z.string().nullable().optional(),
item_type: z.string().nullable().optional(),
dofollow: z.boolean().nullable().optional(),
rank: z.number().nullable().optional(),
domain_from_rank: z.number().nullable().optional(),
page_from_rank: z.number().nullable().optional(),
backlinks_spam_score: z.number().nullable().optional(),
backlink_spam_score: z.number().nullable().optional(),
first_seen: z.string().nullable().optional(),
last_visited: z.string().nullable().optional(),
lost_date: z.string().nullable().optional(),
is_new: z.boolean().nullable().optional(),
is_lost: z.boolean().nullable().optional(),
is_broken: z.boolean().nullable().optional(),
links_count: z.number().nullable().optional(),
rel_attributes: z.array(z.string()).nullable().optional(),
attributes: z.array(z.string()).nullable().optional(),
})
.passthrough();
export const referringDomainItemSchema = z
.object({
domain: z.string().nullable().optional(),
backlinks: z.number().nullable().optional(),
referring_pages: z.number().nullable().optional(),
rank: z.number().nullable().optional(),
first_seen: z.string().nullable().optional(),
broken_backlinks: z.number().nullable().optional(),
broken_pages: z.number().nullable().optional(),
backlinks_spam_score: z.number().nullable().optional(),
target_spam_score: z.number().nullable().optional(),
})
.passthrough();
export const domainPageSummaryItemSchema = z
.object({
page: z.string().nullable().optional(),
url: z.string().nullable().optional(),
backlinks: z.number().nullable().optional(),
referring_domains: z.number().nullable().optional(),
rank: z.number().nullable().optional(),
broken_backlinks: z.number().nullable().optional(),
})
.passthrough();
export const timeseriesSummaryItemSchema = z
.object({
date: z.string().nullable().optional(),
rank: z.number().nullable().optional(),
backlinks: z.number().nullable().optional(),
referring_domains: z.number().nullable().optional(),
})
.passthrough();
export const newLostTimeseriesItemSchema = z
.object({
date: z.string().nullable().optional(),
new_backlinks: z.number().nullable().optional(),
lost_backlinks: z.number().nullable().optional(),
new_reffering_domains: z.number().nullable().optional(),
lost_reffering_domains: z.number().nullable().optional(),
new_referring_domains: z.number().nullable().optional(),
lost_referring_domains: z.number().nullable().optional(),
})
.passthrough();
const resultItemsSchema = z.object({
items: z.array(z.unknown()).optional(),
});
export function classifyBacklinksError(
status: number | undefined,
details: string,
path: string,
): AppError | null {
const text = details.toLowerCase();
const looksLikeBacklinksAccessIssue =
path.includes("/backlinks/") &&
(text.includes("backlinks") ||
text.includes("subscription") ||
text.includes("access") ||
text.includes("plan") ||
text.includes("balance") ||
text.includes("payment") ||
text.includes("billing") ||
text.includes("available") ||
text.includes("enabled") ||
status === 402 ||
status === 403);
if (!looksLikeBacklinksAccessIssue) return null;
if (status === 40204) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled for the connected DataForSEO account",
);
}
if (status === 40200 || status === 40210 || status === 402) {
return new AppError(
"BACKLINKS_BILLING_ISSUE",
"The connected DataForSEO account has a billing or balance issue",
);
}
const unavailableSignals = [
"not available",
"not enabled",
"not allowed",
"access denied",
"forbidden",
"insufficient",
"subscription",
"upgrade",
"plan",
"activate your subscription",
"plans and subscriptions",
];
const billingSignals = [
"payment required",
"billing",
"balance",
"insufficient funds",
"balance is too low",
"problem billing",
"recharged",
];
if (billingSignals.some((signal) => text.includes(signal))) {
return new AppError(
"BACKLINKS_BILLING_ISSUE",
"The connected DataForSEO account has a billing or balance issue",
);
}
if (unavailableSignals.some((signal) => text.includes(signal))) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled for the connected DataForSEO account",
);
}
if (status === 403) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled for the connected DataForSEO account",
);
}
return null;
}
export function parseItems<T extends z.ZodTypeAny>(
endpointName: string,
results: unknown[],
itemSchema: T,
): Array<z.infer<T>> {
const firstResult = results[0] ?? null;
if (firstResult == null) {
console.warn(`dataforseo.${endpointName}.empty-result`);
throw new AppError("VALIDATION_ERROR", "Backlinks target is invalid");
}
const parsedItemsHolder = resultItemsSchema.safeParse(firstResult);
const items = parsedItemsHolder.success
? (parsedItemsHolder.data.items ?? [])
: [];
const parsed = z.array(itemSchema).safeParse(items);
if (!parsed.success) {
console.error(
`dataforseo.${endpointName}.invalid-items`,
parsed.error.issues.slice(0, 5),
);
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${endpointName} returned an invalid response shape`,
);
}
return parsed.data;
}
export function parseFirstResult<T extends z.ZodTypeAny>(
endpointName: string,
results: unknown[],
resultSchema: T,
): z.infer<T> {
const firstResult = results[0] ?? null;
if (firstResult == null) {
console.warn(`dataforseo.${endpointName}.empty-result`);
throw new AppError("VALIDATION_ERROR", "Backlinks target is invalid");
}
const parsed = resultSchema.safeParse(firstResult);
if (!parsed.success) {
console.error(
`dataforseo.${endpointName}.invalid-result`,
parsed.error.issues.slice(0, 5),
);
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${endpointName} returned an invalid response shape`,
);
}
return parsed.data;
}

View File

@ -0,0 +1,92 @@
import { AppError } from "@/server/lib/errors";
import type { BacklinksLookupInput } from "@/types/schemas/backlinks";
type NormalizedBacklinkTarget = {
apiTarget: string;
displayTarget: string;
scope: "domain" | "page";
};
type NormalizeBacklinksTargetOptions = {
scope?: BacklinksLookupInput["scope"];
};
export function normalizeBacklinksTarget(
input: string,
options: NormalizeBacklinksTargetOptions = {},
): NormalizedBacklinkTarget {
const trimmed = input.trim();
if (!trimmed) {
throw new AppError("VALIDATION_ERROR", "Target is required");
}
const hasExplicitProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed);
const withProtocol = hasExplicitProtocol ? trimmed : `https://${trimmed}`;
let parsed: URL;
try {
parsed = new URL(withProtocol);
} catch {
throw new AppError("VALIDATION_ERROR", "Target is invalid");
}
const exactHostname = parsed.hostname.toLowerCase();
const domainHostname = exactHostname.replace(/^www\./, "");
if (!domainHostname || !domainHostname.includes(".")) {
throw new AppError("VALIDATION_ERROR", "Target is invalid");
}
if (parsed.username || parsed.password) {
throw new AppError(
"VALIDATION_ERROR",
"Page URLs with embedded credentials are not supported",
);
}
const hasMeaningfulPath = parsed.pathname !== "/";
const requestedScope = options.scope;
if (requestedScope === "domain") {
return {
apiTarget: domainHostname,
displayTarget: domainHostname,
scope: "domain",
};
}
if (parsed.search || parsed.hash) {
throw new AppError(
"VALIDATION_ERROR",
"Page URLs with query strings or fragments are not supported",
);
}
if (requestedScope === "page") {
const normalizedUrl = new URL(parsed.toString());
normalizedUrl.hostname = exactHostname;
if (!hasExplicitProtocol && !hasMeaningfulPath) {
normalizedUrl.pathname = "/";
}
return {
apiTarget: normalizedUrl.toString(),
displayTarget: normalizedUrl.toString(),
scope: "page",
};
}
if (hasExplicitProtocol || hasMeaningfulPath) {
const normalizedUrl = new URL(parsed.toString());
normalizedUrl.hostname = exactHostname;
return {
apiTarget: normalizedUrl.toString(),
displayTarget: normalizedUrl.toString(),
scope: "page",
};
}
return {
apiTarget: domainHostname,
displayTarget: domainHostname,
scope: "domain",
};
}

View File

@ -1,7 +1,7 @@
import { env } from "cloudflare:workers";
import { sortBy } from "remeda";
import { z } from "zod";
import { jsonCodec } from "@/shared/json";
import { getWorkersBinding } from "@/server/lib/runtime-env";
/**
* Cache TTL constants in seconds.
@ -32,7 +32,8 @@ export function buildCacheKey(
* Get a cached JSON value from KV. Returns null on miss.
*/
export async function getCached(key: string): Promise<unknown> {
const value = await env.KV.get(key, "text");
const kv = await getKvNamespace();
const value = await kv.get(key, "text");
if (value === null) return null;
const parsed = jsonUnknownCodec.safeParse(value);
return parsed.success ? parsed.data : null;
@ -46,11 +47,32 @@ export async function setCached<T>(
data: T,
ttlSeconds: number,
): Promise<void> {
await env.KV.put(key, JSON.stringify(data), {
const kv = await getKvNamespace();
await kv.put(key, JSON.stringify(data), {
expirationTtl: ttlSeconds,
});
}
async function getKvNamespace(): Promise<KVNamespace> {
const binding = await getWorkersBinding("KV");
if (isKvNamespace(binding)) {
return binding;
}
throw new Error("KV binding is not configured correctly");
}
function isKvNamespace(value: unknown): value is KVNamespace {
return (
typeof value === "object" &&
value !== null &&
"get" in value &&
typeof value.get === "function" &&
"put" in value &&
typeof value.put === "function"
);
}
/**
* FNV-1a hash fast, good distribution for cache keys.
*/

View File

@ -0,0 +1,50 @@
let workersEnvPromise: Promise<Record<string, unknown> | null> | null = null;
async function getEnvValue(name: string): Promise<string | undefined> {
const processValue =
typeof process !== "undefined" ? process.env?.[name] : undefined;
if (processValue) {
return processValue;
}
const workersEnv = await getWorkersEnv();
const workerValue = workersEnv?.[name];
return typeof workerValue === "string" ? workerValue : undefined;
}
export async function getRequiredEnvValue(name: string): Promise<string> {
const value = await getEnvValue(name);
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export async function getWorkersBinding(name: string): Promise<unknown> {
const workersEnv = await getWorkersEnv();
const binding = workersEnv?.[name];
if (!binding) {
throw new Error(`Missing required Worker binding: ${name}`);
}
return binding;
}
async function getWorkersEnv(): Promise<Record<string, unknown> | null> {
if (!workersEnvPromise) {
workersEnvPromise = loadWorkersEnv();
}
return workersEnvPromise;
}
async function loadWorkersEnv(): Promise<Record<string, unknown> | null> {
try {
const workersModule = await import("cloudflare:workers");
return isRecord(workersModule.env) ? workersModule.env : null;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View File

@ -0,0 +1,88 @@
import { createServerFn } from "@tanstack/react-start";
import {
buildBacklinksDisabledAccessStatus,
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
import { assertBacklinksProjectAccess } from "@/server/features/backlinks/backlinksProjectAccess";
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
import { AppError } from "@/server/lib/errors";
import { backlinksOverviewInputSchema } from "@/types/schemas/backlinks";
export const getBacklinksOverview = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
await assertBacklinksProjectAccess(context.userId, data.projectId);
try {
return await BacklinksService.getOverview({
target: data.target,
scope: data.scope,
includeSubdomains: data.includeSubdomains,
includeIndirectLinks: data.includeIndirectLinks,
excludeInternalBacklinks: data.excludeInternalBacklinks,
status: data.status,
});
} catch (error) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
const checkedAt = new Date().toISOString();
await setBacklinksAccessStatus(
buildBacklinksDisabledAccessStatus(checkedAt, error.code),
);
}
throw error;
}
});
export const getBacklinksReferringDomains = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
await assertBacklinksProjectAccess(context.userId, data.projectId);
try {
return await BacklinksService.getReferringDomains({
target: data.target,
scope: data.scope,
includeSubdomains: data.includeSubdomains,
includeIndirectLinks: data.includeIndirectLinks,
excludeInternalBacklinks: data.excludeInternalBacklinks,
status: data.status,
});
} catch (error) {
await updateBacklinksAccessStatusOnError(error);
throw error;
}
});
export const getBacklinksTopPages = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
await assertBacklinksProjectAccess(context.userId, data.projectId);
try {
return await BacklinksService.getTopPages({
target: data.target,
scope: data.scope,
includeSubdomains: data.includeSubdomains,
includeIndirectLinks: data.includeIndirectLinks,
excludeInternalBacklinks: data.excludeInternalBacklinks,
status: data.status,
});
} catch (error) {
await updateBacklinksAccessStatusOnError(error);
throw error;
}
});
async function updateBacklinksAccessStatusOnError(error: unknown) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
const checkedAt = new Date().toISOString();
await setBacklinksAccessStatus(
buildBacklinksDisabledAccessStatus(checkedAt, error.code),
);
}
}

View File

@ -0,0 +1,76 @@
import { createServerFn } from "@tanstack/react-start";
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import {
buildBacklinksDisabledAccessStatus,
buildVerifiedBacklinksAccessStatus,
getBacklinksAccessStatus,
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
import { assertBacklinksProjectAccess } from "@/server/features/backlinks/backlinksProjectAccess";
import { AppError } from "@/server/lib/errors";
import { fetchBacklinksSummaryRaw } from "@/server/lib/dataforseoBacklinks";
import { backlinksProjectSchema } from "@/types/schemas/backlinks";
const BACKLINKS_ACCESS_CHECK_COOLDOWN_MS = 15 * 60 * 1000;
export const getBacklinksAccessSetupStatus = createServerFn({ method: "GET" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
.handler(async ({ data, context }) => {
await assertBacklinksProjectAccess(context.userId, data.projectId);
return getBacklinksAccessStatus();
});
export const testBacklinksAccess = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
.handler(async ({ data, context }) => {
await assertBacklinksProjectAccess(context.userId, data.projectId);
const cachedStatus = await getBacklinksAccessStatus();
if (isRecentVerifiedBacklinksAccessCheck(cachedStatus)) {
return cachedStatus;
}
const checkedAt = new Date().toISOString();
try {
await fetchBacklinksSummaryRaw({
target: "dataforseo.com",
includeSubdomains: true,
includeIndirectLinks: true,
excludeInternalBacklinks: true,
status: "live",
});
const status = buildVerifiedBacklinksAccessStatus(checkedAt);
await setBacklinksAccessStatus(status);
return status;
} catch (error) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
const status = buildBacklinksDisabledAccessStatus(
checkedAt,
error.code,
);
await setBacklinksAccessStatus(status);
return status;
}
throw error;
}
});
function isRecentVerifiedBacklinksAccessCheck(
status: Awaited<ReturnType<typeof getBacklinksAccessStatus>>,
) {
if (!status.enabled || !status.lastCheckedAt) {
return false;
}
const lastChecked = Date.parse(status.lastCheckedAt);
if (Number.isNaN(lastChecked)) {
return false;
}
return Date.now() - lastChecked < BACKLINKS_ACCESS_CHECK_COOLDOWN_MS;
}

View File

@ -8,6 +8,8 @@ const ERROR_CODES = [
"AUDIT_CAPACITY_REACHED",
"VALIDATION_ERROR",
"CRAWL_TARGET_BLOCKED",
"BACKLINKS_NOT_ENABLED",
"BACKLINKS_BILLING_ISSUE",
"RATE_LIMITED",
"CONFLICT",
"INTERNAL_ERROR",

View File

@ -0,0 +1,40 @@
import { z } from "zod";
export const backlinksStatusSchema = z.enum(["live", "lost", "all"]);
export const backlinksTabSchema = z.enum(["backlinks", "domains", "pages"]);
export const backlinksTargetScopeSchema = z.enum(["domain", "page"]);
const booleanSearchParamSchema = z
.union([z.boolean(), z.enum(["true", "false"])])
.transform((value) => value === true || value === "true");
export const backlinksLookupSchema = z.object({
target: z.string().min(1, "Target is required").max(2048),
scope: backlinksTargetScopeSchema.optional(),
includeSubdomains: z.boolean().default(true),
includeIndirectLinks: z.boolean().default(true),
excludeInternalBacklinks: z.boolean().default(true),
status: backlinksStatusSchema.default("live"),
});
export const backlinksProjectSchema = z.object({
projectId: z.string().min(1),
});
export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({
projectId: z.string().min(1),
});
export const backlinksSearchSchema = z.object({
target: z.string().optional(),
scope: backlinksTargetScopeSchema.optional(),
subdomains: booleanSearchParamSchema.optional(),
indirect: booleanSearchParamSchema.optional(),
excludeInternal: booleanSearchParamSchema.optional(),
status: backlinksStatusSchema.optional(),
tab: backlinksTabSchema.optional(),
});
export type BacklinksLookupInput = z.infer<typeof backlinksLookupSchema>;
export type BacklinksStatus = z.infer<typeof backlinksStatusSchema>;
export type BacklinksTab = z.infer<typeof backlinksTabSchema>;
export type BacklinksTargetScope = z.infer<typeof backlinksTargetScopeSchema>;

View File

@ -1,5 +1,9 @@
import { z } from "zod";
const booleanSearchParamSchema = z
.union([z.boolean(), z.enum(["true", "false"])])
.transform((value) => value === true || value === "true");
export const domainOverviewSchema = z.object({
domain: z.string().min(1, "Domain is required").max(255),
includeSubdomains: z.boolean().default(true),
@ -17,7 +21,7 @@ const domainTabs = ["keywords", "pages"] as const;
export const domainSearchSchema = z.object({
domain: z.string().optional(),
subdomains: z.coerce.boolean().optional(),
subdomains: booleanSearchParamSchema.optional(),
sort: z.enum(domainSortModes).optional(),
order: z.enum(domainSortOrders).optional(),
tab: z.enum(domainTabs).optional(),

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { backlinksSearchSchema } from "@/types/schemas/backlinks";
import { domainSearchSchema } from "@/types/schemas/domain";
describe("search param boolean parsing", () => {
it("parses explicit false values for backlinks search params", () => {
const parsed = backlinksSearchSchema.parse({
subdomains: "false",
indirect: "false",
excludeInternal: "false",
});
expect(parsed).toEqual({
subdomains: false,
indirect: false,
excludeInternal: false,
});
});
it("parses explicit false values for domain search params", () => {
const parsed = domainSearchSchema.parse({
subdomains: "false",
});
expect(parsed).toEqual({
subdomains: false,
});
});
});