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:
parent
b859fae8dc
commit
8405564925
16
README.md
16
README.md
@ -32,6 +32,8 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
|
||||
- Find topics worth targeting, estimate demand, and prioritize what to write next.
|
||||
- Domain insights
|
||||
- Understand where your domain is gaining or losing visibility so you can focus on the pages that move revenue.
|
||||
- Backlinks
|
||||
- See who links to your site, which pages attract links, and where links are newly won or lost.
|
||||
- Site Audits
|
||||
- Catch technical issues early so your site is easier for search engines to crawl and rank.
|
||||
|
||||
@ -39,7 +41,6 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
|
||||
|
||||
Top priorities:
|
||||
|
||||
- Backlinks
|
||||
- Rank tracking
|
||||
- AI content workflows
|
||||
|
||||
@ -70,6 +71,8 @@ For cost estimates, see [DataForSEO API Cost Reference](#seo-api-cost-reference)
|
||||
|
||||
OpenSEO uses DataForSEO to fetch SEO data. You need an API key to connect OpenSEO to the service.
|
||||
|
||||
Backlinks requires one more step beyond the API key: you also need DataForSEO Backlinks enabled on your account (trial or paid subscription), then confirm access from the Backlinks page in OpenSEO.
|
||||
|
||||
1. Go to [DataForSEO API Access](https://app.dataforseo.com/api-access).
|
||||
2. Request API credentials by email (`API key by email` or `API password by email`).
|
||||
3. Use your DataForSEO login + API password, then base64 encode `login:password`:
|
||||
@ -246,6 +249,7 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
|
||||
### Pricing sources
|
||||
|
||||
- DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
|
||||
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
|
||||
- Google PageSpeed Insights API docs: https://developers.google.com/speed/docs/insights/v5/get-started
|
||||
|
||||
### 1) Site audit
|
||||
@ -268,8 +272,18 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
|
||||
- General formula if needed:
|
||||
- `0.0201 + (0.0001 x ranked_keywords_returned)` USD
|
||||
|
||||
### 4) Backlinks search
|
||||
|
||||
- Backlinks search costs about `$0.08` for a domain or `$0.04` for a page.
|
||||
- Opening extra tabs like `Referring Domains` or `Top Pages` adds about `+$0.02` each.
|
||||
- Exact cost can vary slightly based on returned rows and DataForSEO pricing.
|
||||
|
||||
### Planning examples
|
||||
|
||||
- 100 keyword research requests at the default 150 results: `$3.50`
|
||||
- 100 keyword research requests at 500 results each: `$7.00`
|
||||
- 100 domain overviews (200 ranked keywords each): `$4.01`
|
||||
- 100 backlinks domain searches at current defaults before opening extra tabs: about `$8.38`
|
||||
- 100 backlinks page searches at current defaults before opening extra tabs: about `$4.30`
|
||||
- 100 fully explored backlinks domain searches: about `$12.98`
|
||||
- 100 fully explored backlinks page searches: about `$8.61`
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"dev": "AUTH_MODE=local_noauth vite dev",
|
||||
"dev:agents": "mkdir -p .logs && AUTH_MODE=local_noauth portless run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"dev:agents:force": "mkdir -p .logs && AUTH_MODE=local_noauth portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"lint": "oxlint . --type-aware",
|
||||
"lint:fix": "oxlint . --type-aware --fix",
|
||||
@ -24,6 +25,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ci": "vitest run --reporter=dot",
|
||||
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
|
||||
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
|
||||
},
|
||||
"cloudflare": {
|
||||
@ -87,6 +89,7 @@
|
||||
"oxlint-tsgolint": "^0.15.0",
|
||||
"portless": "^0.5.2",
|
||||
"prettier": "^3.6.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.2",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -135,6 +135,9 @@ importers:
|
||||
prettier:
|
||||
specifier: ^3.6.2
|
||||
version: 3.8.1
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
195
scripts/backlinks-cost-profile.ts
Normal file
195
scripts/backlinks-cost-profile.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
92
src/client/features/backlinks/BacklinksPage.tsx
Normal file
92
src/client/features/backlinks/BacklinksPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
src/client/features/backlinks/BacklinksPageCharts.tsx
Normal file
140
src/client/features/backlinks/BacklinksPageCharts.tsx
Normal 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) : "";
|
||||
}
|
||||
220
src/client/features/backlinks/BacklinksPageContent.tsx
Normal file
220
src/client/features/backlinks/BacklinksPageContent.tsx
Normal 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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
53
src/client/features/backlinks/BacklinksPageLinks.tsx
Normal file
53
src/client/features/backlinks/BacklinksPageLinks.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
276
src/client/features/backlinks/BacklinksPageSections.tsx
Normal file
276
src/client/features/backlinks/BacklinksPageSections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/client/features/backlinks/BacklinksPageStates.test.ts
Normal file
19
src/client/features/backlinks/BacklinksPageStates.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
235
src/client/features/backlinks/BacklinksPageStates.tsx
Normal file
235
src/client/features/backlinks/BacklinksPageStates.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
src/client/features/backlinks/BacklinksPageTables.tsx
Normal file
232
src/client/features/backlinks/BacklinksPageTables.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
288
src/client/features/backlinks/BacklinksSearchCard.tsx
Normal file
288
src/client/features/backlinks/BacklinksSearchCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
src/client/features/backlinks/BacklinksTableHeaders.tsx
Normal file
254
src/client/features/backlinks/BacklinksTableHeaders.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/client/features/backlinks/backlinksPageTypes.ts
Normal file
45
src/client/features/backlinks/backlinksPageTypes.ts
Normal 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;
|
||||
};
|
||||
131
src/client/features/backlinks/backlinksPageUtils.ts
Normal file
131
src/client/features/backlinks/backlinksPageUtils.ts
Normal 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)}`;
|
||||
}
|
||||
59
src/client/features/backlinks/backlinksSearchScope.test.ts
Normal file
59
src/client/features/backlinks/backlinksSearchScope.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
46
src/client/features/backlinks/backlinksSearchScope.ts
Normal file
46
src/client/features/backlinks/backlinksSearchScope.ts
Normal 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;
|
||||
}
|
||||
217
src/client/features/backlinks/backlinksTableSorting.ts
Normal file
217
src/client/features/backlinks/backlinksTableSorting.ts
Normal 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);
|
||||
}
|
||||
236
src/client/features/backlinks/useBacklinksPageData.ts
Normal file
236
src/client/features/backlinks/useBacklinksPageData.ts
Normal 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;
|
||||
}
|
||||
@ -10,6 +10,10 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
|
||||
"You've reached audit capacity for your account. Delete old audits from your projects to start a new one.",
|
||||
VALIDATION_ERROR: "Please check your input and try again.",
|
||||
CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.",
|
||||
BACKLINKS_NOT_ENABLED:
|
||||
"Backlinks is not enabled for the connected DataForSEO account yet.",
|
||||
BACKLINKS_BILLING_ISSUE:
|
||||
"The connected DataForSEO account has a billing or balance issue.",
|
||||
RATE_LIMITED: "Too many requests. Please wait and try again.",
|
||||
CONFLICT: "This request conflicts with existing data.",
|
||||
INTERNAL_ERROR:
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { Bookmark, Bot, ClipboardCheck, Globe, Search } from "lucide-react";
|
||||
import {
|
||||
Bookmark,
|
||||
Bot,
|
||||
ClipboardCheck,
|
||||
Globe,
|
||||
Link2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
export const projectNavItems = [
|
||||
{
|
||||
@ -19,6 +26,12 @@ export const projectNavItems = [
|
||||
icon: Globe,
|
||||
matchSegment: "/domain",
|
||||
},
|
||||
{
|
||||
to: "/p/$projectId/backlinks" as const,
|
||||
label: "Backlinks",
|
||||
icon: Link2,
|
||||
matchSegment: "/backlinks",
|
||||
},
|
||||
{
|
||||
to: "/p/$projectId/audit" as const,
|
||||
label: "Site Audit",
|
||||
|
||||
@ -16,6 +16,7 @@ import { Route as PProjectIdIndexRouteImport } from './routes/p/$projectId/index
|
||||
import { Route as PProjectIdSavedRouteImport } from './routes/p/$projectId/saved'
|
||||
import { Route as PProjectIdKeywordsRouteImport } from './routes/p/$projectId/keywords'
|
||||
import { Route as PProjectIdDomainRouteImport } from './routes/p/$projectId/domain'
|
||||
import { Route as PProjectIdBacklinksRouteImport } from './routes/p/$projectId/backlinks'
|
||||
import { Route as PProjectIdAuditRouteImport } from './routes/p/$projectId/audit'
|
||||
import { Route as PProjectIdAiRouteImport } from './routes/p/$projectId/ai'
|
||||
import { Route as PProjectIdAuditIndexRouteImport } from './routes/p/$projectId/audit/index'
|
||||
@ -57,6 +58,11 @@ const PProjectIdDomainRoute = PProjectIdDomainRouteImport.update({
|
||||
path: '/domain',
|
||||
getParentRoute: () => PProjectIdRouteRoute,
|
||||
} as any)
|
||||
const PProjectIdBacklinksRoute = PProjectIdBacklinksRouteImport.update({
|
||||
id: '/backlinks',
|
||||
path: '/backlinks',
|
||||
getParentRoute: () => PProjectIdRouteRoute,
|
||||
} as any)
|
||||
const PProjectIdAuditRoute = PProjectIdAuditRouteImport.update({
|
||||
id: '/audit',
|
||||
path: '/audit',
|
||||
@ -91,6 +97,7 @@ export interface FileRoutesByFullPath {
|
||||
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
|
||||
'/p/$projectId/ai': typeof PProjectIdAiRoute
|
||||
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
|
||||
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
|
||||
'/p/$projectId/domain': typeof PProjectIdDomainRoute
|
||||
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
|
||||
'/p/$projectId/saved': typeof PProjectIdSavedRoute
|
||||
@ -103,6 +110,7 @@ export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
|
||||
'/p/$projectId/ai': typeof PProjectIdAiRoute
|
||||
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
|
||||
'/p/$projectId/domain': typeof PProjectIdDomainRoute
|
||||
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
|
||||
'/p/$projectId/saved': typeof PProjectIdSavedRoute
|
||||
@ -118,6 +126,7 @@ export interface FileRoutesById {
|
||||
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
|
||||
'/p/$projectId/ai': typeof PProjectIdAiRoute
|
||||
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
|
||||
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
|
||||
'/p/$projectId/domain': typeof PProjectIdDomainRoute
|
||||
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
|
||||
'/p/$projectId/saved': typeof PProjectIdSavedRoute
|
||||
@ -134,6 +143,7 @@ export interface FileRouteTypes {
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/domain'
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/saved'
|
||||
@ -146,6 +156,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/domain'
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/saved'
|
||||
@ -160,6 +171,7 @@ export interface FileRouteTypes {
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/domain'
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/saved'
|
||||
@ -226,6 +238,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PProjectIdDomainRouteImport
|
||||
parentRoute: typeof PProjectIdRouteRoute
|
||||
}
|
||||
'/p/$projectId/backlinks': {
|
||||
id: '/p/$projectId/backlinks'
|
||||
path: '/backlinks'
|
||||
fullPath: '/p/$projectId/backlinks'
|
||||
preLoaderRoute: typeof PProjectIdBacklinksRouteImport
|
||||
parentRoute: typeof PProjectIdRouteRoute
|
||||
}
|
||||
'/p/$projectId/audit': {
|
||||
id: '/p/$projectId/audit'
|
||||
path: '/audit'
|
||||
@ -281,6 +300,7 @@ const PProjectIdAuditRouteWithChildren = PProjectIdAuditRoute._addFileChildren(
|
||||
interface PProjectIdRouteRouteChildren {
|
||||
PProjectIdAiRoute: typeof PProjectIdAiRoute
|
||||
PProjectIdAuditRoute: typeof PProjectIdAuditRouteWithChildren
|
||||
PProjectIdBacklinksRoute: typeof PProjectIdBacklinksRoute
|
||||
PProjectIdDomainRoute: typeof PProjectIdDomainRoute
|
||||
PProjectIdKeywordsRoute: typeof PProjectIdKeywordsRoute
|
||||
PProjectIdSavedRoute: typeof PProjectIdSavedRoute
|
||||
@ -291,6 +311,7 @@ interface PProjectIdRouteRouteChildren {
|
||||
const PProjectIdRouteRouteChildren: PProjectIdRouteRouteChildren = {
|
||||
PProjectIdAiRoute: PProjectIdAiRoute,
|
||||
PProjectIdAuditRoute: PProjectIdAuditRouteWithChildren,
|
||||
PProjectIdBacklinksRoute: PProjectIdBacklinksRoute,
|
||||
PProjectIdDomainRoute: PProjectIdDomainRoute,
|
||||
PProjectIdKeywordsRoute: PProjectIdKeywordsRoute,
|
||||
PProjectIdSavedRoute: PProjectIdSavedRoute,
|
||||
|
||||
40
src/routes/p/$projectId/backlinks.tsx
Normal file
40
src/routes/p/$projectId/backlinks.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
src/server/features/backlinks/backlinksAccess.test.ts
Normal file
37
src/server/features/backlinks/backlinksAccess.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
107
src/server/features/backlinks/backlinksAccess.ts
Normal file
107
src/server/features/backlinks/backlinksAccess.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
47
src/server/features/backlinks/backlinksProjectAccess.test.ts
Normal file
47
src/server/features/backlinks/backlinksProjectAccess.test.ts
Normal 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>);
|
||||
});
|
||||
});
|
||||
14
src/server/features/backlinks/backlinksProjectAccess.ts
Normal file
14
src/server/features/backlinks/backlinksProjectAccess.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
98
src/server/features/backlinks/services/BacklinksService.ts
Normal file
98
src/server/features/backlinks/services/BacklinksService.ts
Normal 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 };
|
||||
50
src/server/features/backlinks/services/backlinksCost.ts
Normal file
50
src/server/features/backlinks/services/backlinksCost.ts
Normal 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;
|
||||
}
|
||||
@ -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>;
|
||||
367
src/server/features/backlinks/services/backlinksServiceData.ts
Normal file
367
src/server/features/backlinks/services/backlinksServiceData.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
174
src/server/lib/dataforseoBacklinks.test.ts
Normal file
174
src/server/lib/dataforseoBacklinks.test.ts
Normal 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");
|
||||
}
|
||||
315
src/server/lib/dataforseoBacklinks.ts
Normal file
315
src/server/lib/dataforseoBacklinks.ts
Normal 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>;
|
||||
}
|
||||
154
src/server/lib/dataforseoBacklinksAccount.ts
Normal file
154
src/server/lib/dataforseoBacklinksAccount.ts
Normal 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;
|
||||
}
|
||||
262
src/server/lib/dataforseoBacklinksSupport.ts
Normal file
262
src/server/lib/dataforseoBacklinksSupport.ts
Normal 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;
|
||||
}
|
||||
92
src/server/lib/dataforseoBacklinksTarget.ts
Normal file
92
src/server/lib/dataforseoBacklinksTarget.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { sortBy } from "remeda";
|
||||
import { z } from "zod";
|
||||
import { jsonCodec } from "@/shared/json";
|
||||
import { getWorkersBinding } from "@/server/lib/runtime-env";
|
||||
|
||||
/**
|
||||
* Cache TTL constants in seconds.
|
||||
@ -32,7 +32,8 @@ export function buildCacheKey(
|
||||
* Get a cached JSON value from KV. Returns null on miss.
|
||||
*/
|
||||
export async function getCached(key: string): Promise<unknown> {
|
||||
const value = await env.KV.get(key, "text");
|
||||
const kv = await getKvNamespace();
|
||||
const value = await kv.get(key, "text");
|
||||
if (value === null) return null;
|
||||
const parsed = jsonUnknownCodec.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
@ -46,11 +47,32 @@ export async function setCached<T>(
|
||||
data: T,
|
||||
ttlSeconds: number,
|
||||
): Promise<void> {
|
||||
await env.KV.put(key, JSON.stringify(data), {
|
||||
const kv = await getKvNamespace();
|
||||
await kv.put(key, JSON.stringify(data), {
|
||||
expirationTtl: ttlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
async function getKvNamespace(): Promise<KVNamespace> {
|
||||
const binding = await getWorkersBinding("KV");
|
||||
if (isKvNamespace(binding)) {
|
||||
return binding;
|
||||
}
|
||||
|
||||
throw new Error("KV binding is not configured correctly");
|
||||
}
|
||||
|
||||
function isKvNamespace(value: unknown): value is KVNamespace {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"get" in value &&
|
||||
typeof value.get === "function" &&
|
||||
"put" in value &&
|
||||
typeof value.put === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FNV-1a hash — fast, good distribution for cache keys.
|
||||
*/
|
||||
|
||||
50
src/server/lib/runtime-env.ts
Normal file
50
src/server/lib/runtime-env.ts
Normal 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;
|
||||
}
|
||||
88
src/serverFunctions/backlinks.ts
Normal file
88
src/serverFunctions/backlinks.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
src/serverFunctions/backlinksAccess.ts
Normal file
76
src/serverFunctions/backlinksAccess.ts
Normal 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;
|
||||
}
|
||||
@ -8,6 +8,8 @@ const ERROR_CODES = [
|
||||
"AUDIT_CAPACITY_REACHED",
|
||||
"VALIDATION_ERROR",
|
||||
"CRAWL_TARGET_BLOCKED",
|
||||
"BACKLINKS_NOT_ENABLED",
|
||||
"BACKLINKS_BILLING_ISSUE",
|
||||
"RATE_LIMITED",
|
||||
"CONFLICT",
|
||||
"INTERNAL_ERROR",
|
||||
|
||||
40
src/types/schemas/backlinks.ts
Normal file
40
src/types/schemas/backlinks.ts
Normal 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>;
|
||||
@ -1,5 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const booleanSearchParamSchema = z
|
||||
.union([z.boolean(), z.enum(["true", "false"])])
|
||||
.transform((value) => value === true || value === "true");
|
||||
|
||||
export const domainOverviewSchema = z.object({
|
||||
domain: z.string().min(1, "Domain is required").max(255),
|
||||
includeSubdomains: z.boolean().default(true),
|
||||
@ -17,7 +21,7 @@ const domainTabs = ["keywords", "pages"] as const;
|
||||
|
||||
export const domainSearchSchema = z.object({
|
||||
domain: z.string().optional(),
|
||||
subdomains: z.coerce.boolean().optional(),
|
||||
subdomains: booleanSearchParamSchema.optional(),
|
||||
sort: z.enum(domainSortModes).optional(),
|
||||
order: z.enum(domainSortOrders).optional(),
|
||||
tab: z.enum(domainTabs).optional(),
|
||||
|
||||
29
src/types/schemas/search-params.test.ts
Normal file
29
src/types/schemas/search-params.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user