569 lines
16 KiB
TypeScript
569 lines
16 KiB
TypeScript
/* eslint-disable max-lines */
|
|
import {
|
|
DataforseoLabsApi,
|
|
DataforseoLabsGoogleRelatedKeywordsLiveRequestInfo,
|
|
DataforseoLabsGoogleKeywordSuggestionsLiveRequestInfo,
|
|
DataforseoLabsGoogleKeywordIdeasLiveRequestInfo,
|
|
DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo,
|
|
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
|
|
} from "dataforseo-client";
|
|
import { env } from "cloudflare:workers";
|
|
import { z } from "zod";
|
|
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
|
|
import { AppError } from "@/server/lib/errors";
|
|
import {
|
|
dataforseoResponseSchema,
|
|
domainMetricsItemSchema,
|
|
domainRankedKeywordItemSchema,
|
|
keywordOverviewItemSchema,
|
|
labsKeywordDataItemSchema,
|
|
parseTaskItems,
|
|
relatedKeywordItemSchema,
|
|
serpSnapshotItemSchema,
|
|
type DataforseoTask,
|
|
type DomainMetricsItem,
|
|
type DomainRankedKeywordItem,
|
|
type KeywordOverviewItem,
|
|
type LabsKeywordDataItem,
|
|
type RelatedKeywordItem,
|
|
type SerpLiveItem,
|
|
successfulDataforseoTaskSchema,
|
|
} from "@/server/lib/dataforseoSchemas";
|
|
export type {
|
|
DomainRankedKeywordItem,
|
|
LabsKeywordDataItem,
|
|
SerpLiveItem,
|
|
} from "@/server/lib/dataforseoSchemas";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SDK client factories (lazily created per-request using the env secret)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createAuthenticatedFetch() {
|
|
return async (url: RequestInfo, init?: RequestInit): Promise<Response> => {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set("Authorization", `Basic ${env.DATAFORSEO_API_KEY}`);
|
|
|
|
const newInit: RequestInit = {
|
|
...init,
|
|
headers,
|
|
};
|
|
const response = await fetch(url, newInit);
|
|
|
|
if (!response.ok) {
|
|
const rawText = await response.text();
|
|
const path = formatDataforseoRequestPath(url);
|
|
const err = new AppError(
|
|
response.status === 429 ? "RATE_LIMITED" : "INTERNAL_ERROR",
|
|
`DataForSEO HTTP ${response.status} on ${path}`,
|
|
{
|
|
provider: "dataforseo",
|
|
providerStatus: String(response.status),
|
|
providerPath: path,
|
|
responseBody: formatDataforseoErrorPayload(rawText),
|
|
},
|
|
);
|
|
err.name = "DataForSEOHttpError";
|
|
throw err;
|
|
}
|
|
|
|
return response;
|
|
};
|
|
}
|
|
|
|
const API_BASE = "https://api.dataforseo.com";
|
|
const MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH = 1600;
|
|
|
|
function getLabsApi() {
|
|
return new DataforseoLabsApi(API_BASE, { fetch: createAuthenticatedFetch() });
|
|
}
|
|
|
|
function formatDataforseoRequestPath(url: RequestInfo): string {
|
|
const rawUrl = typeof url === "string" ? url : url.url;
|
|
try {
|
|
return new URL(rawUrl).pathname;
|
|
} catch {
|
|
return rawUrl;
|
|
}
|
|
}
|
|
|
|
async function postDataforseo(
|
|
path: string,
|
|
payload: unknown,
|
|
): Promise<unknown> {
|
|
const authenticatedFetch = createAuthenticatedFetch();
|
|
const response = await authenticatedFetch(`${API_BASE}${path}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
const rawText = await response.text();
|
|
|
|
try {
|
|
return JSON.parse(rawText);
|
|
} catch {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
`DataForSEO ${path} returned a non-JSON response. Response: ${formatDataforseoErrorPayload(rawText)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Response helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Validate that the top-level response and first task both succeeded.
|
|
* Throws a descriptive error on failure. Returns the first task.
|
|
*/
|
|
type DataforseoTaskLike = {
|
|
id?: string;
|
|
status_code?: number;
|
|
status_message?: string;
|
|
path?: string[];
|
|
cost?: number;
|
|
result_count?: number | null;
|
|
data?: unknown;
|
|
result?: DataforseoTask["result"];
|
|
};
|
|
|
|
function formatDataforseoErrorPayload(value: unknown): string {
|
|
const text =
|
|
typeof value === "string"
|
|
? value
|
|
: (() => {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
})();
|
|
|
|
return text.length > MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH
|
|
? `${text.slice(0, MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH)}... [truncated]`
|
|
: text;
|
|
}
|
|
|
|
function getTaskDebugPayload(task: DataforseoTaskLike) {
|
|
return {
|
|
id: task.id ?? null,
|
|
status_code: task.status_code ?? null,
|
|
status_message: task.status_message ?? null,
|
|
path: task.path ?? null,
|
|
cost: task.cost ?? null,
|
|
result_count: task.result_count ?? null,
|
|
data: task.data ?? null,
|
|
result_length: Array.isArray(task.result) ? task.result.length : null,
|
|
result_preview: Array.isArray(task.result)
|
|
? (task.result[0] ?? null)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function assertOk<T extends DataforseoTaskLike>(
|
|
response: {
|
|
status_code?: number;
|
|
status_message?: string;
|
|
tasks?: T[];
|
|
} | null,
|
|
): DataforseoTask {
|
|
if (!response) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
"DataForSEO returned an empty response",
|
|
);
|
|
}
|
|
if (response.status_code !== 20000) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
response.status_message || "DataForSEO request failed",
|
|
);
|
|
}
|
|
const task = response.tasks?.[0];
|
|
if (!task) {
|
|
throw new AppError("INTERNAL_ERROR", "DataForSEO response missing task");
|
|
}
|
|
if (task.status_code !== 20000) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
task.status_message || "DataForSEO task failed",
|
|
);
|
|
}
|
|
|
|
const parsedTask = successfulDataforseoTaskSchema.safeParse(task);
|
|
if (!parsedTask.success) {
|
|
const issueSummary = parsedTask.error.issues
|
|
.slice(0, 5)
|
|
.map((issue) => {
|
|
const path = issue.path.length > 0 ? issue.path.join(".") : "task";
|
|
return `${path}: ${issue.message}`;
|
|
})
|
|
.join("; ");
|
|
const responseSummary = formatDataforseoErrorPayload({
|
|
status_code: response.status_code ?? null,
|
|
status_message: response.status_message ?? null,
|
|
task: getTaskDebugPayload(task),
|
|
});
|
|
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
`DataForSEO task missing billing metadata (${issueSummary}). Response: ${responseSummary}`,
|
|
);
|
|
}
|
|
|
|
return parsedTask.data;
|
|
}
|
|
|
|
function buildTaskBilling(task: DataforseoTask) {
|
|
return {
|
|
path: task.path,
|
|
costUsd: task.cost,
|
|
resultCount: task.result_count,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DataForSEO Labs API wrappers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function fetchRelatedKeywordsRaw(
|
|
keyword: string,
|
|
locationCode: number,
|
|
languageCode: string,
|
|
limit: number,
|
|
depth: number = 3,
|
|
): Promise<DataforseoApiResponse<RelatedKeywordItem[]>> {
|
|
const api = getLabsApi();
|
|
const req = new DataforseoLabsGoogleRelatedKeywordsLiveRequestInfo({
|
|
keyword,
|
|
location_code: locationCode,
|
|
language_code: languageCode,
|
|
limit,
|
|
depth,
|
|
include_clickstream_data: true,
|
|
include_serp_info: false,
|
|
});
|
|
|
|
const endpoint = "google-related-keywords-live";
|
|
const response = await api.googleRelatedKeywordsLive([req]);
|
|
const task = assertOk(response);
|
|
const data = parseTaskItems(endpoint, task, relatedKeywordItemSchema);
|
|
|
|
return {
|
|
data,
|
|
billing: buildTaskBilling(task),
|
|
};
|
|
}
|
|
|
|
export async function fetchKeywordSuggestionsRaw(
|
|
keyword: string,
|
|
locationCode: number,
|
|
languageCode: string,
|
|
limit: number,
|
|
): Promise<DataforseoApiResponse<LabsKeywordDataItem[]>> {
|
|
const api = getLabsApi();
|
|
const req = new DataforseoLabsGoogleKeywordSuggestionsLiveRequestInfo({
|
|
keyword,
|
|
location_code: locationCode,
|
|
language_code: languageCode,
|
|
limit,
|
|
include_clickstream_data: true,
|
|
include_serp_info: false,
|
|
include_seed_keyword: true,
|
|
ignore_synonyms: false,
|
|
exact_match: false,
|
|
});
|
|
|
|
const endpoint = "google-keyword-suggestions-live";
|
|
const response = await api.googleKeywordSuggestionsLive([req]);
|
|
const task = assertOk(response);
|
|
const data = parseTaskItems(endpoint, task, labsKeywordDataItemSchema);
|
|
|
|
return {
|
|
data,
|
|
billing: buildTaskBilling(task),
|
|
};
|
|
}
|
|
|
|
export async function fetchKeywordIdeasRaw(
|
|
keyword: string,
|
|
locationCode: number,
|
|
languageCode: string,
|
|
limit: number,
|
|
): Promise<DataforseoApiResponse<LabsKeywordDataItem[]>> {
|
|
const api = getLabsApi();
|
|
const req = new DataforseoLabsGoogleKeywordIdeasLiveRequestInfo({
|
|
keywords: [keyword],
|
|
location_code: locationCode,
|
|
language_code: languageCode,
|
|
limit,
|
|
include_clickstream_data: true,
|
|
include_serp_info: false,
|
|
ignore_synonyms: false,
|
|
closely_variants: false,
|
|
});
|
|
|
|
const endpoint = "google-keyword-ideas-live";
|
|
const response = await api.googleKeywordIdeasLive([req]);
|
|
const task = assertOk(response);
|
|
const data = parseTaskItems(endpoint, task, labsKeywordDataItemSchema);
|
|
|
|
return {
|
|
data,
|
|
billing: buildTaskBilling(task),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Domain API wrappers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function fetchDomainRankOverviewRaw(
|
|
target: string,
|
|
locationCode: number,
|
|
languageCode: string,
|
|
): Promise<DataforseoApiResponse<DomainMetricsItem[]>> {
|
|
const api = getLabsApi();
|
|
const req = new DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo({
|
|
target,
|
|
location_code: locationCode,
|
|
language_code: languageCode,
|
|
limit: 1,
|
|
});
|
|
|
|
const endpoint = "google-domain-rank-overview-live";
|
|
const response = await api.googleDomainRankOverviewLive([req]);
|
|
const task = assertOk(response);
|
|
const data = parseTaskItems(endpoint, task, domainMetricsItemSchema);
|
|
|
|
return {
|
|
data,
|
|
billing: buildTaskBilling(task),
|
|
};
|
|
}
|
|
|
|
export async function fetchRankedKeywordsRaw(
|
|
target: string,
|
|
locationCode: number,
|
|
languageCode: string,
|
|
limit: number,
|
|
orderBy?: string[],
|
|
): Promise<DataforseoApiResponse<DomainRankedKeywordItem[]>> {
|
|
const api = getLabsApi();
|
|
const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({
|
|
target,
|
|
location_code: locationCode,
|
|
language_code: languageCode,
|
|
limit,
|
|
order_by: orderBy,
|
|
});
|
|
|
|
const endpoint = "google-ranked-keywords-live";
|
|
const response = await api.googleRankedKeywordsLive([req]);
|
|
const task = assertOk(response);
|
|
const data = parseTaskItems(endpoint, task, domainRankedKeywordItemSchema);
|
|
|
|
return {
|
|
data,
|
|
billing: buildTaskBilling(task),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SERP Analysis API wrapper (Google Organic Live)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function fetchLiveSerpItemsRaw(
|
|
keyword: string,
|
|
locationCode: number,
|
|
languageCode: string,
|
|
): Promise<DataforseoApiResponse<SerpLiveItem[]>> {
|
|
const responseRaw = await postDataforseo(
|
|
"/v3/serp/google/organic/live/advanced",
|
|
[
|
|
{
|
|
keyword,
|
|
location_code: locationCode,
|
|
language_code: languageCode,
|
|
device: "desktop",
|
|
os: "windows",
|
|
depth: 100,
|
|
},
|
|
],
|
|
);
|
|
const response = dataforseoResponseSchema.parse(responseRaw);
|
|
const endpoint = "google-organic-live-advanced";
|
|
const task = assertOk(response);
|
|
const data = parseTaskItems(endpoint, task, serpSnapshotItemSchema);
|
|
|
|
return {
|
|
data,
|
|
billing: buildTaskBilling(task),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SERP Rank Check API wrapper (Google Organic Live with target matching)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface RankCheckResult {
|
|
keywordId: string;
|
|
keyword: string;
|
|
position: number | null;
|
|
url: string | null;
|
|
serpFeatures: string[];
|
|
}
|
|
|
|
export async function fetchRankCheckSerpRaw(input: {
|
|
keyword: string;
|
|
keywordId: string;
|
|
locationCode: number;
|
|
languageCode: string;
|
|
device: "desktop" | "mobile";
|
|
targetDomain: string;
|
|
depth: number;
|
|
}): Promise<DataforseoApiResponse<RankCheckResult>> {
|
|
const depth = Math.min(100, Math.max(10, input.depth));
|
|
const responseRaw = await postDataforseo(
|
|
"/v3/serp/google/organic/live/advanced",
|
|
[
|
|
{
|
|
keyword: input.keyword,
|
|
location_code: input.locationCode,
|
|
language_code: input.languageCode,
|
|
device: input.device,
|
|
os: input.device === "desktop" ? "windows" : "android",
|
|
depth,
|
|
},
|
|
],
|
|
);
|
|
|
|
const response = dataforseoResponseSchema.parse(responseRaw);
|
|
|
|
if (response.status_code !== 20000) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
response.status_message || "DataForSEO request failed",
|
|
);
|
|
}
|
|
|
|
const task = response.tasks?.[0];
|
|
if (!task) {
|
|
throw new AppError("INTERNAL_ERROR", "DataForSEO response missing task");
|
|
}
|
|
|
|
// "No Search Results" (40501) is valid for obscure/new keywords —
|
|
// treat as empty result set rather than failing the entire run.
|
|
const isNoResults =
|
|
task.status_code === 40501 ||
|
|
task.status_message?.toLowerCase().includes("no search results");
|
|
if (task.status_code !== 20000 && !isNoResults) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
task.status_message || "DataForSEO task failed",
|
|
);
|
|
}
|
|
|
|
const parsedTask = successfulDataforseoTaskSchema.safeParse(task);
|
|
if (!parsedTask.success) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
`DataForSEO rank check task missing billing metadata`,
|
|
);
|
|
}
|
|
|
|
const items = z
|
|
.array(serpSnapshotItemSchema)
|
|
.parse(parsedTask.data.result?.[0]?.items ?? []);
|
|
|
|
const target = input.targetDomain.toLowerCase();
|
|
const organicMatch = items.find((item) => {
|
|
if (item.type !== "organic" || item.domain == null) return false;
|
|
const d = item.domain.toLowerCase();
|
|
return d === target || d.endsWith(`.${target}`);
|
|
});
|
|
|
|
return {
|
|
data: {
|
|
keywordId: input.keywordId,
|
|
keyword: input.keyword,
|
|
position: organicMatch
|
|
? (organicMatch.rank_absolute ?? organicMatch.rank_group ?? null)
|
|
: null,
|
|
url: organicMatch?.url ?? null,
|
|
serpFeatures: [
|
|
...new Set(items.map((item) => item.type).filter(Boolean)),
|
|
],
|
|
},
|
|
billing: buildTaskBilling(parsedTask.data),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DataForSEO Labs — Keyword Overview (batch up to 700 keywords)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function fetchKeywordOverviewRaw(
|
|
keywords: string[],
|
|
locationCode: number,
|
|
languageCode: string,
|
|
): Promise<DataforseoApiResponse<KeywordOverviewItem[]>> {
|
|
const responseRaw = await postDataforseo(
|
|
"/v3/dataforseo_labs/google/keyword_overview/live",
|
|
[
|
|
{
|
|
keywords,
|
|
location_code: locationCode,
|
|
language_code: languageCode,
|
|
},
|
|
],
|
|
);
|
|
|
|
const response = dataforseoResponseSchema.parse(responseRaw);
|
|
|
|
if (response.status_code !== 20000) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
response.status_message || "DataForSEO keyword overview request failed",
|
|
);
|
|
}
|
|
|
|
const task = response.tasks?.[0];
|
|
if (!task) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
"DataForSEO keyword overview response missing task",
|
|
);
|
|
}
|
|
|
|
if (task.status_code !== 20000) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
task.status_message || "DataForSEO keyword overview task failed",
|
|
);
|
|
}
|
|
|
|
const parsedTask = successfulDataforseoTaskSchema.safeParse(task);
|
|
if (!parsedTask.success) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
"DataForSEO keyword overview task missing billing metadata",
|
|
);
|
|
}
|
|
|
|
const data = parseTaskItems(
|
|
"google-keyword-overview-live",
|
|
parsedTask.data,
|
|
keywordOverviewItemSchema,
|
|
);
|
|
|
|
return {
|
|
data,
|
|
billing: buildTaskBilling(parsedTask.data),
|
|
};
|
|
}
|