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();
}
export function normalizeIntent(raw: unknown): KeywordIntent {
if (typeof raw !== "string") return "unknown";
export function normalizeIntent(raw: string | null | undefined): KeywordIntent {
if (!raw) return "unknown";
const value = raw.toLowerCase();
if (value.includes("inform")) return "informational";
if (value.includes("commerc")) return "commercial";

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
domainRankedKeywordItemSchema,
parseTaskItems,
relatedKeywordItemSchema,
successfulDataforseoTaskSchema,
@ -37,4 +38,38 @@ describe("dataforseoSchemas", () => {
),
).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(),
)
.nullable()
.optional(),
})
.passthrough();
@ -31,10 +32,6 @@ export const dataforseoResponseSchema = z
})
.passthrough();
function getTaskItems(task: DataforseoTask): unknown[] {
return task.result?.[0]?.items ?? [];
}
const monthlySearchSchema = z
.object({
year: z.number().int(),
@ -205,7 +202,7 @@ export function parseTaskItems<T extends z.ZodType>(
task: DataforseoTask,
itemSchema: 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) {
console.error(
`dataforseo.${endpointName}.invalid-payload`,