293 lines
7.6 KiB
TypeScript
293 lines
7.6 KiB
TypeScript
import { AppError } from "@/server/lib/errors";
|
|
import {
|
|
normalizeBacklinksSpamFilterOptions,
|
|
type BacklinksSpamFilterOptions,
|
|
} from "@/types/schemas/backlinks";
|
|
import type {
|
|
DataforseoApiCallCost,
|
|
DataforseoApiResponse,
|
|
} from "@/server/lib/dataforseoCost";
|
|
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
|
|
import {
|
|
classifyBacklinksError,
|
|
type BacklinksTaskResult,
|
|
backlinksHistoryItemSchema,
|
|
backlinksItemSchema,
|
|
backlinksSummaryItemSchema,
|
|
domainPageSummaryItemSchema,
|
|
parseItems,
|
|
referringDomainItemSchema,
|
|
responseSchema,
|
|
} from "@/server/lib/dataforseoBacklinksSupport";
|
|
export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
|
|
|
|
const API_BASE = "https://api.dataforseo.com";
|
|
|
|
export type BacklinksRequest = {
|
|
target: string;
|
|
};
|
|
|
|
export type BacklinksListRequest = BacklinksRequest &
|
|
BacklinksSpamFilterOptions & {
|
|
limit?: number;
|
|
};
|
|
|
|
export type BacklinksTimeseriesRequest = {
|
|
target: string;
|
|
dateFrom: string;
|
|
dateTo: string;
|
|
};
|
|
|
|
type DataforseoTaskResponse = {
|
|
results: BacklinksTaskResult[];
|
|
billing: DataforseoApiCallCost;
|
|
};
|
|
|
|
async function createAuthenticatedFetch() {
|
|
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
|
|
|
|
return (url: RequestInfo, init?: RequestInit): Promise<Response> => {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set("Authorization", `Basic ${apiKey}`);
|
|
|
|
return fetch(url, {
|
|
...init,
|
|
headers,
|
|
});
|
|
};
|
|
}
|
|
|
|
async function postBacklinks(path: string, payload: unknown) {
|
|
const authenticatedFetch = await createAuthenticatedFetch();
|
|
const response = await authenticatedFetch(`${API_BASE}${path}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
const rawText = await response.text();
|
|
if (!response.ok) {
|
|
const classifiedError = classifyBacklinksError(
|
|
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 = classifyBacklinksError(
|
|
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 = classifyBacklinksError(
|
|
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 = classifyBacklinksError(
|
|
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 = classifyBacklinksError(
|
|
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 ?? []).filter(
|
|
(r): r is BacklinksTaskResult => r != null,
|
|
),
|
|
billing: {
|
|
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: true,
|
|
include_indirect_links: true,
|
|
exclude_internal_backlinks: true,
|
|
backlinks_status_type: "live",
|
|
rank_scale: "one_hundred",
|
|
};
|
|
}
|
|
|
|
export async function fetchBacklinksSummaryRaw(input: BacklinksRequest) {
|
|
const response = await postBacklinks("/v3/backlinks/summary/live", [
|
|
buildCommonPayload(input),
|
|
]);
|
|
const firstResult = response.results[0];
|
|
const parsed = firstResult
|
|
? backlinksSummaryItemSchema.safeParse(firstResult)
|
|
: null;
|
|
if (parsed && !parsed.success) {
|
|
console.error(
|
|
"dataforseo.backlinks-summary-live.invalid-result",
|
|
parsed.error.issues.slice(0, 5),
|
|
);
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
"DataForSEO backlinks-summary-live returned an invalid response shape",
|
|
);
|
|
}
|
|
const data = parsed?.data ?? {};
|
|
return {
|
|
data,
|
|
billing: response.billing,
|
|
} satisfies DataforseoApiResponse<typeof data>;
|
|
}
|
|
|
|
export async function fetchBacklinksRowsRaw(input: BacklinksListRequest) {
|
|
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
|
|
const filters = spamFilterOptions.hideSpam
|
|
? [["backlink_spam_score", "<=", spamFilterOptions.spamThreshold]]
|
|
: undefined;
|
|
const response = await postBacklinks("/v3/backlinks/backlinks/live", [
|
|
{
|
|
...buildCommonPayload(input),
|
|
limit: input.limit ?? 100,
|
|
order_by: ["rank,desc"],
|
|
...(filters ? { filters } : {}),
|
|
},
|
|
]);
|
|
const data = parseItems(
|
|
"backlinks-live",
|
|
response.results,
|
|
backlinksItemSchema,
|
|
);
|
|
return {
|
|
data,
|
|
billing: response.billing,
|
|
} satisfies DataforseoApiResponse<typeof data>;
|
|
}
|
|
|
|
export async function fetchReferringDomainsRaw(input: BacklinksListRequest) {
|
|
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
|
|
const filters = spamFilterOptions.hideSpam
|
|
? [["backlinks_spam_score", "<=", spamFilterOptions.spamThreshold]]
|
|
: undefined;
|
|
const response = await postBacklinks("/v3/backlinks/referring_domains/live", [
|
|
{
|
|
...buildCommonPayload(input),
|
|
limit: input.limit ?? 100,
|
|
order_by: ["backlinks,desc"],
|
|
...(filters ? { filters } : {}),
|
|
},
|
|
]);
|
|
const data = parseItems(
|
|
"referring-domains-live",
|
|
response.results,
|
|
referringDomainItemSchema,
|
|
);
|
|
return {
|
|
data,
|
|
billing: response.billing,
|
|
} satisfies DataforseoApiResponse<typeof data>;
|
|
}
|
|
|
|
export async function fetchDomainPagesSummaryRaw(input: BacklinksListRequest) {
|
|
const response = await postBacklinks(
|
|
"/v3/backlinks/domain_pages_summary/live",
|
|
[
|
|
{
|
|
...buildCommonPayload(input),
|
|
limit: input.limit ?? 100,
|
|
order_by: ["backlinks,desc"],
|
|
},
|
|
],
|
|
);
|
|
const data = parseItems(
|
|
"domain-pages-summary-live",
|
|
response.results,
|
|
domainPageSummaryItemSchema,
|
|
);
|
|
return {
|
|
data,
|
|
billing: response.billing,
|
|
} satisfies DataforseoApiResponse<typeof data>;
|
|
}
|
|
|
|
export async function fetchBacklinksHistoryRaw(
|
|
input: BacklinksTimeseriesRequest,
|
|
) {
|
|
const response = await postBacklinks("/v3/backlinks/history/live", [
|
|
{
|
|
target: input.target,
|
|
date_from: input.dateFrom,
|
|
date_to: input.dateTo,
|
|
rank_scale: "one_hundred",
|
|
},
|
|
]);
|
|
const data = parseItems(
|
|
"backlinks-history-live",
|
|
response.results,
|
|
backlinksHistoryItemSchema,
|
|
);
|
|
return {
|
|
data,
|
|
billing: response.billing,
|
|
} satisfies DataforseoApiResponse<typeof data>;
|
|
}
|