* feat: add backlinks setup and overview workflow * save * fix backlinks timeseries and refine overview layout * update backlinks table guidance * refactor backlinks CI cleanup * refactor backlinks page state colocation * fix backlinks access error handling Differentiate DataForSEO subscription access failures from billing issues and preserve exact page hosts for page-level backlink lookups. * fix backlinks project scoping and error states * simplify backlinks billing and lazy loading * harden backlinks profiling and access flow * refine backlinks access status typing * update backlinks scope selection and access handling * fix backlinks scope fallback and cache scoping * fix backlinks ci checks * docs: simplify backlinks pricing * refine backlinks search: move scope toggle below input, remove redundant description * refine backlinks summaries and help text * refine backlinks table sorting * fix backlinks invalid target handling * delete docs * fix backlinks scope inference and domain normalization * docs: mark backlinks as a supported workflow * fix backlinks links badge wrapping * test: split backlinks test suites
155 lines
3.9 KiB
TypeScript
155 lines
3.9 KiB
TypeScript
import { z } from "zod";
|
|
import { AppError } from "@/server/lib/errors";
|
|
import { classifyBacklinksError } from "@/server/lib/dataforseoBacklinksSupport";
|
|
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
|
|
|
|
const API_BASE = "https://api.dataforseo.com";
|
|
|
|
const userDataResponseSchema = z
|
|
.object({
|
|
status_code: z.number().optional(),
|
|
status_message: z.string().optional(),
|
|
tasks: z
|
|
.array(
|
|
z
|
|
.object({
|
|
status_code: z.number().optional(),
|
|
status_message: z.string().optional(),
|
|
result: z
|
|
.array(
|
|
z
|
|
.object({
|
|
money: z
|
|
.object({
|
|
balance: z.number().nullable().optional(),
|
|
})
|
|
.passthrough()
|
|
.optional(),
|
|
backlinks_subscription_expiry_date: z
|
|
.string()
|
|
.nullable()
|
|
.optional(),
|
|
})
|
|
.passthrough(),
|
|
)
|
|
.nullable()
|
|
.optional(),
|
|
})
|
|
.passthrough(),
|
|
)
|
|
.optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
async function createAuthenticatedFetch() {
|
|
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
|
|
|
|
return (url: RequestInfo, init?: RequestInit): Promise<Response> => {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set("Authorization", `Basic ${apiKey}`);
|
|
|
|
return fetch(url, {
|
|
...init,
|
|
headers,
|
|
});
|
|
};
|
|
}
|
|
|
|
async function getDataforseo(path: string) {
|
|
const authenticatedFetch = await createAuthenticatedFetch();
|
|
const response = await authenticatedFetch(`${API_BASE}${path}`, {
|
|
method: "GET",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
`DataForSEO HTTP ${response.status} on ${path}`,
|
|
);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
async function fetchBacklinksAccountState() {
|
|
const raw = await getDataforseo("/v3/appendix/user_data");
|
|
const parsed = userDataResponseSchema.safeParse(raw);
|
|
if (!parsed.success || parsed.data.status_code !== 20000) {
|
|
return null;
|
|
}
|
|
|
|
const task = parsed.data.tasks?.[0];
|
|
if (!task || task.status_code !== 20000) {
|
|
return null;
|
|
}
|
|
|
|
const result = task.result?.[0];
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
balance: result.money?.balance ?? null,
|
|
backlinksSubscriptionExpiryDate:
|
|
result.backlinks_subscription_expiry_date ?? null,
|
|
};
|
|
}
|
|
|
|
function hasActiveBacklinksSubscription(value: string | null) {
|
|
if (!value) return false;
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) return true;
|
|
return parsed.getTime() > Date.now();
|
|
}
|
|
|
|
export async function classifyBacklinksErrorWithAccountState(
|
|
status: number | undefined,
|
|
details: string,
|
|
path: string,
|
|
) {
|
|
const classifiedError = classifyBacklinksError(status, details, path);
|
|
if (classifiedError) {
|
|
return classifiedError;
|
|
}
|
|
|
|
const text = details.toLowerCase();
|
|
const needsAccountLookup =
|
|
path.includes("/backlinks/") &&
|
|
(status === 402 ||
|
|
status === 403 ||
|
|
text.includes("backlinks") ||
|
|
text.includes("subscription") ||
|
|
text.includes("billing") ||
|
|
text.includes("balance") ||
|
|
text.includes("payment"));
|
|
|
|
if (!needsAccountLookup) {
|
|
return null;
|
|
}
|
|
|
|
const accountState = await fetchBacklinksAccountState().catch(() => null);
|
|
if (!accountState) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
!hasActiveBacklinksSubscription(
|
|
accountState.backlinksSubscriptionExpiryDate,
|
|
)
|
|
) {
|
|
return new AppError(
|
|
"BACKLINKS_NOT_ENABLED",
|
|
"Backlinks is not enabled for the connected DataForSEO account",
|
|
);
|
|
}
|
|
|
|
if (typeof accountState.balance === "number" && accountState.balance <= 0) {
|
|
return new AppError(
|
|
"BACKLINKS_BILLING_ISSUE",
|
|
"The connected DataForSEO account has a billing or balance issue",
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|