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