diff --git a/src/server/features/keywords/services/research/helpers.ts b/src/server/features/keywords/services/research/helpers.ts index bfbb99c..42f9efe 100644 --- a/src/server/features/keywords/services/research/helpers.ts +++ b/src/server/features/keywords/services/research/helpers.ts @@ -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"; diff --git a/src/server/lib/audit/progress-kv.ts b/src/server/lib/audit/progress-kv.ts index a246796..244c6cb 100644 --- a/src/server/lib/audit/progress-kv.ts +++ b/src/server/lib/audit/progress-kv.ts @@ -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; + +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 { diff --git a/src/server/lib/dataforseoBacklinks.ts b/src/server/lib/dataforseoBacklinks.ts index 0d99029..27c8a0f 100644 --- a/src/server/lib/dataforseoBacklinks.ts +++ b/src/server/lib/dataforseoBacklinks.ts @@ -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, diff --git a/src/server/lib/dataforseoBacklinksSupport.ts b/src/server/lib/dataforseoBacklinksSupport.ts index 272afc0..a6fe15e 100644 --- a/src/server/lib/dataforseoBacklinksSupport.ts +++ b/src/server/lib/dataforseoBacklinksSupport.ts @@ -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; + 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( endpointName: string, - results: unknown[], + results: BacklinksTaskResult[], itemSchema: T, ): Array> { const firstResult = results[0] ?? null; @@ -210,11 +214,7 @@ export function parseItems( 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( export function parseFirstResult( endpointName: string, - results: unknown[], + results: BacklinksTaskResult[], resultSchema: T, ): z.infer { const firstResult = results[0] ?? null; diff --git a/src/server/lib/dataforseoSchemas.test.ts b/src/server/lib/dataforseoSchemas.test.ts index f4b334f..079ae91 100644 --- a/src/server/lib/dataforseoSchemas.test.ts +++ b/src/server/lib/dataforseoSchemas.test.ts @@ -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([]); + }); }); diff --git a/src/server/lib/dataforseoSchemas.ts b/src/server/lib/dataforseoSchemas.ts index a459200..01b977a 100644 --- a/src/server/lib/dataforseoSchemas.ts +++ b/src/server/lib/dataforseoSchemas.ts @@ -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( task: DataforseoTask, itemSchema: T, ): z.infer[] { - 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`,