metatron-open-seo/src/server/lib/dataforseoBacklinks.ts

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>;
}