diff --git a/README.md b/README.md index 55b11fa..5a1016b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too - Find topics worth targeting, estimate demand, and prioritize what to write next. - Domain insights - Understand where your domain is gaining or losing visibility so you can focus on the pages that move revenue. +- Backlinks + - See who links to your site, which pages attract links, and where links are newly won or lost. - Site Audits - Catch technical issues early so your site is easier for search engines to crawl and rank. @@ -39,7 +41,6 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too Top priorities: -- Backlinks - Rank tracking - AI content workflows @@ -70,6 +71,8 @@ For cost estimates, see [DataForSEO API Cost Reference](#seo-api-cost-reference) OpenSEO uses DataForSEO to fetch SEO data. You need an API key to connect OpenSEO to the service. +Backlinks requires one more step beyond the API key: you also need DataForSEO Backlinks enabled on your account (trial or paid subscription), then confirm access from the Backlinks page in OpenSEO. + 1. Go to [DataForSEO API Access](https://app.dataforseo.com/api-access). 2. Request API credentials by email (`API key by email` or `API password by email`). 3. Use your DataForSEO login + API password, then base64 encode `login:password`: @@ -246,6 +249,7 @@ That means you can try OpenSEO for free with the starter credit, then decide if/ ### Pricing sources - DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api +- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks - Google PageSpeed Insights API docs: https://developers.google.com/speed/docs/insights/v5/get-started ### 1) Site audit @@ -268,8 +272,18 @@ That means you can try OpenSEO for free with the starter credit, then decide if/ - General formula if needed: - `0.0201 + (0.0001 x ranked_keywords_returned)` USD +### 4) Backlinks search + +- Backlinks search costs about `$0.08` for a domain or `$0.04` for a page. +- Opening extra tabs like `Referring Domains` or `Top Pages` adds about `+$0.02` each. +- Exact cost can vary slightly based on returned rows and DataForSEO pricing. + ### Planning examples - 100 keyword research requests at the default 150 results: `$3.50` - 100 keyword research requests at 500 results each: `$7.00` - 100 domain overviews (200 ranked keywords each): `$4.01` +- 100 backlinks domain searches at current defaults before opening extra tabs: about `$8.38` +- 100 backlinks page searches at current defaults before opening extra tabs: about `$4.30` +- 100 fully explored backlinks domain searches: about `$12.98` +- 100 fully explored backlinks page searches: about `$8.61` diff --git a/package.json b/package.json index aef7d73..8c1c3db 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "AUTH_MODE=local_noauth vite dev", "dev:agents": "mkdir -p .logs && AUTH_MODE=local_noauth portless run vite dev 2>&1 | tee .logs/dev-server.log", + "dev:agents:force": "mkdir -p .logs && AUTH_MODE=local_noauth portless --force run vite dev 2>&1 | tee .logs/dev-server.log", "build": "vite build && tsc --noEmit", "lint": "oxlint . --type-aware", "lint:fix": "oxlint . --type-aware --fix", @@ -24,6 +25,7 @@ "test": "vitest run", "test:watch": "vitest", "test:ci": "vitest run --reporter=dot", + "billing:backlinks": "tsx scripts/backlinks-cost-profile.ts", "ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware" }, "cloudflare": { @@ -87,6 +89,7 @@ "oxlint-tsgolint": "^0.15.0", "portless": "^0.5.2", "prettier": "^3.6.2", + "tsx": "^4.21.0", "typescript": "^5.9.3", "vite": "^7.1.2", "vite-tsconfig-paths": "^5.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2dd7fd..057d8b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: prettier: specifier: ^3.6.2 version: 3.8.1 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 diff --git a/scripts/backlinks-cost-profile.ts b/scripts/backlinks-cost-profile.ts new file mode 100644 index 0000000..abc3c47 --- /dev/null +++ b/scripts/backlinks-cost-profile.ts @@ -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(); +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): 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 = {}; + + 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; + } +} diff --git a/src/client/features/backlinks/BacklinksPage.tsx b/src/client/features/backlinks/BacklinksPage.tsx new file mode 100644 index 0000000..2f68616 --- /dev/null +++ b/src/client/features/backlinks/BacklinksPage.tsx @@ -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 ( +
+
+
+

Backlinks

+

+ Understand who links to a site, what changed recently, and which + pages attract links. +

+
+ + {!accessStatusQuery.isLoading && + backlinksEnabled && + !backlinksDisabledByError ? ( + navigateToBacklinksSearch(navigate, values)} + /> + ) : null} + + void accessStatusQuery.refetch()} + onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)} + onRetryOverview={() => void overviewQuery.refetch()} + onTestAccess={() => testAccessMutation.mutate()} + /> +
+
+ ); +} diff --git a/src/client/features/backlinks/BacklinksPageCharts.tsx b/src/client/features/backlinks/BacklinksPageCharts.tsx new file mode 100644 index 0000000..892936e --- /dev/null +++ b/src/client/features/backlinks/BacklinksPageCharts.tsx @@ -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 ; + } + + return ( +
+ + + + + + + + + + + +
+ ); +} + +export function BacklinksNewLostChart({ + data, +}: { + data: BacklinksOverviewData["newLostTrends"]; +}) { + if (data.length === 0) { + return ; + } + + return ( +
+ + + + + + + + + + + +
+ ); +} + +function EmptyChartState() { + return ( +
+ Not enough historical data yet. +
+ ); +} + +function formatChartTick(value: unknown) { + return typeof value === "string" ? formatMonthLabel(value) : ""; +} + +function formatChartLabel(value: unknown) { + return typeof value === "string" ? formatFullDate(value) : ""; +} diff --git a/src/client/features/backlinks/BacklinksPageContent.tsx b/src/client/features/backlinks/BacklinksPageContent.tsx new file mode 100644 index 0000000..9bf1db2 --- /dev/null +++ b/src/client/features/backlinks/BacklinksPageContent.tsx @@ -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 ; + } + + if (accessStatusError) { + return ( + + ); + } + + if (!backlinksEnabled || backlinksDisabledByError) { + return ( + + ); + } + + return ( + + ); +} + +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 ; + } + + if (isLoading) { + return ; + } + + if (!mergedData) { + return ( + + ); + } + + return ( + <> + + + + ); +} + +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; + }), + }; +} diff --git a/src/client/features/backlinks/BacklinksPageEmptyTableState.tsx b/src/client/features/backlinks/BacklinksPageEmptyTableState.tsx new file mode 100644 index 0000000..fc194c3 --- /dev/null +++ b/src/client/features/backlinks/BacklinksPageEmptyTableState.tsx @@ -0,0 +1,7 @@ +export function EmptyTableState({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} diff --git a/src/client/features/backlinks/BacklinksPageLinks.tsx b/src/client/features/backlinks/BacklinksPageLinks.tsx new file mode 100644 index 0000000..dc8ad59 --- /dev/null +++ b/src/client/features/backlinks/BacklinksPageLinks.tsx @@ -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 {label}; + } + + return ( + + {label} + + + ); +} + +export function BacklinksSourceLink({ + url, + maxLength, + muted = false, +}: { + url: string; + maxLength: number; + muted?: boolean; +}) { + return ( + + ); +} + +function getSafeExternalUrl(value: string) { + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:" + ? parsed.toString() + : null; + } catch { + return null; + } +} diff --git a/src/client/features/backlinks/BacklinksPageSections.tsx b/src/client/features/backlinks/BacklinksPageSections.tsx new file mode 100644 index 0000000..528a430 --- /dev/null +++ b/src/client/features/backlinks/BacklinksPageSections.tsx @@ -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 ( + <> +
+ {data.scope} + Target: {data.displayTarget} + - + Updated {formatRelativeTimestamp(data.fetchedAt)} +
+ + {data.scope === "page" ? ( +
+ + Showing backlinks for this exact page. Enter a bare domain for + site-wide results. Trend charts are only shown for domain-level + lookups. + +
+ ) : 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 ( +
+
+ + {tabErrorMessage ? ( +
+ {tabErrorMessage} +
+ ) : null} + {activeTab === "backlinks" ? ( + + ) : null} + {activeTab === "domains" && isTabLoading && !tabErrorMessage ? ( + + ) : null} + {activeTab === "domains" && !isTabLoading && !tabErrorMessage ? ( + + ) : null} + {activeTab === "pages" && isTabLoading && !tabErrorMessage ? ( + + ) : null} + {activeTab === "pages" && !isTabLoading && !tabErrorMessage ? ( + + ) : null} +
+
+ ); +} + +function OverviewGrid({ + data, + summaryStats, +}: { + data: BacklinksOverviewData; + summaryStats: Array<{ label: string; value: string; description: string }>; +}) { + const domainScope = data.scope === "domain"; + + return ( +
+ + {domainScope ? : null} +
+ ); +} + +function ResultsHeader({ + activeTab, + filterText, + onFilterTextChange, + onSetActiveTab, +}: { + activeTab: BacklinksSearchState["tab"]; + filterText: string; + onFilterTextChange: (value: string) => void; + onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void; +}) { + return ( +
+
+
+ + Backlinks + + + Referring Domains + + + Top Pages + +
+

+ {TAB_DESCRIPTIONS[activeTab]} +

+
+ + +
+ ); +} + +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 ( +
+
+
+ {summaryStats.map((item) => ( +
+
+ +
+

{item.value}

+
+ ))} +
+
+
+ ); +} + +function TrendPanels({ data }: { data: BacklinksOverviewData }) { + return ( + <> + + + + + + + + ); +} + +function TrendCard({ + children, + description, + title, +}: { + children: React.ReactNode; + description: string; + title: string; +}) { + return ( +
+
+
+

{title}

+

{description}

+
+ {children} +
+
+ ); +} + +function TabButton({ + activeTab, + children, + onClick, + tab, +}: { + activeTab: BacklinksSearchState["tab"]; + children: string; + onClick: (tab: BacklinksSearchState["tab"]) => void; + tab: BacklinksSearchState["tab"]; +}) { + return ( + + ); +} + +function TabLoadingState({ label }: { label: string }) { + return ( +
+

{label}...

+
+
+
+
+ ); +} diff --git a/src/client/features/backlinks/BacklinksPageStates.test.ts b/src/client/features/backlinks/BacklinksPageStates.test.ts new file mode 100644 index 0000000..3e6fadd --- /dev/null +++ b/src/client/features/backlinks/BacklinksPageStates.test.ts @@ -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"); + }); +}); diff --git a/src/client/features/backlinks/BacklinksPageStates.tsx b/src/client/features/backlinks/BacklinksPageStates.tsx new file mode 100644 index 0000000..f4d9b2d --- /dev/null +++ b/src/client/features/backlinks/BacklinksPageStates.tsx @@ -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 ( +
+
+
+
+
+
+
+
+ ); +} + +export function BacklinksSetupGate({ + status, + isTesting, + testError, + onTest, +}: { + status: BacklinksAccessStatusData | undefined; + isTesting: boolean; + testError: string | null; + onTest: () => void; +}) { + return ( +
+
+
+
+ +
+
+

Enable Backlinks

+

+ Backlinks is not enabled for your DataForSEO account yet. Turn it + on in DataForSEO, then test access here. +

+

+ 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, if you're interested. +

+
+
+ +
+ + + Open DataForSEO Backlinks + +
+ + +
+
+ ); +} + +export function BacklinksEmptyState() { + return ( +
+
+ +
+
+

Start with a domain or page URL

+

+ 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. +

+
+
+ + + +
+
+ ); +} + +export function BacklinksLoadingState() { + return ( +
+
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+ ))} +
+
+ {Array.from({ length: 2 }).map((_, index) => ( +
+
+
+
+
+
+ ))} +
+
+
+
+
+
+
+
+ ); +} + +export function BacklinksErrorState({ + errorMessage, + onRetry, +}: { + errorMessage: string | null; + onRetry: () => void; +}) { + return ( +
+
+
+ +
+
+

Could not load backlinks

+

+ {errorMessage ?? "Please try again in a moment."} +

+
+
+ +
+ ); +} + +function BacklinksSetupFeedback({ + status, + testError, +}: { + status: BacklinksAccessStatusData | undefined; + testError: string | null; +}) { + return ( +
+ {status?.lastCheckedAt ? ( +
+ Last checked {formatRelativeTimestamp(status.lastCheckedAt)}. +
+ ) : null} + {status?.lastErrorMessage ? ( +
+ + {status.lastErrorMessage} +
+ ) : null} + {testError ? ( +
+ + {testError} +
+ ) : null} +
+ ); +} + +function BeginnerCard({ + icon: Icon, + title, + body, +}: { + icon: LucideIcon; + title: string; + body: string; +}) { + return ( +
+ +

{title}

+

{body}

+
+ ); +} + +function InlineMailingListLink() { + return ( + + join the openrank.io mailing list + + ); +} diff --git a/src/client/features/backlinks/BacklinksPageTables.tsx b/src/client/features/backlinks/BacklinksPageTables.tsx new file mode 100644 index 0000000..26b11bd --- /dev/null +++ b/src/client/features/backlinks/BacklinksPageTables.tsx @@ -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 ; + } + + return ( +
+ + + + {sortedRows.map((row, index) => ( + + ))} + +
+
+ ); +} + +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 ; + } + + return ( +
+ + + + {sortedRows.map((row, index) => ( + + + + + + + + + + ))} + +
{row.domain ?? "-"}{formatNumber(row.backlinks)}{formatNumber(row.referringPages)}{formatNumber(row.rank)}{formatDecimal(row.spamScore)}{formatCompactDate(row.firstSeen)} +
+
Broken links: {formatNumber(row.brokenBacklinks)}
+
+ Broken pages: {formatNumber(row.brokenPages)} +
+
+
+
+ ); +} + +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 ; + } + + return ( +
+ + + + {sortedRows.map((row, index) => ( + + + + + + + + ))} + +
+ {row.page ? ( + + ) : ( + "-" + )} + {formatNumber(row.backlinks)}{formatNumber(row.referringDomains)}{formatNumber(row.rank)}{formatNumber(row.brokenBacklinks)}
+
+ ); +} + +function BacklinksTableRow({ + row, +}: { + row: BacklinksOverviewData["backlinks"][number]; +}) { + const hasNotableFlags = + row.isLost || + row.isBroken || + row.isDofollow === false || + (row.linksCount != null && row.linksCount > 1); + + return ( + + +
+
+ {row.domainFrom?.replace(/^www\./, "") ?? "-"} +
+ {row.urlFrom ? ( + + ) : null} +
+ + + {row.urlTo ? ( + + ) : ( + "-" + )} + + +
+ {row.anchor || "No anchor text"} + {row.itemType ? ( +
{row.itemType}
+ ) : null} +
+ + {hasNotableFlags ? : null} + + + {formatNumber(row.rank)} + + + + {formatNumber(row.domainFromRank)} + + +
{formatCompactDate(row.firstSeen)}
+ {row.lastSeen ? ( +
+ Last {formatCompactDate(row.lastSeen)} +
+ ) : null} + + + ); +} + +function BacklinkFlags({ + row, +}: { + row: BacklinksOverviewData["backlinks"][number]; +}) { + return ( +
+ {row.isLost ? ( + Lost + ) : null} + {row.isBroken ? ( + + Broken + + ) : null} + {row.isDofollow === false ? ( + Nofollow + ) : null} + {row.linksCount != null && row.linksCount > 1 ? ( + + {row.linksCount} links + + ) : null} +
+ ); +} diff --git a/src/client/features/backlinks/BacklinksSearchCard.tsx b/src/client/features/backlinks/BacklinksSearchCard.tsx new file mode 100644 index 0000000..ca696b2 --- /dev/null +++ b/src/client/features/backlinks/BacklinksSearchCard.tsx @@ -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(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 ( +
+
+
+ setStatus(toBacklinksStatus(value))} + onTargetInputChange={setTargetInput} + setFormError={setFormError} + scope={scope} + status={status} + targetInput={targetInput} + userSelectedScope={userSelectedScope} + onUserSelectedScopeChange={setUserSelectedScope} + /> + setShowAdvanced((current) => !current)} + /> + {showAdvanced ? ( + + ) : null} + + {formError ?

{formError}

: null} + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+
+ ); +} + +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 ( +
+
+ + + +
+
+ + +
+
+ ); +} + +function SearchToggles({ + includeSubdomains, + onIncludeSubdomainsChange, + showAdvanced, + toggleAdvanced, +}: { + includeSubdomains: boolean; + onIncludeSubdomainsChange: (checked: boolean) => void; + showAdvanced: boolean; + toggleAdvanced: () => void; +}) { + return ( +
+ + +
+ ); +} + +function AdvancedSearchOptions({ + excludeInternalBacklinks, + includeIndirectLinks, + onExcludeInternalChange, + onIncludeIndirectChange, +}: { + excludeInternalBacklinks: boolean; + includeIndirectLinks: boolean; + onExcludeInternalChange: (checked: boolean) => void; + onIncludeIndirectChange: (checked: boolean) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/src/client/features/backlinks/BacklinksTableHeaders.tsx b/src/client/features/backlinks/BacklinksTableHeaders.tsx new file mode 100644 index 0000000..795fea2 --- /dev/null +++ b/src/client/features/backlinks/BacklinksTableHeaders.tsx @@ -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 ( + + + + + + + + + + + + + + ); +} + +export function ReferringDomainsTableHeader({ + sort, + onSortChange, +}: { + sort: ReferringDomainsTableSort; + onSortChange: (sort: ReferringDomainsTableSort) => void; +}) { + return ( + + + + + + + + + + + + ); +} + +export function TopPagesTableHeader({ + sort, + onSortChange, +}: { + sort: TopPagesTableSort; + onSortChange: (sort: TopPagesTableSort) => void; +}) { + return ( + + + + + + + + + + ); +} + +function SortableHeaderCell({ + 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 = ( + + ); + + return ( + + {align === "right" ? ( + {content} + ) : ( + content + )} + + ); +} diff --git a/src/client/features/backlinks/backlinksPageTypes.ts b/src/client/features/backlinks/backlinksPageTypes.ts new file mode 100644 index 0000000..3f4a468 --- /dev/null +++ b/src/client/features/backlinks/backlinksPageTypes.ts @@ -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 +>; +export type BacklinksAccessStatusData = Awaited< + ReturnType +>; +export type BacklinksReferringDomainsData = Awaited< + ReturnType +>; +export type BacklinksTopPagesData = Awaited< + ReturnType +>; + +export type BacklinksSearchState = { + target: string; + scope: BacklinksTargetScope; + subdomains: boolean; + indirect: boolean; + excludeInternal: boolean; + status: BacklinksStatus; + tab: BacklinksTab; +}; + +export type BacklinksNavigate = (args: { + search: (prev: Record) => Record; + replace: boolean; +}) => void; + +export type BacklinksPageProps = { + projectId: string; + searchState: BacklinksSearchState; + navigate: BacklinksNavigate; +}; diff --git a/src/client/features/backlinks/backlinksPageUtils.ts b/src/client/features/backlinks/backlinksPageUtils.ts new file mode 100644 index 0000000..1ad6157 --- /dev/null +++ b/src/client/features/backlinks/backlinksPageUtils.ts @@ -0,0 +1,131 @@ +import type { BacklinksTab } from "@/types/schemas/backlinks"; +import type { BacklinksOverviewData } from "./backlinksPageTypes"; + +export const TAB_DESCRIPTIONS: Record = { + 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)}`; +} diff --git a/src/client/features/backlinks/backlinksSearchScope.test.ts b/src/client/features/backlinks/backlinksSearchScope.test.ts new file mode 100644 index 0000000..2f0fb22 --- /dev/null +++ b/src/client/features/backlinks/backlinksSearchScope.test.ts @@ -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"); + }); +}); diff --git a/src/client/features/backlinks/backlinksSearchScope.ts b/src/client/features/backlinks/backlinksSearchScope.ts new file mode 100644 index 0000000..4219574 --- /dev/null +++ b/src/client/features/backlinks/backlinksSearchScope.ts @@ -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; +} diff --git a/src/client/features/backlinks/backlinksTableSorting.ts b/src/client/features/backlinks/backlinksTableSorting.ts new file mode 100644 index 0000000..3d083b9 --- /dev/null +++ b/src/client/features/backlinks/backlinksTableSorting.ts @@ -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 = { + field: TField; + direction: SortDirection; +}; + +export type BacklinksTableSort = TableSort; +export type ReferringDomainsTableSort = + TableSort; +export type TopPagesTableSort = TableSort; + +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( + current: TableSort, + field: TField, + defaultDirection: SortDirection, +): TableSort { + 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); +} diff --git a/src/client/features/backlinks/useBacklinksPageData.ts b/src/client/features/backlinks/useBacklinksPageData.ts new file mode 100644 index 0000000..6eb34d5 --- /dev/null +++ b/src/client/features/backlinks/useBacklinksPageData.ts @@ -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; +} diff --git a/src/client/lib/error-messages.ts b/src/client/lib/error-messages.ts index c1e3a40..0939a58 100644 --- a/src/client/lib/error-messages.ts +++ b/src/client/lib/error-messages.ts @@ -10,6 +10,10 @@ const STANDARD_MESSAGES: Record = { "You've reached audit capacity for your account. Delete old audits from your projects to start a new one.", VALIDATION_ERROR: "Please check your input and try again.", CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.", + BACKLINKS_NOT_ENABLED: + "Backlinks is not enabled for the connected DataForSEO account yet.", + BACKLINKS_BILLING_ISSUE: + "The connected DataForSEO account has a billing or balance issue.", RATE_LIMITED: "Too many requests. Please wait and try again.", CONFLICT: "This request conflicts with existing data.", INTERNAL_ERROR: diff --git a/src/client/navigation/items.ts b/src/client/navigation/items.ts index 4b77066..6a1abdd 100644 --- a/src/client/navigation/items.ts +++ b/src/client/navigation/items.ts @@ -1,4 +1,11 @@ -import { Bookmark, Bot, ClipboardCheck, Globe, Search } from "lucide-react"; +import { + Bookmark, + Bot, + ClipboardCheck, + Globe, + Link2, + Search, +} from "lucide-react"; export const projectNavItems = [ { @@ -19,6 +26,12 @@ export const projectNavItems = [ icon: Globe, matchSegment: "/domain", }, + { + to: "/p/$projectId/backlinks" as const, + label: "Backlinks", + icon: Link2, + matchSegment: "/backlinks", + }, { to: "/p/$projectId/audit" as const, label: "Site Audit", diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 29b6f7d..f70b012 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as PProjectIdIndexRouteImport } from './routes/p/$projectId/index import { Route as PProjectIdSavedRouteImport } from './routes/p/$projectId/saved' import { Route as PProjectIdKeywordsRouteImport } from './routes/p/$projectId/keywords' import { Route as PProjectIdDomainRouteImport } from './routes/p/$projectId/domain' +import { Route as PProjectIdBacklinksRouteImport } from './routes/p/$projectId/backlinks' import { Route as PProjectIdAuditRouteImport } from './routes/p/$projectId/audit' import { Route as PProjectIdAiRouteImport } from './routes/p/$projectId/ai' import { Route as PProjectIdAuditIndexRouteImport } from './routes/p/$projectId/audit/index' @@ -57,6 +58,11 @@ const PProjectIdDomainRoute = PProjectIdDomainRouteImport.update({ path: '/domain', getParentRoute: () => PProjectIdRouteRoute, } as any) +const PProjectIdBacklinksRoute = PProjectIdBacklinksRouteImport.update({ + id: '/backlinks', + path: '/backlinks', + getParentRoute: () => PProjectIdRouteRoute, +} as any) const PProjectIdAuditRoute = PProjectIdAuditRouteImport.update({ id: '/audit', path: '/audit', @@ -91,6 +97,7 @@ export interface FileRoutesByFullPath { '/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute '/p/$projectId/ai': typeof PProjectIdAiRoute '/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren + '/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute '/p/$projectId/domain': typeof PProjectIdDomainRoute '/p/$projectId/keywords': typeof PProjectIdKeywordsRoute '/p/$projectId/saved': typeof PProjectIdSavedRoute @@ -103,6 +110,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute '/p/$projectId/ai': typeof PProjectIdAiRoute + '/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute '/p/$projectId/domain': typeof PProjectIdDomainRoute '/p/$projectId/keywords': typeof PProjectIdKeywordsRoute '/p/$projectId/saved': typeof PProjectIdSavedRoute @@ -118,6 +126,7 @@ export interface FileRoutesById { '/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute '/p/$projectId/ai': typeof PProjectIdAiRoute '/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren + '/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute '/p/$projectId/domain': typeof PProjectIdDomainRoute '/p/$projectId/keywords': typeof PProjectIdKeywordsRoute '/p/$projectId/saved': typeof PProjectIdSavedRoute @@ -134,6 +143,7 @@ export interface FileRouteTypes { | '/help/dataforseo-api-key' | '/p/$projectId/ai' | '/p/$projectId/audit' + | '/p/$projectId/backlinks' | '/p/$projectId/domain' | '/p/$projectId/keywords' | '/p/$projectId/saved' @@ -146,6 +156,7 @@ export interface FileRouteTypes { | '/' | '/help/dataforseo-api-key' | '/p/$projectId/ai' + | '/p/$projectId/backlinks' | '/p/$projectId/domain' | '/p/$projectId/keywords' | '/p/$projectId/saved' @@ -160,6 +171,7 @@ export interface FileRouteTypes { | '/help/dataforseo-api-key' | '/p/$projectId/ai' | '/p/$projectId/audit' + | '/p/$projectId/backlinks' | '/p/$projectId/domain' | '/p/$projectId/keywords' | '/p/$projectId/saved' @@ -226,6 +238,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PProjectIdDomainRouteImport parentRoute: typeof PProjectIdRouteRoute } + '/p/$projectId/backlinks': { + id: '/p/$projectId/backlinks' + path: '/backlinks' + fullPath: '/p/$projectId/backlinks' + preLoaderRoute: typeof PProjectIdBacklinksRouteImport + parentRoute: typeof PProjectIdRouteRoute + } '/p/$projectId/audit': { id: '/p/$projectId/audit' path: '/audit' @@ -281,6 +300,7 @@ const PProjectIdAuditRouteWithChildren = PProjectIdAuditRoute._addFileChildren( interface PProjectIdRouteRouteChildren { PProjectIdAiRoute: typeof PProjectIdAiRoute PProjectIdAuditRoute: typeof PProjectIdAuditRouteWithChildren + PProjectIdBacklinksRoute: typeof PProjectIdBacklinksRoute PProjectIdDomainRoute: typeof PProjectIdDomainRoute PProjectIdKeywordsRoute: typeof PProjectIdKeywordsRoute PProjectIdSavedRoute: typeof PProjectIdSavedRoute @@ -291,6 +311,7 @@ interface PProjectIdRouteRouteChildren { const PProjectIdRouteRouteChildren: PProjectIdRouteRouteChildren = { PProjectIdAiRoute: PProjectIdAiRoute, PProjectIdAuditRoute: PProjectIdAuditRouteWithChildren, + PProjectIdBacklinksRoute: PProjectIdBacklinksRoute, PProjectIdDomainRoute: PProjectIdDomainRoute, PProjectIdKeywordsRoute: PProjectIdKeywordsRoute, PProjectIdSavedRoute: PProjectIdSavedRoute, diff --git a/src/routes/p/$projectId/backlinks.tsx b/src/routes/p/$projectId/backlinks.tsx new file mode 100644 index 0000000..1c71bec --- /dev/null +++ b/src/routes/p/$projectId/backlinks.tsx @@ -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 ( + + ); +} diff --git a/src/server/features/backlinks/backlinksAccess.test.ts b/src/server/features/backlinks/backlinksAccess.test.ts new file mode 100644 index 0000000..916af15 --- /dev/null +++ b/src/server/features/backlinks/backlinksAccess.test.ts @@ -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(), +})); + +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, + }); + }); +}); diff --git a/src/server/features/backlinks/backlinksAccess.ts b/src/server/features/backlinks/backlinksAccess.ts new file mode 100644 index 0000000..fcf8f51 --- /dev/null +++ b/src/server/features/backlinks/backlinksAccess.ts @@ -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; + +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 { + 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 { + 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 { + 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; + } +} diff --git a/src/server/features/backlinks/backlinksProjectAccess.test.ts b/src/server/features/backlinks/backlinksProjectAccess.test.ts new file mode 100644 index 0000000..a1800de --- /dev/null +++ b/src/server/features/backlinks/backlinksProjectAccess.test.ts @@ -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); + }); +}); diff --git a/src/server/features/backlinks/backlinksProjectAccess.ts b/src/server/features/backlinks/backlinksProjectAccess.ts new file mode 100644 index 0000000..fa67aeb --- /dev/null +++ b/src/server/features/backlinks/backlinksProjectAccess.ts @@ -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; +} diff --git a/src/server/features/backlinks/services/BacklinksService.billing.test.ts b/src/server/features/backlinks/services/BacklinksService.billing.test.ts new file mode 100644 index 0000000..ef09d6a --- /dev/null +++ b/src/server/features/backlinks/services/BacklinksService.billing.test.ts @@ -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(); +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; + } +} diff --git a/src/server/features/backlinks/services/BacklinksService.ts b/src/server/features/backlinks/services/BacklinksService.ts new file mode 100644 index 0000000..8cc7eca --- /dev/null +++ b/src/server/features/backlinks/services/BacklinksService.ts @@ -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 { + 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 }; diff --git a/src/server/features/backlinks/services/backlinksCost.ts b/src/server/features/backlinks/services/backlinksCost.ts new file mode 100644 index 0000000..670b702 --- /dev/null +++ b/src/server/features/backlinks/services/backlinksCost.ts @@ -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; +} diff --git a/src/server/features/backlinks/services/backlinksOverviewSchema.ts b/src/server/features/backlinks/services/backlinksOverviewSchema.ts new file mode 100644 index 0000000..f2fd1ea --- /dev/null +++ b/src/server/features/backlinks/services/backlinksOverviewSchema.ts @@ -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; diff --git a/src/server/features/backlinks/services/backlinksServiceData.ts b/src/server/features/backlinks/services/backlinksServiceData.ts new file mode 100644 index 0000000..775291d --- /dev/null +++ b/src/server/features/backlinks/services/backlinksServiceData.ts @@ -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; + set(key: string, data: unknown, ttlSeconds: number): Promise; +}; + +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 { + 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 { + 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 { + 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; + now: Date; + summary: Awaited>; + backlinks: Awaited>; + trends: Awaited>; + newLostTrends: Awaited>; +}): 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>["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>["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>["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(data: T): BacklinksApiResponse { + return { + data, + billing: { + endpoint: "", + path: [], + costUsd: 0, + resultCount: null, + rowsReturned: 0, + }, + }; +} diff --git a/src/server/lib/dataforseoBacklinks.test.ts b/src/server/lib/dataforseoBacklinks.test.ts new file mode 100644 index 0000000..2f95688 --- /dev/null +++ b/src/server/lib/dataforseoBacklinks.test.ts @@ -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"); +} diff --git a/src/server/lib/dataforseoBacklinks.ts b/src/server/lib/dataforseoBacklinks.ts new file mode 100644 index 0000000..c8156b2 --- /dev/null +++ b/src/server/lib/dataforseoBacklinks.ts @@ -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; +}; + +export type BacklinksApiResponse = { + data: T; + billing: BacklinksApiCallCost; +}; + +async function createAuthenticatedFetch() { + const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY"); + + return (url: RequestInfo, init?: RequestInit): Promise => { + 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; +} + +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; +} + +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; +} + +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; +} + +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; +} + +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; +} diff --git a/src/server/lib/dataforseoBacklinksAccount.ts b/src/server/lib/dataforseoBacklinksAccount.ts new file mode 100644 index 0000000..3f604df --- /dev/null +++ b/src/server/lib/dataforseoBacklinksAccount.ts @@ -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 => { + 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; +} diff --git a/src/server/lib/dataforseoBacklinksSupport.ts b/src/server/lib/dataforseoBacklinksSupport.ts new file mode 100644 index 0000000..a0365a7 --- /dev/null +++ b/src/server/lib/dataforseoBacklinksSupport.ts @@ -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( + endpointName: string, + results: unknown[], + itemSchema: T, +): Array> { + 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( + endpointName: string, + results: unknown[], + resultSchema: T, +): z.infer { + 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; +} diff --git a/src/server/lib/dataforseoBacklinksTarget.ts b/src/server/lib/dataforseoBacklinksTarget.ts new file mode 100644 index 0000000..9e5caa5 --- /dev/null +++ b/src/server/lib/dataforseoBacklinksTarget.ts @@ -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", + }; +} diff --git a/src/server/lib/kv-cache.ts b/src/server/lib/kv-cache.ts index e05447c..3ceaf29 100644 --- a/src/server/lib/kv-cache.ts +++ b/src/server/lib/kv-cache.ts @@ -1,7 +1,7 @@ -import { env } from "cloudflare:workers"; import { sortBy } from "remeda"; import { z } from "zod"; import { jsonCodec } from "@/shared/json"; +import { getWorkersBinding } from "@/server/lib/runtime-env"; /** * Cache TTL constants in seconds. @@ -32,7 +32,8 @@ export function buildCacheKey( * Get a cached JSON value from KV. Returns null on miss. */ export async function getCached(key: string): Promise { - const value = await env.KV.get(key, "text"); + const kv = await getKvNamespace(); + const value = await kv.get(key, "text"); if (value === null) return null; const parsed = jsonUnknownCodec.safeParse(value); return parsed.success ? parsed.data : null; @@ -46,11 +47,32 @@ export async function setCached( data: T, ttlSeconds: number, ): Promise { - await env.KV.put(key, JSON.stringify(data), { + const kv = await getKvNamespace(); + await kv.put(key, JSON.stringify(data), { expirationTtl: ttlSeconds, }); } +async function getKvNamespace(): Promise { + 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. */ diff --git a/src/server/lib/runtime-env.ts b/src/server/lib/runtime-env.ts new file mode 100644 index 0000000..b44b7a8 --- /dev/null +++ b/src/server/lib/runtime-env.ts @@ -0,0 +1,50 @@ +let workersEnvPromise: Promise | null> | null = null; + +async function getEnvValue(name: string): Promise { + 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 { + const value = await getEnvValue(name); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +export async function getWorkersBinding(name: string): Promise { + const workersEnv = await getWorkersEnv(); + const binding = workersEnv?.[name]; + if (!binding) { + throw new Error(`Missing required Worker binding: ${name}`); + } + return binding; +} + +async function getWorkersEnv(): Promise | null> { + if (!workersEnvPromise) { + workersEnvPromise = loadWorkersEnv(); + } + return workersEnvPromise; +} + +async function loadWorkersEnv(): Promise | 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 { + return typeof value === "object" && value !== null; +} diff --git a/src/serverFunctions/backlinks.ts b/src/serverFunctions/backlinks.ts new file mode 100644 index 0000000..4680272 --- /dev/null +++ b/src/serverFunctions/backlinks.ts @@ -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), + ); + } +} diff --git a/src/serverFunctions/backlinksAccess.ts b/src/serverFunctions/backlinksAccess.ts new file mode 100644 index 0000000..35e101b --- /dev/null +++ b/src/serverFunctions/backlinksAccess.ts @@ -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>, +) { + 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; +} diff --git a/src/shared/error-codes.ts b/src/shared/error-codes.ts index fbb6e40..351b425 100644 --- a/src/shared/error-codes.ts +++ b/src/shared/error-codes.ts @@ -8,6 +8,8 @@ const ERROR_CODES = [ "AUDIT_CAPACITY_REACHED", "VALIDATION_ERROR", "CRAWL_TARGET_BLOCKED", + "BACKLINKS_NOT_ENABLED", + "BACKLINKS_BILLING_ISSUE", "RATE_LIMITED", "CONFLICT", "INTERNAL_ERROR", diff --git a/src/types/schemas/backlinks.ts b/src/types/schemas/backlinks.ts new file mode 100644 index 0000000..08634a5 --- /dev/null +++ b/src/types/schemas/backlinks.ts @@ -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; +export type BacklinksStatus = z.infer; +export type BacklinksTab = z.infer; +export type BacklinksTargetScope = z.infer; diff --git a/src/types/schemas/domain.ts b/src/types/schemas/domain.ts index dfa0021..6b3478b 100644 --- a/src/types/schemas/domain.ts +++ b/src/types/schemas/domain.ts @@ -1,5 +1,9 @@ import { z } from "zod"; +const booleanSearchParamSchema = z + .union([z.boolean(), z.enum(["true", "false"])]) + .transform((value) => value === true || value === "true"); + export const domainOverviewSchema = z.object({ domain: z.string().min(1, "Domain is required").max(255), includeSubdomains: z.boolean().default(true), @@ -17,7 +21,7 @@ const domainTabs = ["keywords", "pages"] as const; export const domainSearchSchema = z.object({ domain: z.string().optional(), - subdomains: z.coerce.boolean().optional(), + subdomains: booleanSearchParamSchema.optional(), sort: z.enum(domainSortModes).optional(), order: z.enum(domainSortOrders).optional(), tab: z.enum(domainTabs).optional(), diff --git a/src/types/schemas/search-params.test.ts b/src/types/schemas/search-params.test.ts new file mode 100644 index 0000000..044a64a --- /dev/null +++ b/src/types/schemas/search-params.test.ts @@ -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, + }); + }); +});