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. - Find topics worth targeting, estimate demand, and prioritize what to write next.
- Domain insights - Domain insights
- Understand where your domain is gaining or losing visibility so you can focus on the pages that move revenue. - 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 - Site Audits
- Catch technical issues early so your site is easier for search engines to crawl and rank. - 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: Top priorities:
- Backlinks
- Rank tracking - Rank tracking
- AI content workflows - 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. 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). 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`). 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`: 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 ### Pricing sources
- DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api - 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 - Google PageSpeed Insights API docs: https://developers.google.com/speed/docs/insights/v5/get-started
### 1) Site audit ### 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: - General formula if needed:
- `0.0201 + (0.0001 x ranked_keywords_returned)` USD - `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 ### Planning examples
- 100 keyword research requests at the default 150 results: `$3.50` - 100 keyword research requests at the default 150 results: `$3.50`
- 100 keyword research requests at 500 results each: `$7.00` - 100 keyword research requests at 500 results each: `$7.00`
- 100 domain overviews (200 ranked keywords each): `$4.01` - 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": { "scripts": {
"dev": "AUTH_MODE=local_noauth vite dev", "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": "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", "build": "vite build && tsc --noEmit",
"lint": "oxlint . --type-aware", "lint": "oxlint . --type-aware",
"lint:fix": "oxlint . --type-aware --fix", "lint:fix": "oxlint . --type-aware --fix",
@ -24,6 +25,7 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:ci": "vitest run --reporter=dot", "test:ci": "vitest run --reporter=dot",
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware" "ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
}, },
"cloudflare": { "cloudflare": {
@ -87,6 +89,7 @@
"oxlint-tsgolint": "^0.15.0", "oxlint-tsgolint": "^0.15.0",
"portless": "^0.5.2", "portless": "^0.5.2",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.1.2", "vite": "^7.1.2",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",

3
pnpm-lock.yaml generated
View File

@ -135,6 +135,9 @@ importers:
prettier: prettier:
specifier: ^3.6.2 specifier: ^3.6.2
version: 3.8.1 version: 3.8.1
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 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.", "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.", VALIDATION_ERROR: "Please check your input and try again.",
CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.", 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.", RATE_LIMITED: "Too many requests. Please wait and try again.",
CONFLICT: "This request conflicts with existing data.", CONFLICT: "This request conflicts with existing data.",
INTERNAL_ERROR: 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 = [ export const projectNavItems = [
{ {
@ -19,6 +26,12 @@ export const projectNavItems = [
icon: Globe, icon: Globe,
matchSegment: "/domain", matchSegment: "/domain",
}, },
{
to: "/p/$projectId/backlinks" as const,
label: "Backlinks",
icon: Link2,
matchSegment: "/backlinks",
},
{ {
to: "/p/$projectId/audit" as const, to: "/p/$projectId/audit" as const,
label: "Site Audit", 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 PProjectIdSavedRouteImport } from './routes/p/$projectId/saved'
import { Route as PProjectIdKeywordsRouteImport } from './routes/p/$projectId/keywords' import { Route as PProjectIdKeywordsRouteImport } from './routes/p/$projectId/keywords'
import { Route as PProjectIdDomainRouteImport } from './routes/p/$projectId/domain' 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 PProjectIdAuditRouteImport } from './routes/p/$projectId/audit'
import { Route as PProjectIdAiRouteImport } from './routes/p/$projectId/ai' import { Route as PProjectIdAiRouteImport } from './routes/p/$projectId/ai'
import { Route as PProjectIdAuditIndexRouteImport } from './routes/p/$projectId/audit/index' import { Route as PProjectIdAuditIndexRouteImport } from './routes/p/$projectId/audit/index'
@ -57,6 +58,11 @@ const PProjectIdDomainRoute = PProjectIdDomainRouteImport.update({
path: '/domain', path: '/domain',
getParentRoute: () => PProjectIdRouteRoute, getParentRoute: () => PProjectIdRouteRoute,
} as any) } as any)
const PProjectIdBacklinksRoute = PProjectIdBacklinksRouteImport.update({
id: '/backlinks',
path: '/backlinks',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const PProjectIdAuditRoute = PProjectIdAuditRouteImport.update({ const PProjectIdAuditRoute = PProjectIdAuditRouteImport.update({
id: '/audit', id: '/audit',
path: '/audit', path: '/audit',
@ -91,6 +97,7 @@ export interface FileRoutesByFullPath {
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
'/p/$projectId/ai': typeof PProjectIdAiRoute '/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren '/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
'/p/$projectId/domain': typeof PProjectIdDomainRoute '/p/$projectId/domain': typeof PProjectIdDomainRoute
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute '/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
'/p/$projectId/saved': typeof PProjectIdSavedRoute '/p/$projectId/saved': typeof PProjectIdSavedRoute
@ -103,6 +110,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
'/p/$projectId/ai': typeof PProjectIdAiRoute '/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
'/p/$projectId/domain': typeof PProjectIdDomainRoute '/p/$projectId/domain': typeof PProjectIdDomainRoute
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute '/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
'/p/$projectId/saved': typeof PProjectIdSavedRoute '/p/$projectId/saved': typeof PProjectIdSavedRoute
@ -118,6 +126,7 @@ export interface FileRoutesById {
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
'/p/$projectId/ai': typeof PProjectIdAiRoute '/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren '/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
'/p/$projectId/domain': typeof PProjectIdDomainRoute '/p/$projectId/domain': typeof PProjectIdDomainRoute
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute '/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
'/p/$projectId/saved': typeof PProjectIdSavedRoute '/p/$projectId/saved': typeof PProjectIdSavedRoute
@ -134,6 +143,7 @@ export interface FileRouteTypes {
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/p/$projectId/ai' | '/p/$projectId/ai'
| '/p/$projectId/audit' | '/p/$projectId/audit'
| '/p/$projectId/backlinks'
| '/p/$projectId/domain' | '/p/$projectId/domain'
| '/p/$projectId/keywords' | '/p/$projectId/keywords'
| '/p/$projectId/saved' | '/p/$projectId/saved'
@ -146,6 +156,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/p/$projectId/ai' | '/p/$projectId/ai'
| '/p/$projectId/backlinks'
| '/p/$projectId/domain' | '/p/$projectId/domain'
| '/p/$projectId/keywords' | '/p/$projectId/keywords'
| '/p/$projectId/saved' | '/p/$projectId/saved'
@ -160,6 +171,7 @@ export interface FileRouteTypes {
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/p/$projectId/ai' | '/p/$projectId/ai'
| '/p/$projectId/audit' | '/p/$projectId/audit'
| '/p/$projectId/backlinks'
| '/p/$projectId/domain' | '/p/$projectId/domain'
| '/p/$projectId/keywords' | '/p/$projectId/keywords'
| '/p/$projectId/saved' | '/p/$projectId/saved'
@ -226,6 +238,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PProjectIdDomainRouteImport preLoaderRoute: typeof PProjectIdDomainRouteImport
parentRoute: typeof PProjectIdRouteRoute 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': { '/p/$projectId/audit': {
id: '/p/$projectId/audit' id: '/p/$projectId/audit'
path: '/audit' path: '/audit'
@ -281,6 +300,7 @@ const PProjectIdAuditRouteWithChildren = PProjectIdAuditRoute._addFileChildren(
interface PProjectIdRouteRouteChildren { interface PProjectIdRouteRouteChildren {
PProjectIdAiRoute: typeof PProjectIdAiRoute PProjectIdAiRoute: typeof PProjectIdAiRoute
PProjectIdAuditRoute: typeof PProjectIdAuditRouteWithChildren PProjectIdAuditRoute: typeof PProjectIdAuditRouteWithChildren
PProjectIdBacklinksRoute: typeof PProjectIdBacklinksRoute
PProjectIdDomainRoute: typeof PProjectIdDomainRoute PProjectIdDomainRoute: typeof PProjectIdDomainRoute
PProjectIdKeywordsRoute: typeof PProjectIdKeywordsRoute PProjectIdKeywordsRoute: typeof PProjectIdKeywordsRoute
PProjectIdSavedRoute: typeof PProjectIdSavedRoute PProjectIdSavedRoute: typeof PProjectIdSavedRoute
@ -291,6 +311,7 @@ interface PProjectIdRouteRouteChildren {
const PProjectIdRouteRouteChildren: PProjectIdRouteRouteChildren = { const PProjectIdRouteRouteChildren: PProjectIdRouteRouteChildren = {
PProjectIdAiRoute: PProjectIdAiRoute, PProjectIdAiRoute: PProjectIdAiRoute,
PProjectIdAuditRoute: PProjectIdAuditRouteWithChildren, PProjectIdAuditRoute: PProjectIdAuditRouteWithChildren,
PProjectIdBacklinksRoute: PProjectIdBacklinksRoute,
PProjectIdDomainRoute: PProjectIdDomainRoute, PProjectIdDomainRoute: PProjectIdDomainRoute,
PProjectIdKeywordsRoute: PProjectIdKeywordsRoute, PProjectIdKeywordsRoute: PProjectIdKeywordsRoute,
PProjectIdSavedRoute: PProjectIdSavedRoute, 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 { sortBy } from "remeda";
import { z } from "zod"; import { z } from "zod";
import { jsonCodec } from "@/shared/json"; import { jsonCodec } from "@/shared/json";
import { getWorkersBinding } from "@/server/lib/runtime-env";
/** /**
* Cache TTL constants in seconds. * Cache TTL constants in seconds.
@ -32,7 +32,8 @@ export function buildCacheKey(
* Get a cached JSON value from KV. Returns null on miss. * Get a cached JSON value from KV. Returns null on miss.
*/ */
export async function getCached(key: string): Promise<unknown> { 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; if (value === null) return null;
const parsed = jsonUnknownCodec.safeParse(value); const parsed = jsonUnknownCodec.safeParse(value);
return parsed.success ? parsed.data : null; return parsed.success ? parsed.data : null;
@ -46,11 +47,32 @@ export async function setCached<T>(
data: T, data: T,
ttlSeconds: number, ttlSeconds: number,
): Promise<void> { ): Promise<void> {
await env.KV.put(key, JSON.stringify(data), { const kv = await getKvNamespace();
await kv.put(key, JSON.stringify(data), {
expirationTtl: ttlSeconds, 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. * 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", "AUDIT_CAPACITY_REACHED",
"VALIDATION_ERROR", "VALIDATION_ERROR",
"CRAWL_TARGET_BLOCKED", "CRAWL_TARGET_BLOCKED",
"BACKLINKS_NOT_ENABLED",
"BACKLINKS_BILLING_ISSUE",
"RATE_LIMITED", "RATE_LIMITED",
"CONFLICT", "CONFLICT",
"INTERNAL_ERROR", "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"; 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({ export const domainOverviewSchema = z.object({
domain: z.string().min(1, "Domain is required").max(255), domain: z.string().min(1, "Domain is required").max(255),
includeSubdomains: z.boolean().default(true), includeSubdomains: z.boolean().default(true),
@ -17,7 +21,7 @@ const domainTabs = ["keywords", "pages"] as const;
export const domainSearchSchema = z.object({ export const domainSearchSchema = z.object({
domain: z.string().optional(), domain: z.string().optional(),
subdomains: z.coerce.boolean().optional(), subdomains: booleanSearchParamSchema.optional(),
sort: z.enum(domainSortModes).optional(), sort: z.enum(domainSortModes).optional(),
order: z.enum(domainSortOrders).optional(), order: z.enum(domainSortOrders).optional(),
tab: z.enum(domainTabs).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,
});
});
});