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
This commit is contained in:
Ben Senescu 2026-04-06 21:48:25 -04:00 committed by Ben Senescu
parent aed666ef3c
commit f3ef909d3e
6 changed files with 67 additions and 46 deletions

View File

@ -14,8 +14,8 @@ export function normalizeKeyword(input: string): string {
return input.trim().toLowerCase(); return input.trim().toLowerCase();
} }
export function normalizeIntent(raw: unknown): KeywordIntent { export function normalizeIntent(raw: string | null | undefined): KeywordIntent {
if (typeof raw !== "string") return "unknown"; if (!raw) return "unknown";
const value = raw.toLowerCase(); const value = raw.toLowerCase();
if (value.includes("inform")) return "informational"; if (value.includes("inform")) return "informational";
if (value.includes("commerc")) return "commercial"; if (value.includes("commerc")) return "commercial";

View File

@ -14,37 +14,23 @@ import { jsonCodec } from "@/shared/json";
const KV_PREFIX = "audit-progress:"; const KV_PREFIX = "audit-progress:";
const TTL_SECONDS = 30 * 60; // 30 minutes const TTL_SECONDS = 30 * 60; // 30 minutes
const MAX_ENTRIES = 300; const MAX_ENTRIES = 300;
const jsonUnknownCodec = jsonCodec(z.unknown());
interface CrawledUrlEntry { const crawledUrlEntrySchema = z.object({
url: string; url: z.string(),
statusCode: number; statusCode: z.number(),
title: string; title: z.string(),
/** Unix timestamp ms when this page was crawled */ /** Unix timestamp ms when this page was crawled */
crawledAt: number; crawledAt: z.number(),
} });
function isCrawledUrlEntry(value: unknown): value is CrawledUrlEntry { type CrawledUrlEntry = z.infer<typeof crawledUrlEntrySchema>;
if (!value || typeof value !== "object") return false;
const candidate = value as { const crawledEntriesCodec = jsonCodec(z.array(crawledUrlEntrySchema));
url?: unknown;
statusCode?: unknown;
title?: unknown;
crawledAt?: unknown;
};
return (
typeof candidate.url === "string" &&
typeof candidate.statusCode === "number" &&
typeof candidate.title === "string" &&
typeof candidate.crawledAt === "number"
);
}
function parseCrawledEntries(json: string | null): CrawledUrlEntry[] { function parseCrawledEntries(json: string | null): CrawledUrlEntry[] {
if (!json) return []; if (!json) return [];
const parsed = jsonUnknownCodec.safeParse(json); const parsed = crawledEntriesCodec.safeParse(json);
if (!parsed.success || !Array.isArray(parsed.data)) return []; return parsed.success ? parsed.data : [];
return parsed.data.filter(isCrawledUrlEntry);
} }
function key(auditId: string): string { function key(auditId: string): string {

View File

@ -5,6 +5,7 @@ import type {
} from "@/server/lib/dataforseoCost"; } from "@/server/lib/dataforseoCost";
import { getRequiredEnvValue } from "@/server/lib/runtime-env"; import { getRequiredEnvValue } from "@/server/lib/runtime-env";
import { import {
type BacklinksTaskResult,
backlinksHistoryItemSchema, backlinksHistoryItemSchema,
backlinksItemSchema, backlinksItemSchema,
backlinksSummaryItemSchema, backlinksSummaryItemSchema,
@ -34,7 +35,7 @@ export type BacklinksTimeseriesRequest = {
}; };
type DataforseoTaskResponse = { type DataforseoTaskResponse = {
results: unknown[]; results: BacklinksTaskResult[];
billing: DataforseoApiCallCost; billing: DataforseoApiCallCost;
}; };
@ -147,7 +148,9 @@ async function postBacklinks(path: string, payload: unknown) {
} }
return { return {
results: task.result ?? [], results: (task.result ?? []).filter(
(r): r is BacklinksTaskResult => r != null,
),
billing: { billing: {
path: task.path ?? [], path: task.path ?? [],
costUsd: task.cost ?? responseData.cost ?? 0, costUsd: task.cost ?? responseData.cost ?? 0,

View File

@ -1,6 +1,14 @@
import { z } from "zod"; import { z } from "zod";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
const taskResultSchema = z
.object({
items: z.array(z.unknown()).nullable().optional(),
})
.passthrough();
export type BacklinksTaskResult = z.infer<typeof taskResultSchema>;
const taskSchema = z const taskSchema = z
.object({ .object({
status_code: z.number().optional(), status_code: z.number().optional(),
@ -8,7 +16,7 @@ const taskSchema = z
cost: z.number().nullable().optional(), cost: z.number().nullable().optional(),
result_count: z.number().nullable().optional(), result_count: z.number().nullable().optional(),
path: z.array(z.string()).optional(), path: z.array(z.string()).optional(),
result: z.array(z.unknown()).nullable().optional(), result: z.array(taskResultSchema.nullable()).nullable().optional(),
}) })
.passthrough(); .passthrough();
@ -112,10 +120,6 @@ export const backlinksHistoryItemSchema = z
}) })
.passthrough(); .passthrough();
const resultItemsSchema = z.object({
items: z.array(z.unknown()).optional(),
});
export function classifyBacklinksError( export function classifyBacklinksError(
status: number | undefined, status: number | undefined,
details: string, details: string,
@ -201,7 +205,7 @@ export function classifyBacklinksError(
export function parseItems<T extends z.ZodTypeAny>( export function parseItems<T extends z.ZodTypeAny>(
endpointName: string, endpointName: string,
results: unknown[], results: BacklinksTaskResult[],
itemSchema: T, itemSchema: T,
): Array<z.infer<T>> { ): Array<z.infer<T>> {
const firstResult = results[0] ?? null; const firstResult = results[0] ?? null;
@ -210,11 +214,7 @@ export function parseItems<T extends z.ZodTypeAny>(
throw new AppError("VALIDATION_ERROR", "Backlinks target is invalid"); throw new AppError("VALIDATION_ERROR", "Backlinks target is invalid");
} }
const parsedItemsHolder = resultItemsSchema.safeParse(firstResult); const parsed = z.array(itemSchema).safeParse(firstResult.items ?? []);
const items = parsedItemsHolder.success
? (parsedItemsHolder.data.items ?? [])
: [];
const parsed = z.array(itemSchema).safeParse(items);
if (!parsed.success) { if (!parsed.success) {
console.error( console.error(
`dataforseo.${endpointName}.invalid-items`, `dataforseo.${endpointName}.invalid-items`,
@ -231,7 +231,7 @@ export function parseItems<T extends z.ZodTypeAny>(
export function parseFirstResult<T extends z.ZodTypeAny>( export function parseFirstResult<T extends z.ZodTypeAny>(
endpointName: string, endpointName: string,
results: unknown[], results: BacklinksTaskResult[],
resultSchema: T, resultSchema: T,
): z.infer<T> { ): z.infer<T> {
const firstResult = results[0] ?? null; const firstResult = results[0] ?? null;

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
domainRankedKeywordItemSchema,
parseTaskItems, parseTaskItems,
relatedKeywordItemSchema, relatedKeywordItemSchema,
successfulDataforseoTaskSchema, successfulDataforseoTaskSchema,
@ -37,4 +38,38 @@ describe("dataforseoSchemas", () => {
), ),
).toEqual([]); ).toEqual([]);
}); });
it("accepts empty ranked keyword tasks with null items", () => {
const task = {
id: "04070246-1577-0381-0000-2c56c059f67e",
status_code: 20000,
status_message: "Ok.",
path: ["v3", "dataforseo_labs", "google", "ranked_keywords", "live"],
cost: 0.01,
result_count: 1,
result: [
{
se_type: "google",
target: "openseo.so",
location_code: 2840,
language_code: "en",
total_count: null,
items_count: 0,
metrics: null,
metrics_absolute: null,
items: null,
},
],
};
const parsedTask = successfulDataforseoTaskSchema.parse(task);
expect(
parseTaskItems(
"google-ranked-keywords-live",
parsedTask,
domainRankedKeywordItemSchema,
),
).toEqual([]);
});
}); });

View File

@ -16,6 +16,7 @@ const dataforseoTaskSchema = z
}) })
.passthrough(), .passthrough(),
) )
.nullable()
.optional(), .optional(),
}) })
.passthrough(); .passthrough();
@ -31,10 +32,6 @@ export const dataforseoResponseSchema = z
}) })
.passthrough(); .passthrough();
function getTaskItems(task: DataforseoTask): unknown[] {
return task.result?.[0]?.items ?? [];
}
const monthlySearchSchema = z const monthlySearchSchema = z
.object({ .object({
year: z.number().int(), year: z.number().int(),
@ -205,7 +202,7 @@ export function parseTaskItems<T extends z.ZodType>(
task: DataforseoTask, task: DataforseoTask,
itemSchema: T, itemSchema: T,
): z.infer<T>[] { ): z.infer<T>[] {
const parsed = z.array(itemSchema).safeParse(getTaskItems(task)); const parsed = z.array(itemSchema).safeParse(task.result?.[0]?.items ?? []);
if (!parsed.success) { if (!parsed.success) {
console.error( console.error(
`dataforseo.${endpointName}.invalid-payload`, `dataforseo.${endpointName}.invalid-payload`,