metatron-open-seo/src/server/lib/dataforseoSchemas.ts
Ben Senescu f3ef909d3e fix: handle empty DataForSEO task results (#84)
* fix: accept empty DataForSEO task results

Treat successful tasks with null items as empty payloads so empty ranked keyword responses do not fail billing validation.

* refactor: simplify DataForSEO null result handling

Add .nullable() to the existing structured result schema instead of
loosening the type to unknown[] and re-parsing in parseTaskItems.

* refactor: simplify backlinks zod parsing with structured result schema

Same pattern as the dataforseoSchemas fix: give taskSchema.result a
structured type with .items instead of z.unknown(), removing the
intermediate resultItemsSchema and two-step parsing in parseItems.

* refactor: replace manual type guards with zod schemas

- progress-kv.ts: replace isCrawledUrlEntry type guard with a zod
  schema and use jsonCodec(z.array(...)) instead of parsing unknown
  then filtering
- helpers.ts: tighten normalizeIntent param from unknown to
  string | null | undefined to match actual call sites
- dataforseoBacklinksSupport.ts: allow null result elements to match
  API responses where result contains [null]

* refactor: filter null result elements at the source

Filter out null elements from task.result in postBacklinks so
downstream functions receive clean BacklinksTaskResult[] instead
of (BacklinksTaskResult | null)[].

* save
2026-04-08 14:09:02 -04:00

218 lines
6.3 KiB
TypeScript

import { z } from "zod";
import { AppError } from "@/server/lib/errors";
const dataforseoTaskSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
path: z.array(z.string()),
cost: z.number(),
result_count: z.number().nullable(),
result: z
.array(
z
.object({
items: z.array(z.unknown()).nullable().optional(),
})
.passthrough(),
)
.nullable()
.optional(),
})
.passthrough();
export type DataforseoTask = z.infer<typeof dataforseoTaskSchema>;
export const successfulDataforseoTaskSchema = dataforseoTaskSchema;
export const dataforseoResponseSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
tasks: z.array(dataforseoTaskSchema).optional(),
})
.passthrough();
const monthlySearchSchema = z
.object({
year: z.number().int(),
month: z.number().int().min(1).max(12),
search_volume: z.number().nullable(),
})
.passthrough();
const keywordInfoSchema = z
.object({
search_volume: z.number().nullable().optional(),
cpc: z.number().nullable().optional(),
competition: z.number().nullable().optional(),
monthly_searches: z.array(monthlySearchSchema).nullable().optional(),
})
.passthrough();
const keywordInfoWithClickstreamSchema = z
.object({
search_volume: z.number().nullable().optional(),
monthly_searches: z.array(monthlySearchSchema).nullable().optional(),
})
.passthrough();
const searchIntentInfoSchema = z
.object({
main_intent: z.string().nullable().optional(),
})
.passthrough();
const keywordPropertiesSchema = z
.object({
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
export const relatedKeywordItemSchema = z
.object({
keyword_data: z
.object({
keyword: z.string().optional(),
keyword_info: keywordInfoSchema.optional(),
keyword_info_normalized_with_clickstream:
keywordInfoWithClickstreamSchema.optional(),
search_intent_info: searchIntentInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough(),
})
.passthrough();
export const labsKeywordDataItemSchema = z
.object({
keyword: z.string(),
keyword_info: keywordInfoSchema.optional(),
keyword_info_normalized_with_clickstream:
keywordInfoWithClickstreamSchema.optional(),
search_intent_info: searchIntentInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough();
const domainMetricsValueSchema = z
.object({
etv: z.number().nullable().optional(),
count: z.number().nullable().optional(),
})
.passthrough();
export const domainMetricsItemSchema = z
.object({
metrics: z.record(
z.string(),
domainMetricsValueSchema.nullable().optional(),
),
})
.passthrough();
const rankedKeywordInfoSchema = z
.object({
search_volume: z.number().nullable().optional(),
cpc: z.number().nullable().optional(),
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
const rankedKeywordDataSchema = z
.object({
keyword: z.string().nullable().optional(),
keyword_info: rankedKeywordInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough();
const rankedSerpItemSchema = z
.object({
url: z.string().nullable().optional(),
relative_url: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
})
.passthrough();
const rankedSerpElementSchema = z
.object({
serp_item: rankedSerpItemSchema.nullable().optional(),
url: z.string().nullable().optional(),
relative_url: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
})
.passthrough();
export const domainRankedKeywordItemSchema = z
.object({
keyword_data: rankedKeywordDataSchema.nullable().optional(),
ranked_serp_element: rankedSerpElementSchema.nullable().optional(),
keyword: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
export const serpSnapshotItemSchema = z
.object({
type: z.string(),
rank_group: z.number().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
domain: z.string().nullable().optional(),
title: z.string().nullable().optional(),
url: z.string().nullable().optional(),
description: z.string().nullable().optional(),
breadcrumb: z.string().nullable().optional(),
etv: z.number().nullable().optional(),
estimated_paid_traffic_cost: z.number().nullable().optional(),
backlinks_info: z
.object({
referring_domains: z.number().nullable().optional(),
backlinks: z.number().nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
rank_changes: z
.object({
previous_rank_absolute: z.number().nullable().optional(),
is_new: z.boolean().nullable().optional(),
is_up: z.boolean().nullable().optional(),
is_down: z.boolean().nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
})
.passthrough();
export type RelatedKeywordItem = z.infer<typeof relatedKeywordItemSchema>;
export type LabsKeywordDataItem = z.infer<typeof labsKeywordDataItemSchema>;
export type DomainMetricsItem = z.infer<typeof domainMetricsItemSchema>;
export type DomainRankedKeywordItem = z.infer<
typeof domainRankedKeywordItemSchema
>;
export type SerpLiveItem = z.infer<typeof serpSnapshotItemSchema>;
export function parseTaskItems<T extends z.ZodType>(
endpointName: string,
task: DataforseoTask,
itemSchema: T,
): z.infer<T>[] {
const parsed = z.array(itemSchema).safeParse(task.result?.[0]?.items ?? []);
if (!parsed.success) {
console.error(
`dataforseo.${endpointName}.invalid-payload`,
parsed.error.issues.slice(0, 5),
);
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${endpointName} returned an invalid response shape`,
);
}
return parsed.data;
}