398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
AiOptimizationChatGptLlmResponsesLiveRequestInfo,
|
|
AiOptimizationClaudeLlmResponsesLiveRequestInfo,
|
|
AiOptimizationGeminiLlmResponsesLiveRequestInfo,
|
|
AiOptimizationLLmMentionsCrossAggregateMetricsTargetInfo,
|
|
AiOptimizationLLmMentionsDomainElement,
|
|
AiOptimizationLLmMentionsKeywordElement,
|
|
AiOptimizationLlmMentionsAggregatedMetricsLiveRequestInfo,
|
|
AiOptimizationLlmMentionsCrossAggregatedMetricsLiveRequestInfo,
|
|
AiOptimizationLlmMentionsSearchLiveRequestInfo,
|
|
AiOptimizationLlmMentionsTopPagesLiveRequestInfo,
|
|
type BaseAiOptimizationLLmMentionsTargetElement,
|
|
type AiOptimizationPerplexityLlmResponsesLiveRequestInfo,
|
|
} from "dataforseo-client";
|
|
import {
|
|
llmAggregatedTotalSchema,
|
|
llmCrossAggregatedItemSchema,
|
|
llmMentionItemSchema,
|
|
llmResponseResultSchema,
|
|
llmTopPagesItemSchema,
|
|
type LlmAggregatedTotal,
|
|
type LlmCrossAggregatedItem,
|
|
type LlmMentionItem,
|
|
type LlmResponseResult,
|
|
type LlmTopPagesItem,
|
|
} from "@/server/lib/dataforseoLlmSchemas";
|
|
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
|
|
import { AppError } from "@/server/lib/errors";
|
|
import { aiOptimizationApi } from "@/server/lib/dataforseo/core";
|
|
import {
|
|
assertOk,
|
|
buildTaskBilling,
|
|
isRecord,
|
|
type DataforseoApiResponse,
|
|
type DataforseoTaskLike,
|
|
} from "@/server/lib/dataforseo/envelope";
|
|
|
|
// ChatGPT mention/response data is only available for US/en per DataForSEO docs.
|
|
export const CHATGPT_LOCATION_CODE = 2840;
|
|
export const CHATGPT_LANGUAGE_CODE = "en";
|
|
|
|
export type LlmPlatform = "chat_gpt" | "google";
|
|
|
|
const classifyAiSearchError = createDataforseoAccessClassifier({
|
|
pathPrefix: "/ai_optimization/",
|
|
notEnabledCode: "AI_SEARCH_NOT_ENABLED",
|
|
notEnabledMessage:
|
|
"AI Optimization is not enabled for the connected DataForSEO account",
|
|
billingIssueCode: "AI_SEARCH_BILLING_ISSUE",
|
|
billingIssueMessage:
|
|
"The connected DataForSEO account has a billing or balance issue",
|
|
});
|
|
|
|
const assertOptions = (path: string) =>
|
|
({ classify: classifyAiSearchError, classifyPath: path }) as const;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Target builders — DataForSEO's `target` array accepts domain OR keyword
|
|
// entries. We always pass exactly one target per call.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type LlmTarget =
|
|
| {
|
|
domain: string;
|
|
include_subdomains?: boolean;
|
|
search_filter?: "include" | "exclude";
|
|
search_scope?: string[];
|
|
}
|
|
| {
|
|
keyword: string;
|
|
search_filter?: "include" | "exclude";
|
|
search_scope?: string[];
|
|
match_type?: "word_match" | "partial_match";
|
|
};
|
|
|
|
export function buildLlmTarget(input: {
|
|
type: "domain" | "keyword";
|
|
value: string;
|
|
}): LlmTarget {
|
|
if (input.type === "domain") {
|
|
return {
|
|
domain: input.value,
|
|
include_subdomains: true,
|
|
search_filter: "include",
|
|
search_scope: ["any"],
|
|
};
|
|
}
|
|
return {
|
|
keyword: input.value,
|
|
search_filter: "include",
|
|
search_scope: ["any", "brand_entities"],
|
|
match_type: "word_match",
|
|
};
|
|
}
|
|
|
|
function clampLimit(value: number, min: number, max: number): number {
|
|
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
}
|
|
|
|
function targetList(
|
|
target: LlmTarget,
|
|
): BaseAiOptimizationLLmMentionsTargetElement[] {
|
|
return [
|
|
"domain" in target
|
|
? new AiOptimizationLLmMentionsDomainElement(target)
|
|
: new AiOptimizationLLmMentionsKeywordElement(target),
|
|
];
|
|
}
|
|
|
|
function firstResult(task: DataforseoTaskLike): Record<string, unknown> | null {
|
|
const first = task.result?.[0];
|
|
return isRecord(first) ? first : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LLM Mentions Search
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type LlmMentionsSearchInput = {
|
|
target: LlmTarget;
|
|
platform: LlmPlatform;
|
|
locationCode: number;
|
|
languageCode: string;
|
|
limit?: number;
|
|
};
|
|
|
|
export async function fetchLlmMentionsSearch(
|
|
input: LlmMentionsSearchInput,
|
|
): Promise<DataforseoApiResponse<LlmMentionItem[]>> {
|
|
const response = await aiOptimizationApi(
|
|
classifyAiSearchError,
|
|
).llmMentionsSearchLive([
|
|
new AiOptimizationLlmMentionsSearchLiveRequestInfo({
|
|
target: targetList(input.target),
|
|
platform: input.platform,
|
|
location_code: input.locationCode,
|
|
language_code: input.languageCode,
|
|
limit: clampLimit(input.limit ?? 100, 1, 1000),
|
|
}),
|
|
]);
|
|
const task = assertOk(
|
|
response,
|
|
assertOptions("/v3/ai_optimization/llm_mentions/search/live"),
|
|
);
|
|
|
|
const items = z
|
|
.array(llmMentionItemSchema)
|
|
.safeParse(firstResult(task)?.items ?? []);
|
|
if (!items.success) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
"DataForSEO llm_mentions/search returned an invalid mention items shape",
|
|
);
|
|
}
|
|
return { data: items.data, billing: buildTaskBilling(task) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LLM Mentions Aggregated Metrics
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type LlmAggregatedMetricsInput = {
|
|
target: LlmTarget;
|
|
platform: LlmPlatform;
|
|
locationCode: number;
|
|
languageCode: string;
|
|
internalListLimit?: number;
|
|
};
|
|
|
|
export async function fetchLlmAggregatedMetrics(
|
|
input: LlmAggregatedMetricsInput,
|
|
): Promise<DataforseoApiResponse<LlmAggregatedTotal>> {
|
|
const response = await aiOptimizationApi(
|
|
classifyAiSearchError,
|
|
).llmMentionsAggregatedMetricsLive([
|
|
new AiOptimizationLlmMentionsAggregatedMetricsLiveRequestInfo({
|
|
target: targetList(input.target),
|
|
platform: input.platform,
|
|
location_code: input.locationCode,
|
|
language_code: input.languageCode,
|
|
internal_list_limit: clampLimit(input.internalListLimit ?? 10, 1, 20),
|
|
}),
|
|
]);
|
|
const task = assertOk(
|
|
response,
|
|
assertOptions("/v3/ai_optimization/llm_mentions/aggregated_metrics/live"),
|
|
);
|
|
|
|
const total = llmAggregatedTotalSchema.safeParse(
|
|
firstResult(task)?.total ?? {},
|
|
);
|
|
if (!total.success) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
"DataForSEO llm_mentions/aggregated_metrics returned an invalid shape",
|
|
);
|
|
}
|
|
return { data: total.data, billing: buildTaskBilling(task) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LLM Mentions Top Pages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type LlmTopPagesInput = {
|
|
target: LlmTarget;
|
|
platform: LlmPlatform;
|
|
locationCode: number;
|
|
languageCode: string;
|
|
itemsListLimit?: number;
|
|
};
|
|
|
|
export async function fetchLlmTopPages(
|
|
input: LlmTopPagesInput,
|
|
): Promise<DataforseoApiResponse<LlmTopPagesItem[]>> {
|
|
const response = await aiOptimizationApi(
|
|
classifyAiSearchError,
|
|
).llmMentionsTopPagesLive([
|
|
new AiOptimizationLlmMentionsTopPagesLiveRequestInfo({
|
|
target: targetList(input.target),
|
|
platform: input.platform,
|
|
location_code: input.locationCode,
|
|
language_code: input.languageCode,
|
|
links_scope: "sources",
|
|
items_list_limit: clampLimit(input.itemsListLimit ?? 10, 1, 10),
|
|
internal_list_limit: 5,
|
|
}),
|
|
]);
|
|
const task = assertOk(
|
|
response,
|
|
assertOptions("/v3/ai_optimization/llm_mentions/top_pages/live"),
|
|
);
|
|
|
|
const items = z
|
|
.array(llmTopPagesItemSchema)
|
|
.safeParse(firstResult(task)?.items ?? []);
|
|
if (!items.success) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
"DataForSEO llm_mentions/top_pages returned an invalid shape",
|
|
);
|
|
}
|
|
return { data: items.data, billing: buildTaskBilling(task) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LLM Mentions Cross-Aggregated Metrics
|
|
// Compares 2..10 aggregation groups (target + competitors) in one call and
|
|
// returns one item per group, keyed by its aggregation_key (brand label).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type LlmCrossAggregatedMetricsInput = {
|
|
groups: Array<{ key: string; target: LlmTarget }>;
|
|
platform: LlmPlatform;
|
|
locationCode: number;
|
|
languageCode: string;
|
|
internalListLimit?: number;
|
|
};
|
|
|
|
export async function fetchLlmCrossAggregatedMetrics(
|
|
input: LlmCrossAggregatedMetricsInput,
|
|
): Promise<DataforseoApiResponse<LlmCrossAggregatedItem[]>> {
|
|
if (input.groups.length < 2 || input.groups.length > 10) {
|
|
throw new AppError(
|
|
"VALIDATION_ERROR",
|
|
"DataForSEO llm_mentions/cross_aggregated_metrics requires 2 to 10 target groups",
|
|
);
|
|
}
|
|
|
|
const response = await aiOptimizationApi(
|
|
classifyAiSearchError,
|
|
).llmMentionsCrossAggregatedMetricsLive([
|
|
new AiOptimizationLlmMentionsCrossAggregatedMetricsLiveRequestInfo({
|
|
targets: input.groups.map(
|
|
(group) =>
|
|
new AiOptimizationLLmMentionsCrossAggregateMetricsTargetInfo({
|
|
aggregation_key: group.key,
|
|
target: targetList(group.target),
|
|
}),
|
|
),
|
|
platform: input.platform,
|
|
location_code: input.locationCode,
|
|
language_code: input.languageCode,
|
|
internal_list_limit: clampLimit(input.internalListLimit ?? 5, 1, 10),
|
|
}),
|
|
]);
|
|
const task = assertOk(
|
|
response,
|
|
assertOptions(
|
|
"/v3/ai_optimization/llm_mentions/cross_aggregated_metrics/live",
|
|
),
|
|
);
|
|
|
|
const items = z
|
|
.array(llmCrossAggregatedItemSchema)
|
|
.safeParse(firstResult(task)?.items ?? []);
|
|
if (!items.success) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
"DataForSEO llm_mentions/cross_aggregated_metrics returned an invalid shape",
|
|
);
|
|
}
|
|
return { data: items.data, billing: buildTaskBilling(task) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LLM Responses (per-model)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type LlmResponseModelSlug = "chat_gpt" | "claude" | "gemini" | "perplexity";
|
|
|
|
type LlmResponsesInput = {
|
|
userPrompt: string;
|
|
modelSlug: LlmResponseModelSlug;
|
|
modelName: string;
|
|
webSearch?: boolean;
|
|
maxOutputTokens?: number;
|
|
/** Two-letter ISO country code used to geolocate the web-search component. */
|
|
webSearchCountryCode?: string;
|
|
};
|
|
|
|
type LlmResponseRequestFields = {
|
|
user_prompt: string;
|
|
model_name: string;
|
|
web_search: boolean;
|
|
max_output_tokens: number;
|
|
web_search_country_iso_code?: string;
|
|
};
|
|
|
|
function buildPerplexityLlmResponseRequest(
|
|
fields: LlmResponseRequestFields,
|
|
): AiOptimizationPerplexityLlmResponsesLiveRequestInfo {
|
|
return {
|
|
...fields,
|
|
init(data?: unknown) {
|
|
if (isRecord(data)) Object.assign(this, data);
|
|
},
|
|
toJSON(data?: unknown) {
|
|
return {
|
|
...(isRecord(data) ? data : {}),
|
|
...fields,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function fetchLlmResponse(
|
|
input: LlmResponsesInput,
|
|
): Promise<DataforseoApiResponse<LlmResponseResult>> {
|
|
// DataForSEO's Gemini endpoint rejects `web_search_country_iso_code` with a
|
|
// 40501 "Invalid Field" error. The other three models accept it.
|
|
const supportsCountry = input.modelSlug !== "gemini";
|
|
const fields: LlmResponseRequestFields = {
|
|
user_prompt: input.userPrompt,
|
|
model_name: input.modelName,
|
|
web_search: input.webSearch ?? true,
|
|
max_output_tokens: clampLimit(input.maxOutputTokens ?? 1024, 256, 4096),
|
|
...(supportsCountry && input.webSearchCountryCode
|
|
? { web_search_country_iso_code: input.webSearchCountryCode }
|
|
: {}),
|
|
};
|
|
|
|
const api = aiOptimizationApi(classifyAiSearchError);
|
|
const response =
|
|
input.modelSlug === "chat_gpt"
|
|
? await api.chatGptLlmResponsesLive([
|
|
new AiOptimizationChatGptLlmResponsesLiveRequestInfo(fields),
|
|
])
|
|
: input.modelSlug === "claude"
|
|
? await api.claudeLlmResponsesLive([
|
|
new AiOptimizationClaudeLlmResponsesLiveRequestInfo(fields),
|
|
])
|
|
: input.modelSlug === "gemini"
|
|
? await api.geminiLlmResponsesLive([
|
|
new AiOptimizationGeminiLlmResponsesLiveRequestInfo(fields),
|
|
])
|
|
: await api.perplexityLlmResponsesLive([
|
|
// The generated Perplexity request class drops `web_search` in
|
|
// toJSON(), while the SDK method only JSON.stringify's this body.
|
|
buildPerplexityLlmResponseRequest(fields),
|
|
]);
|
|
|
|
const task = assertOk(
|
|
response,
|
|
assertOptions(`/v3/ai_optimization/${input.modelSlug}/llm_responses/live`),
|
|
);
|
|
|
|
const result = llmResponseResultSchema.safeParse(firstResult(task) ?? {});
|
|
if (!result.success) {
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
"DataForSEO llm_responses returned an invalid response shape",
|
|
);
|
|
}
|
|
return { data: result.data, billing: buildTaskBilling(task) };
|
|
}
|