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:
parent
aed666ef3c
commit
f3ef909d3e
@ -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";
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user