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