Enforce type-aware TypeScript linting and remove unsafe assertions (#15)

* Enforce safe TypeScript assertions and validate runtime payloads

* Fix CI knip config and floating promise lint

* Validate DataForSEO payloads with Zod schemas

Replace weak object guards with endpoint-level schema parsing so invalid API shapes fail fast instead of being silently filtered. Align downstream keyword mapping with the stricter validated payload contracts.
This commit is contained in:
Ben Senescu 2026-03-11 11:56:48 -04:00 committed by GitHub
parent a3b1f6e967
commit f75bbaeb37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 674 additions and 293 deletions

View File

@ -12,6 +12,11 @@
"unicorn/no-array-sort": "error", "unicorn/no-array-sort": "error",
"typescript/no-explicit-any": "error", "typescript/no-explicit-any": "error",
"typescript/consistent-type-imports": "error", "typescript/consistent-type-imports": "error",
"typescript/no-unsafe-type-assertion": "error",
"typescript/no-unnecessary-type-assertion": [
"error",
{ "checkLiteralConstAssertions": false }
],
"eslint/no-constant-binary-expression": "error", "eslint/no-constant-binary-expression": "error",
"eslint/no-self-assign": "error", "eslint/no-self-assign": "error",
"eslint/no-unreachable-loop": "error", "eslint/no-unreachable-loop": "error",

View File

@ -146,11 +146,13 @@ pnpm run db:migrate:local
``` ```
Configure .env.local: Configure .env.local:
1. `cp .env.example .env.local` 1. `cp .env.example .env.local`
2. Add `AUTH_MODE=local_noauth` so that it doesn't expect Cloudflare Access 2. Add `AUTH_MODE=local_noauth` so that it doesn't expect Cloudflare Access
3. Add `DATAFORSEO_API_KEY=yourkey` 3. Add `DATAFORSEO_API_KEY=yourkey`
Run Locally: Run Locally:
``` ```
# Option 1 # Option 1
pnpm run dev pnpm run dev

View File

@ -1,5 +1,5 @@
{ {
"ignoreBinaries": [], "ignoreBinaries": ["portless"],
"entry": [ "entry": [
// Detect Tanstack Start Routes // Detect Tanstack Start Routes
"src/router.tsx", "src/router.tsx",
@ -40,5 +40,6 @@
"daisyui", "daisyui",
"@tanstack/query-sync-storage-persister", "@tanstack/query-sync-storage-persister",
"@tanstack/react-query-persist-client", "@tanstack/react-query-persist-client",
"portless",
], ],
} }

View File

@ -7,8 +7,8 @@
"dev": "AUTH_MODE=local_noauth vite dev", "dev": "AUTH_MODE=local_noauth vite dev",
"dev:agents": "mkdir -p .logs && AUTH_MODE=local_noauth portless run vite dev 2>&1 | tee .logs/dev-server.log", "dev:agents": "mkdir -p .logs && AUTH_MODE=local_noauth portless run vite dev 2>&1 | tee .logs/dev-server.log",
"build": "vite build && tsc --noEmit", "build": "vite build && tsc --noEmit",
"lint": "oxlint .", "lint": "oxlint . --type-aware",
"lint:fix": "oxlint . --fix", "lint:fix": "oxlint . --type-aware --fix",
"preview": "npm run build && vite preview --port 3001", "preview": "npm run build && vite preview --port 3001",
"deploy": "npm run db:migrate:prod && npm run build && wrangler deploy", "deploy": "npm run db:migrate:prod && npm run build && wrangler deploy",
"cf-typegen": "wrangler types", "cf-typegen": "wrangler types",
@ -19,7 +19,7 @@
"db:migrate:local": "wrangler d1 migrations apply DB --local", "db:migrate:local": "wrangler d1 migrations apply DB --local",
"db:migrate:prod": "wrangler d1 migrations apply DB --remote", "db:migrate:prod": "wrangler d1 migrations apply DB --remote",
"knip": "knip", "knip": "knip",
"ci": "prettier --check . && knip && tsc --noEmit && oxlint ." "ci": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
}, },
"cloudflare": { "cloudflare": {
"bindings": { "bindings": {
@ -78,6 +78,7 @@
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"knip": "^5.66.4", "knip": "^5.66.4",
"oxlint": "^1.50.0", "oxlint": "^1.50.0",
"oxlint-tsgolint": "^0.15.0",
"portless": "^0.5.2", "portless": "^0.5.2",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",

69
pnpm-lock.yaml generated
View File

@ -122,7 +122,10 @@ importers:
version: 5.85.0(@types/node@22.19.11)(typescript@5.9.3) version: 5.85.0(@types/node@22.19.11)(typescript@5.9.3)
oxlint: oxlint:
specifier: ^1.50.0 specifier: ^1.50.0
version: 1.50.0 version: 1.50.0(oxlint-tsgolint@0.15.0)
oxlint-tsgolint:
specifier: ^0.15.0
version: 0.15.0
portless: portless:
specifier: ^0.5.2 specifier: ^0.5.2
version: 0.5.2 version: 0.5.2
@ -1146,6 +1149,36 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.15.0':
resolution: {integrity: sha512-d7Ch+A6hic+RYrm32+Gh1o4lOrQqnFsHi721ORdHUDBiQPea+dssKUEMwIbA6MKmCy6TVJ02sQyi24OEfCiGzw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.15.0':
resolution: {integrity: sha512-Aoai2wAkaUJqp/uEs1gml6TbaPW4YmyO5Ai/vOSkiizgHqVctjhjKqmRiWTX2xuPY94VkwOLqp+Qr3y/0qSpWQ==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.15.0':
resolution: {integrity: sha512-4og13a7ec4Vku5t2Y7s3zx6YJP6IKadb1uA9fOoRH6lm/wHWoCnxjcfJmKHXRZJII81WmbdJMSPxaBfwN/S68Q==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.15.0':
resolution: {integrity: sha512-9b9xzh/1Harn3a+XiKTK/8LrWw3VcqLfYp/vhV5/zAVR2Mt0d63WSp4FL+wG7DKnI2T/CbMFUFHwc7kCQjDMzQ==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.15.0':
resolution: {integrity: sha512-nNac5hewHdkk5mowOwTqB1ZD76zB/FsUiyUvdCyupq5cG54XyKqSLEp9QGbx7wFJkWCkeWmuwRed4sfpAlKaeA==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.15.0':
resolution: {integrity: sha512-ioAY2XLpy83E2EqOLH9p1cEgj0G2qB1lmAn0a3yFV1jHQB29LIPIKGNsu/tYCClpwmHN79pT5KZAHZOgWxxqNg==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.50.0': '@oxlint/binding-android-arm-eabi@1.50.0':
resolution: {integrity: sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg==} resolution: {integrity: sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -2602,6 +2635,10 @@ packages:
oxc-resolver@11.18.0: oxc-resolver@11.18.0:
resolution: {integrity: sha512-Fv/b05AfhpYoCDvsog6tgsDm2yIwIeJafpMFLncNwKHRYu+Y1xQu5Q/rgUn7xBfuhNgjtPO7C0jCf7p2fLDj1g==} resolution: {integrity: sha512-Fv/b05AfhpYoCDvsog6tgsDm2yIwIeJafpMFLncNwKHRYu+Y1xQu5Q/rgUn7xBfuhNgjtPO7C0jCf7p2fLDj1g==}
oxlint-tsgolint@0.15.0:
resolution: {integrity: sha512-iwvFmhKQVZzVTFygUVI4t2S/VKEm+Mqkw3jQRJwfDuTcUYI5LCIYzdO5Dbuv4mFOkXZCcXaRRh0m+uydB5xdqw==}
hasBin: true
oxlint@1.50.0: oxlint@1.50.0:
resolution: {integrity: sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ==} resolution: {integrity: sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -3772,6 +3809,24 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.18.0': '@oxc-resolver/binding-win32-x64-msvc@11.18.0':
optional: true optional: true
'@oxlint-tsgolint/darwin-arm64@0.15.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.15.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.15.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.15.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.15.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.15.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.50.0': '@oxlint/binding-android-arm-eabi@1.50.0':
optional: true optional: true
@ -5168,7 +5223,16 @@ snapshots:
'@oxc-resolver/binding-win32-ia32-msvc': 11.18.0 '@oxc-resolver/binding-win32-ia32-msvc': 11.18.0
'@oxc-resolver/binding-win32-x64-msvc': 11.18.0 '@oxc-resolver/binding-win32-x64-msvc': 11.18.0
oxlint@1.50.0: oxlint-tsgolint@0.15.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.15.0
'@oxlint-tsgolint/darwin-x64': 0.15.0
'@oxlint-tsgolint/linux-arm64': 0.15.0
'@oxlint-tsgolint/linux-x64': 0.15.0
'@oxlint-tsgolint/win32-arm64': 0.15.0
'@oxlint-tsgolint/win32-x64': 0.15.0
oxlint@1.50.0(oxlint-tsgolint@0.15.0):
optionalDependencies: optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.50.0 '@oxlint/binding-android-arm-eabi': 1.50.0
'@oxlint/binding-android-arm64': 1.50.0 '@oxlint/binding-android-arm64': 1.50.0
@ -5189,6 +5253,7 @@ snapshots:
'@oxlint/binding-win32-arm64-msvc': 1.50.0 '@oxlint/binding-win32-arm64-msvc': 1.50.0
'@oxlint/binding-win32-ia32-msvc': 1.50.0 '@oxlint/binding-win32-ia32-msvc': 1.50.0
'@oxlint/binding-win32-x64-msvc': 1.50.0 '@oxlint/binding-win32-x64-msvc': 1.50.0
oxlint-tsgolint: 0.15.0
parse5-htmlparser2-tree-adapter@7.1.0: parse5-htmlparser2-tree-adapter@7.1.0:
dependencies: dependencies:

View File

@ -26,7 +26,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
<AuthConfigErrorCard <AuthConfigErrorCard
message={message} message={message}
onRetry={() => { onRetry={() => {
router.invalidate(); void router.invalidate();
}} }}
/> />
</div> </div>
@ -39,7 +39,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
<div className="flex gap-2 items-center flex-wrap"> <div className="flex gap-2 items-center flex-wrap">
<button <button
onClick={() => { onClick={() => {
router.invalidate(); void router.invalidate();
}} }}
className="btn btn-neutral btn-sm uppercase" className="btn btn-neutral btn-sm uppercase"
> >

View File

@ -120,15 +120,24 @@ function LaunchView({
projectId: string; projectId: string;
onAuditStarted: (auditId: string) => void; onAuditStarted: (auditId: string) => void;
}) { }) {
type LaunchFormValues = {
url: string;
maxPagesInput: string;
runPsi: boolean;
psiMode: "auto" | "all";
};
const defaultLaunchValues: LaunchFormValues = {
url: "",
maxPagesInput: "50",
runPsi: false,
psiMode: "auto",
};
const minPages = 10; const minPages = 10;
const maxPagesLimit = 10_000; const maxPagesLimit = 10_000;
const launchForm = useForm({ const launchForm = useForm({
defaultValues: { defaultValues: defaultLaunchValues,
url: "",
maxPagesInput: "50",
runPsi: false,
psiMode: "auto" as "auto" | "all",
},
}); });
const settingsForm = useForm({ const settingsForm = useForm({
defaultValues: { defaultValues: {
@ -416,11 +425,11 @@ function LaunchView({
<select <select
className="select select-bordered select-xs" className="select select-bordered select-xs"
value={field.state.value} value={field.state.value}
onChange={(e) => onChange={(e) => {
field.handleChange( field.handleChange(
e.target.value as "auto" | "all", e.target.value === "all" ? "all" : "auto",
) );
} }}
> >
<option value="auto"> <option value="auto">
Auto sample (recommended) Auto sample (recommended)

View File

@ -91,7 +91,7 @@ function PsiIssuesPage() {
}); });
const visibleIssues = (issuesQuery.data?.issues ?? []) as PsiIssue[]; const visibleIssues = (issuesQuery.data?.issues ?? []) as PsiIssue[];
const allIssues = (summaryQuery.data?.issues ?? visibleIssues) as PsiIssue[]; const allIssues = summaryQuery.data?.issues ?? visibleIssues;
const categoryCounts = categoryTabs.reduce<Record<CategoryTab, number>>( const categoryCounts = categoryTabs.reduce<Record<CategoryTab, number>>(
(acc, tab) => { (acc, tab) => {

View File

@ -86,12 +86,14 @@ function DomainOverviewPage() {
const [overviewError, setOverviewError] = useState<string | null>(null); const [overviewError, setOverviewError] = useState<string | null>(null);
const [pendingSearch, setPendingSearch] = useState(searchText); const [pendingSearch, setPendingSearch] = useState(searchText);
const defaultControlValues: DomainControlsValues = {
domain: domainInput,
subdomains: includeSubdomains,
sort: sortMode,
};
const controlsForm = useForm({ const controlsForm = useForm({
defaultValues: { defaultValues: defaultControlValues,
domain: domainInput,
subdomains: includeSubdomains,
sort: sortMode,
} as DomainControlsValues,
}); });
const { const {
@ -490,8 +492,7 @@ function DomainOverviewPage() {
className="select select-bordered lg:col-span-2" className="select select-bordered lg:col-span-2"
value={field.state.value} value={field.state.value}
onChange={(e) => { onChange={(e) => {
const next = e.target const next = toSortMode(e.target.value) ?? "rank";
.value as DomainControlsValues["sort"];
field.handleChange(next); field.handleChange(next);
applySort(next, getDefaultSortOrder(next)); applySort(next, getDefaultSortOrder(next));
}} }}

View File

@ -77,6 +77,10 @@ const RESULT_LIMITS = [150, 300, 500] as const;
type ResultLimit = (typeof RESULT_LIMITS)[number]; type ResultLimit = (typeof RESULT_LIMITS)[number];
type KeywordSource = "related" | "suggestions" | "ideas"; type KeywordSource = "related" | "suggestions" | "ideas";
function isResultLimit(value: number): value is ResultLimit {
return value === 150 || value === 300 || value === 500;
}
type KeywordControlsValues = { type KeywordControlsValues = {
keyword: string; keyword: string;
locationCode: number; locationCode: number;
@ -200,12 +204,14 @@ function KeywordResearchPage() {
const [selectedKeyword, setSelectedKeyword] = const [selectedKeyword, setSelectedKeyword] =
useState<KeywordResearchRow | null>(null); useState<KeywordResearchRow | null>(null);
const defaultControlValues: KeywordControlsValues = {
keyword: keywordInput,
locationCode,
resultLimit,
};
const controlsForm = useForm({ const controlsForm = useForm({
defaultValues: { defaultValues: defaultControlValues,
keyword: keywordInput,
locationCode,
resultLimit,
} as KeywordControlsValues,
}); });
const [pendingInclude, setPendingInclude] = useState(includeText); const [pendingInclude, setPendingInclude] = useState(includeText);
const [pendingExclude, setPendingExclude] = useState(excludeText); const [pendingExclude, setPendingExclude] = useState(excludeText);
@ -713,9 +719,10 @@ function KeywordResearchPage() {
<select <select
className="select select-bordered select-sm w-auto" className="select select-bordered select-sm w-auto"
value={field.state.value} value={field.state.value}
onChange={(e) => onChange={(e) => {
field.handleChange(Number(e.target.value) as ResultLimit) const next = Number(e.target.value);
} field.handleChange(isResultLimit(next) ? next : 150);
}}
> >
{RESULT_LIMITS.map((limit) => ( {RESULT_LIMITS.map((limit) => (
<option key={limit} value={limit}> <option key={limit} value={limit}>

View File

@ -91,7 +91,7 @@ function PsiIssuesPage() {
}); });
const visibleIssues = (issuesQuery.data?.issues ?? []) as PsiIssue[]; const visibleIssues = (issuesQuery.data?.issues ?? []) as PsiIssue[];
const allIssues = (summaryQuery.data?.issues ?? visibleIssues) as PsiIssue[]; const allIssues = summaryQuery.data?.issues ?? visibleIssues;
const categoryCounts = categoryTabs.reduce<Record<CategoryTab, number>>( const categoryCounts = categoryTabs.reduce<Record<CategoryTab, number>>(
(acc, tab) => { (acc, tab) => {

View File

@ -79,18 +79,21 @@ function isProbablySitemapXml(
function getSitemapLocations(input: unknown): string[] { function getSitemapLocations(input: unknown): string[] {
if (!input) return []; if (!input) return [];
const entries: Array<{ loc?: string }> = Array.isArray(input) const entries = Array.isArray(input) ? input : [input];
? (input as Array<{ loc?: string }>)
: ([input] as Array<{ loc?: string }>);
return entries return entries
.map((entry) => entry.loc) .map((entry) => {
if (entry && typeof entry === "object" && "loc" in entry) {
const loc = entry.loc;
return typeof loc === "string" ? loc : null;
}
return null;
})
.filter((loc): loc is string => typeof loc === "string"); .filter((loc): loc is string => typeof loc === "string");
} }
function isTimeoutError(error: unknown): boolean { function isTimeoutError(error: unknown): boolean {
if (!error || typeof error !== "object") return false; if (!error || typeof error !== "object") return false;
const maybe = error as { name?: string }; return "name" in error && error.name === "TimeoutError";
return maybe.name === "TimeoutError";
} }
async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{ async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{

View File

@ -50,7 +50,10 @@ export function analyzeHtml(
const headingOrder: number[] = []; const headingOrder: number[] = [];
$("h1, h2, h3, h4, h5, h6").each((_, el) => { $("h1, h2, h3, h4, h5, h6").each((_, el) => {
const tag = (el as unknown as { tagName?: string }).tagName?.toLowerCase(); const tag =
"tagName" in el && typeof el.tagName === "string"
? el.tagName.toLowerCase()
: null;
if (tag) { if (tag) {
const level = parseInt(tag.charAt(1), 10); const level = parseInt(tag.charAt(1), 10);
if (!isNaN(level)) headingOrder.push(level); if (!isNaN(level)) headingOrder.push(level);

View File

@ -45,7 +45,7 @@ export async function fetchPsiResult(
throw new Error(`PSI API ${response.status}: ${text.slice(0, 200)}`); throw new Error(`PSI API ${response.status}: ${text.slice(0, 200)}`);
} }
const data = (await response.json()) as PsiApiResponse; const data: PsiApiResponse = await response.json();
return parsePsiResponse(data, url, pageId, strategy); return parsePsiResponse(data, url, pageId, strategy);
} catch (error) { } catch (error) {

View File

@ -153,7 +153,7 @@ async function resolveAddressRecords(
if (!response.ok) return []; if (!response.ok) return [];
const body = (await response.json()) as DnsJsonResponse; const body: DnsJsonResponse = await response.json();
if (body.Status !== 0 || !Array.isArray(body.Answer)) return []; if (body.Status !== 0 || !Array.isArray(body.Answer)) return [];
const expectedType = type === "A" ? 1 : 28; const expectedType = type === "A" ? 1 : 28;

View File

@ -8,6 +8,7 @@ import {
DataforseoLabsGoogleHistoricalSerpsLiveRequestInfo, DataforseoLabsGoogleHistoricalSerpsLiveRequestInfo,
} from "dataforseo-client"; } from "dataforseo-client";
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { z } from "zod";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -16,12 +17,12 @@ import { AppError } from "@/server/lib/errors";
function createAuthenticatedFetch() { function createAuthenticatedFetch() {
return (url: RequestInfo, init?: RequestInit): Promise<Response> => { return (url: RequestInfo, init?: RequestInit): Promise<Response> => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Basic ${env.DATAFORSEO_API_KEY}`);
const newInit: RequestInit = { const newInit: RequestInit = {
...init, ...init,
headers: { headers,
...init?.headers,
Authorization: `Basic ${env.DATAFORSEO_API_KEY}`,
},
}; };
return fetch(url, newInit); return fetch(url, newInit);
}; };
@ -73,36 +74,214 @@ function assertOk<T extends { status_code?: number; status_message?: string }>(
return task; return task;
} }
type DataforseoTaskResult = { items?: unknown[] };
type DataforseoTask = {
status_code?: number;
status_message?: string;
result?: DataforseoTaskResult[];
};
function getTaskItems(task: DataforseoTask): unknown[] {
return task.result?.[0]?.items ?? [];
}
const monthlySearchSchema = z
.object({
year: z.number().int(),
month: z.number().int().min(1).max(12),
search_volume: z.number().nullable(),
})
.passthrough();
const keywordInfoSchema = z
.object({
search_volume: z.number().nullable().optional(),
cpc: z.number().nullable().optional(),
competition: z.number().nullable().optional(),
monthly_searches: z.array(monthlySearchSchema).nullable().optional(),
})
.passthrough();
const keywordInfoWithClickstreamSchema = z
.object({
search_volume: z.number().nullable().optional(),
monthly_searches: z.array(monthlySearchSchema).nullable().optional(),
})
.passthrough();
const searchIntentInfoSchema = z
.object({
main_intent: z.string().nullable().optional(),
})
.passthrough();
const keywordPropertiesSchema = z
.object({
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
const relatedKeywordItemSchema = z
.object({
keyword_data: z
.object({
keyword: z.string().optional(),
keyword_info: keywordInfoSchema.optional(),
keyword_info_normalized_with_clickstream:
keywordInfoWithClickstreamSchema.optional(),
search_intent_info: searchIntentInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough(),
})
.passthrough();
const labsKeywordDataItemSchema = z
.object({
keyword: z.string(),
keyword_info: keywordInfoSchema.optional(),
keyword_info_normalized_with_clickstream:
keywordInfoWithClickstreamSchema.optional(),
search_intent_info: searchIntentInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough();
const domainMetricsValueSchema = z
.object({
etv: z.number().nullable().optional(),
count: z.number().nullable().optional(),
})
.passthrough();
const domainMetricsItemSchema = z
.object({
metrics: z.record(
z.string(),
domainMetricsValueSchema.nullable().optional(),
),
})
.passthrough();
const rankedKeywordInfoSchema = z
.object({
search_volume: z.number().nullable().optional(),
cpc: z.number().nullable().optional(),
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
const rankedKeywordDataSchema = z
.object({
keyword: z.string().nullable().optional(),
keyword_info: rankedKeywordInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough();
const rankedSerpItemSchema = z
.object({
url: z.string().nullable().optional(),
relative_url: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
})
.passthrough();
const rankedSerpElementSchema = z
.object({
serp_item: rankedSerpItemSchema.nullable().optional(),
url: z.string().nullable().optional(),
relative_url: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
})
.passthrough();
const domainRankedKeywordItemSchema = z
.object({
keyword_data: rankedKeywordDataSchema.nullable().optional(),
ranked_serp_element: rankedSerpElementSchema.nullable().optional(),
keyword: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
const serpSnapshotItemSchema = z
.object({
type: z.string(),
rank_group: z.number().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
domain: z.string().nullable().optional(),
title: z.string().nullable().optional(),
url: z.string().nullable().optional(),
description: z.string().nullable().optional(),
breadcrumb: z.string().nullable().optional(),
etv: z.number().nullable().optional(),
estimated_paid_traffic_cost: z.number().nullable().optional(),
backlinks_info: z
.object({
referring_domains: z.number().nullable().optional(),
backlinks: z.number().nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
rank_changes: z
.object({
previous_rank_absolute: z.number().nullable().optional(),
is_new: z.boolean().nullable().optional(),
is_up: z.boolean().nullable().optional(),
is_down: z.boolean().nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
})
.passthrough();
const serpSnapshotSchema = z
.object({
se_results_count: z.number().nullable().optional(),
items_count: z.number().nullable().optional(),
items: z.array(serpSnapshotItemSchema),
})
.passthrough();
type RelatedKeywordItem = z.infer<typeof relatedKeywordItemSchema>;
export type LabsKeywordDataItem = z.infer<typeof labsKeywordDataItemSchema>;
type DomainMetricsItem = z.infer<typeof domainMetricsItemSchema>;
export type DomainRankedKeywordItem = z.infer<
typeof domainRankedKeywordItemSchema
>;
type SerpSnapshot = z.infer<typeof serpSnapshotSchema>;
function parseTaskItems<T extends z.ZodType>(
endpointName: string,
task: DataforseoTask,
itemSchema: T,
): z.infer<T>[] {
const parsed = z.array(itemSchema).safeParse(getTaskItems(task));
if (!parsed.success) {
console.error(
`dataforseo.${endpointName}.invalid-payload`,
parsed.error.issues.slice(0, 5),
);
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${endpointName} returned an invalid response shape`,
);
}
return parsed.data;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DataForSEO Labs API wrappers // DataForSEO Labs API wrappers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type RelatedKeywordItem = {
keyword_data?: {
keyword?: string;
keyword_info?: {
search_volume?: number | null;
cpc?: number | null;
competition?: number | null;
monthly_searches?: Array<{
year: number;
month: number;
search_volume: number | null;
}> | null;
};
keyword_info_normalized_with_clickstream?: {
search_volume?: number | null;
monthly_searches?: Array<{
year: number;
month: number;
search_volume: number | null;
}> | null;
};
search_intent_info?: { main_intent?: string | null } | null;
keyword_properties?: { keyword_difficulty?: number | null } | null;
};
};
export async function fetchRelatedKeywordsRaw( export async function fetchRelatedKeywordsRaw(
keyword: string, keyword: string,
locationCode: number, locationCode: number,
@ -122,37 +301,14 @@ export async function fetchRelatedKeywordsRaw(
}); });
const response = await api.googleRelatedKeywordsLive([req]); const response = await api.googleRelatedKeywordsLive([req]);
const task = assertOk(response); const task = assertOk<DataforseoTask>(response);
return parseTaskItems(
const result = (task as { result?: Array<{ items?: unknown[] }> }) "google-related-keywords-live",
.result?.[0]; task,
return (result?.items ?? []) as RelatedKeywordItem[]; relatedKeywordItemSchema,
);
} }
export type LabsKeywordDataItem = {
keyword?: string;
keyword_info?: {
search_volume?: number | null;
cpc?: number | null;
competition?: number | null;
monthly_searches?: Array<{
year: number;
month: number;
search_volume: number | null;
}> | null;
};
keyword_info_normalized_with_clickstream?: {
search_volume?: number | null;
monthly_searches?: Array<{
year: number;
month: number;
search_volume: number | null;
}> | null;
};
search_intent_info?: { main_intent?: string | null } | null;
keyword_properties?: { keyword_difficulty?: number | null } | null;
};
export async function fetchKeywordSuggestionsRaw( export async function fetchKeywordSuggestionsRaw(
keyword: string, keyword: string,
locationCode: number, locationCode: number,
@ -173,11 +329,12 @@ export async function fetchKeywordSuggestionsRaw(
}); });
const response = await api.googleKeywordSuggestionsLive([req]); const response = await api.googleKeywordSuggestionsLive([req]);
const task = assertOk(response); const task = assertOk<DataforseoTask>(response);
return parseTaskItems(
const result = (task as { result?: Array<{ items?: unknown[] }> }) "google-keyword-suggestions-live",
.result?.[0]; task,
return (result?.items ?? []) as LabsKeywordDataItem[]; labsKeywordDataItemSchema,
);
} }
export async function fetchKeywordIdeasRaw( export async function fetchKeywordIdeasRaw(
@ -199,24 +356,18 @@ export async function fetchKeywordIdeasRaw(
}); });
const response = await api.googleKeywordIdeasLive([req]); const response = await api.googleKeywordIdeasLive([req]);
const task = assertOk(response); const task = assertOk<DataforseoTask>(response);
return parseTaskItems(
const result = (task as { result?: Array<{ items?: unknown[] }> }) "google-keyword-ideas-live",
.result?.[0]; task,
return (result?.items ?? []) as LabsKeywordDataItem[]; labsKeywordDataItemSchema,
);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Domain API wrappers // Domain API wrappers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type DomainMetricsItem = {
metrics?: Record<
string,
{ etv?: number | null; count?: number | null } | undefined
>;
};
export async function fetchDomainRankOverviewRaw( export async function fetchDomainRankOverviewRaw(
target: string, target: string,
locationCode: number, locationCode: number,
@ -231,43 +382,14 @@ export async function fetchDomainRankOverviewRaw(
}); });
const response = await api.googleDomainRankOverviewLive([req]); const response = await api.googleDomainRankOverviewLive([req]);
const task = assertOk(response); const task = assertOk<DataforseoTask>(response);
return parseTaskItems(
const result = (task as { result?: Array<{ items?: unknown[] }> }) "google-domain-rank-overview-live",
.result?.[0]; task,
return (result?.items ?? []) as DomainMetricsItem[]; domainMetricsItemSchema,
);
} }
export type DomainRankedKeywordItem = {
keyword_data?: {
keyword?: string | null;
keyword_info?: {
search_volume?: number | null;
cpc?: number | null;
keyword_difficulty?: number | null;
} | null;
keyword_properties?: {
keyword_difficulty?: number | null;
} | null;
} | null;
ranked_serp_element?: {
serp_item?: {
url?: string | null;
relative_url?: string | null;
rank_absolute?: number | null;
etv?: number | null;
} | null;
url?: string | null;
relative_url?: string | null;
rank_absolute?: number | null;
etv?: number | null;
} | null;
keyword?: string | null;
rank_absolute?: number | null;
etv?: number | null;
keyword_difficulty?: number | null;
};
export async function fetchRankedKeywordsRaw( export async function fetchRankedKeywordsRaw(
target: string, target: string,
locationCode: number, locationCode: number,
@ -285,46 +407,18 @@ export async function fetchRankedKeywordsRaw(
}); });
const response = await api.googleRankedKeywordsLive([req]); const response = await api.googleRankedKeywordsLive([req]);
const task = assertOk(response); const task = assertOk<DataforseoTask>(response);
return parseTaskItems(
const result = (task as { result?: Array<{ items?: unknown[] }> }) "google-ranked-keywords-live",
.result?.[0]; task,
return (result?.items ?? []) as DomainRankedKeywordItem[]; domainRankedKeywordItemSchema,
);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SERP Analysis API wrapper // SERP Analysis API wrapper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type SerpSnapshotItem = {
type?: string;
rank_group?: number | null;
rank_absolute?: number | null;
domain?: string | null;
title?: string | null;
url?: string | null;
description?: string | null;
breadcrumb?: string | null;
etv?: number | null;
estimated_paid_traffic_cost?: number | null;
backlinks_info?: {
referring_domains?: number | null;
backlinks?: number | null;
} | null;
rank_changes?: {
previous_rank_absolute?: number | null;
is_new?: boolean | null;
is_up?: boolean | null;
is_down?: boolean | null;
} | null;
};
type SerpSnapshot = {
se_results_count?: number | null;
items_count?: number | null;
items?: SerpSnapshotItem[];
};
export async function fetchHistoricalSerpsRaw( export async function fetchHistoricalSerpsRaw(
keyword: string, keyword: string,
locationCode: number, locationCode: number,
@ -338,11 +432,12 @@ export async function fetchHistoricalSerpsRaw(
}); });
const response = await api.googleHistoricalSerpsLive([req]); const response = await api.googleHistoricalSerpsLive([req]);
const task = assertOk(response); const task = assertOk<DataforseoTask>(response);
return parseTaskItems(
const result = (task as { result?: Array<{ items?: unknown[] }> }) "google-historical-serps-live",
.result?.[0]; task,
return (result?.items ?? []) as SerpSnapshot[]; serpSnapshotSchema,
);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -27,12 +27,12 @@ export function buildCacheKey(
/** /**
* Get a cached JSON value from KV. Returns null on miss. * Get a cached JSON value from KV. Returns null on miss.
*/ */
export async function getCached<T>(key: string): Promise<T | null> { export async function getCached(key: string): Promise<unknown> {
const value = await env.KV.get(key, "text"); const value = await env.KV.get(key, "text");
if (value === null) return null; if (value === null) return null;
try { try {
return JSON.parse(value) as T; return JSON.parse(value);
} catch { } catch {
return null; return null;
} }

View File

@ -9,6 +9,25 @@ import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types"; import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types";
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository"; import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository";
import { z } from "zod";
const auditConfigSchema = z.object({
maxPages: z.number().int().min(10).max(10_000),
psiStrategy: z.enum(["auto", "all", "manual", "none"]),
psiApiKey: z.string().optional(),
});
function parseAuditConfig(configRaw: string | null): AuditConfig | null {
if (!configRaw) return null;
try {
const parsed = JSON.parse(configRaw);
const result = auditConfigSchema.safeParse(parsed);
if (!result.success) return null;
return result.data;
} catch {
return null;
}
}
async function startAudit(input: { async function startAudit(input: {
userId: string; userId: string;
@ -103,7 +122,10 @@ async function getResults(auditId: string, userId: string) {
if (!audit) throw new AppError("NOT_FOUND"); if (!audit) throw new AppError("NOT_FOUND");
const parsedConfig = JSON.parse(audit.config) as AuditConfig; const parsedConfig = parseAuditConfig(audit.config);
if (!parsedConfig) {
throw new AppError("INTERNAL_ERROR", "Invalid audit configuration");
}
const { psiApiKey: _psiApiKey, ...safeConfig } = parsedConfig; const { psiApiKey: _psiApiKey, ...safeConfig } = parsedConfig;
return { return {
@ -137,13 +159,8 @@ async function getHistory(projectId: string, userId: string) {
); );
const didRunPsi = (configRaw: string | null) => { const didRunPsi = (configRaw: string | null) => {
if (!configRaw) return false; const parsed = parseAuditConfig(configRaw);
try { return parsed?.psiStrategy != null && parsed.psiStrategy !== "none";
const parsed = JSON.parse(configRaw) as Partial<AuditConfig>;
return parsed.psiStrategy != null && parsed.psiStrategy !== "none";
} catch {
return false;
}
}; };
return auditList.map((a) => ({ return auditList.map((a) => ({

View File

@ -7,6 +7,7 @@ import {
} from "@/server/lib/dataforseo"; } from "@/server/lib/dataforseo";
import { sortBy } from "remeda"; import { sortBy } from "remeda";
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache"; import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
import { z } from "zod";
/** Domain overview data is refreshed every 12 hours. */ /** Domain overview data is refreshed every 12 hours. */
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60; const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;
@ -38,6 +39,37 @@ type DomainOverviewResult = {
fetchedAt: string; fetchedAt: string;
}; };
const domainKeywordSchema = z.object({
keyword: z.string(),
position: z.number().nullable(),
searchVolume: z.number().nullable(),
traffic: z.number().nullable(),
cpc: z.number().nullable(),
url: z.string().nullable(),
relativeUrl: z.string().nullable(),
keywordDifficulty: z.number().nullable(),
});
const domainPageSchema = z.object({
page: z.string(),
relativePath: z.string().nullable(),
organicTraffic: z.number().nullable(),
keywords: z.number().nullable(),
backlinks: z.number().nullable(),
});
const domainOverviewSchema = z.object({
domain: z.string(),
organicTraffic: z.number().nullable(),
organicKeywords: z.number().nullable(),
backlinks: z.number().nullable(),
referringDomains: z.number().nullable(),
hasData: z.boolean(),
keywords: z.array(domainKeywordSchema),
pages: z.array(domainPageSchema),
fetchedAt: z.string(),
});
async function getOverview(input: { async function getOverview(input: {
domain: string; domain: string;
includeSubdomains: boolean; includeSubdomains: boolean;
@ -54,9 +86,10 @@ async function getOverview(input: {
languageCode: input.languageCode, languageCode: input.languageCode,
}); });
const cached = await getCached<DomainOverviewResult>(cacheKey); const cachedRaw = await getCached(cacheKey);
if (cached && cached.hasData) { const cached = domainOverviewSchema.safeParse(cachedRaw);
return cached; if (cached.success && cached.data.hasData) {
return cached.data;
} }
// --- Fetch fresh from DataForSEO --- // --- Fetch fresh from DataForSEO ---
@ -205,7 +238,7 @@ function derivePages(
relativePath: page.relativePath, relativePath: page.relativePath,
organicTraffic: page.traffic, organicTraffic: page.traffic,
keywords: page.keywords, keywords: page.keywords,
backlinks: null as number | null, backlinks: null,
})); }));
} }

View File

@ -28,6 +28,7 @@ import {
} from "@/server/lib/kv-cache"; } from "@/server/lib/kv-cache";
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository"; import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { z } from "zod";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@ -63,10 +64,58 @@ type EnrichedKeyword = {
type KeywordSource = "related" | "suggestions" | "ideas"; type KeywordSource = "related" | "suggestions" | "ideas";
const monthlySearchSchema = z.object({
year: z.number().int().positive(),
month: z.number().int().min(1).max(12),
searchVolume: z.number().int().nonnegative(),
});
const cachedKeywordRowSchema = z.object({
keyword: z.string(),
searchVolume: z.number().nullable(),
trend: z.array(monthlySearchSchema),
cpc: z.number().nullable(),
competition: z.number().nullable(),
keywordDifficulty: z.number().nullable(),
intent: z.enum([
"informational",
"commercial",
"transactional",
"navigational",
"unknown",
]),
});
const cachedResultSchema = z.object({
rows: z.array(cachedKeywordRowSchema),
source: z.enum(["related", "suggestions", "ideas"]).optional(),
usedFallback: z.boolean().optional(),
});
const serpResultItemSchema = z.object({
rank: z.number().int(),
title: z.string(),
url: z.string(),
domain: z.string(),
description: z.string(),
etv: z.number().nullable(),
estimatedPaidTrafficCost: z.number().nullable(),
referringDomains: z.number().nullable(),
backlinks: z.number().nullable(),
isNew: z.boolean(),
rankChange: z.number().nullable(),
});
const serpCacheSchema = z.object({
items: z.array(serpResultItemSchema),
});
function parseMonthlySearches(payload: string | null): MonthlySearch[] { function parseMonthlySearches(payload: string | null): MonthlySearch[] {
if (!payload) return []; if (!payload) return [];
try { try {
return JSON.parse(payload) as MonthlySearch[]; const parsed = JSON.parse(payload);
const result = z.array(monthlySearchSchema).safeParse(parsed);
return result.success ? result.data : [];
} catch (error) { } catch (error) {
console.error("keywords.saved.parse-monthly-searches failed:", error); console.error("keywords.saved.parse-monthly-searches failed:", error);
return []; return [];
@ -93,7 +142,8 @@ async function fetchRelatedKeywordsWithData(
const seen = new Set<string>(); const seen = new Set<string>();
for (const item of items) { for (const item of items) {
const kw = item.keyword_data?.keyword; const keywordData = item.keyword_data;
const kw = keywordData.keyword;
if (!kw) continue; if (!kw) continue;
const normalizedKw = normalizeKeyword(kw); const normalizedKw = normalizeKeyword(kw);
@ -101,10 +151,10 @@ async function fetchRelatedKeywordsWithData(
seen.add(normalizedKw); seen.add(normalizedKw);
// Use clickstream-normalized volume if available, otherwise fall back to regular // Use clickstream-normalized volume if available, otherwise fall back to regular
const keywordInfo = item.keyword_data const keywordInfo = keywordData.keyword_info_normalized_with_clickstream
?.keyword_info_normalized_with_clickstream?.search_volume ?.search_volume
? item.keyword_data?.keyword_info_normalized_with_clickstream ? keywordData.keyword_info_normalized_with_clickstream
: item.keyword_data?.keyword_info; : keywordData.keyword_info;
rows.push({ rows.push({
keyword: normalizedKw, keyword: normalizedKw,
@ -114,13 +164,11 @@ async function fetchRelatedKeywordsWithData(
month: m.month, month: m.month,
searchVolume: m.search_volume ?? 0, searchVolume: m.search_volume ?? 0,
})), })),
cpc: item.keyword_data?.keyword_info?.cpc ?? null, cpc: keywordData.keyword_info?.cpc ?? null,
competition: item.keyword_data?.keyword_info?.competition ?? null, competition: keywordData.keyword_info?.competition ?? null,
keywordDifficulty: keywordDifficulty:
item.keyword_data?.keyword_properties?.keyword_difficulty ?? null, keywordData.keyword_properties?.keyword_difficulty ?? null,
intent: normalizeIntent( intent: normalizeIntent(keywordData.search_intent_info?.main_intent),
item.keyword_data?.search_intent_info?.main_intent,
),
}); });
} }
@ -246,12 +294,9 @@ async function research(
depth: 3, // bump when depth changes to bust stale cache depth: 3, // bump when depth changes to bust stale cache
}); });
type CachedResult = { const cachedRaw = await getCached(cacheKey);
rows: EnrichedKeyword[]; const cachedResult = cachedResultSchema.safeParse(cachedRaw);
source?: KeywordSource; const cached = cachedResult.success ? cachedResult.data : null;
usedFallback?: boolean;
};
const cached = await getCached<CachedResult>(cacheKey);
// Only serve cached results that actually have metric data. Previous // Only serve cached results that actually have metric data. Previous
// failed fetches may have cached rows with all-zero volume/cpc/competition. // failed fetches may have cached rows with all-zero volume/cpc/competition.
@ -505,9 +550,10 @@ async function getSerpAnalysis(input: {
languageCode: input.languageCode, languageCode: input.languageCode,
}); });
const cached = await getCached<{ items: SerpResultItem[] }>(cacheKey); const cachedRaw = await getCached(cacheKey);
if (cached && cached.items.length > 0) { const cachedResult = serpCacheSchema.safeParse(cachedRaw);
return cached; if (cachedResult.success && cachedResult.data.items.length > 0) {
return cachedResult.data;
} }
const snapshots = await fetchHistoricalSerpsRaw( const snapshots = await fetchHistoricalSerpsRaw(

View File

@ -1,4 +1,5 @@
import { sortBy } from "remeda"; import { sortBy } from "remeda";
import { z } from "zod";
const PSI_CATEGORIES = [ const PSI_CATEGORIES = [
"performance", "performance",
@ -42,6 +43,50 @@ type LighthouseCategory = {
}>; }>;
}; };
const lighthouseAuditSchema = z.object({
title: z.string().optional(),
description: z.string().optional(),
score: z.number().nullable().optional(),
scoreDisplayMode: z.string().optional(),
displayValue: z.string().optional(),
details: z
.object({
overallSavingsMs: z.number().optional(),
overallSavingsBytes: z.number().optional(),
items: z.array(z.record(z.string(), z.unknown())).optional(),
})
.optional(),
});
const lighthouseCategorySchema = z.object({
auditRefs: z
.array(
z.object({
id: z.string().optional(),
}),
)
.optional(),
});
const psiPayloadSchema = z.object({
lighthouseResult: z
.object({
audits: z
.record(z.string(), lighthouseAuditSchema)
.optional()
.default({}),
categories: z
.record(z.string(), lighthouseCategorySchema)
.optional()
.default({}),
})
.optional()
.default({
audits: {},
categories: {},
}),
});
function normalizeScore(score: number | null | undefined): number | null { function normalizeScore(score: number | null | undefined): number | null {
if (score == null || Number.isNaN(score)) return null; if (score == null || Number.isNaN(score)) return null;
return Math.round(score * 100); return Math.round(score * 100);
@ -104,25 +149,22 @@ function parseIssues(
payloadJson: string, payloadJson: string,
categoryFilter?: PsiIssueCategory, categoryFilter?: PsiIssueCategory,
): PsiIssue[] { ): PsiIssue[] {
let payload: Record<string, unknown>; let payload: unknown;
try { try {
payload = JSON.parse(payloadJson) as Record<string, unknown>; payload = JSON.parse(payloadJson);
} catch { } catch {
throw new Error("Invalid Lighthouse payload JSON"); throw new Error("Invalid Lighthouse payload JSON");
} }
const lighthouseResult = (payload.lighthouseResult ?? {}) as Record< const parsedPayload = psiPayloadSchema.safeParse(payload);
string, if (!parsedPayload.success) {
unknown throw new Error("Invalid Lighthouse payload JSON");
>; }
const audits = (lighthouseResult.audits ?? {}) as Record<
string, const audits: Record<string, LighthouseAudit> =
LighthouseAudit parsedPayload.data.lighthouseResult.audits;
>; const categories: Record<string, LighthouseCategory> =
const categories = (lighthouseResult.categories ?? {}) as Record< parsedPayload.data.lighthouseResult.categories;
string,
LighthouseCategory
>;
const issues: PsiIssue[] = []; const issues: PsiIssue[] = [];
@ -158,7 +200,7 @@ function parseIssues(
: null; : null;
const items = Array.isArray(audit.details?.items) const items = Array.isArray(audit.details?.items)
? audit.details!.items!.slice(0, 10).map(compactItem) ? audit.details.items.slice(0, 10).map(compactItem)
: []; : [];
issues.push({ issues.push({

View File

@ -1,3 +1,5 @@
import { z } from "zod";
const PSI_ENDPOINT = const PSI_ENDPOINT =
"https://www.googleapis.com/pagespeedonline/v5/runPagespeed"; "https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
const PSI_CATEGORIES = [ const PSI_CATEGORIES = [
@ -40,6 +42,48 @@ type LighthouseAudit = {
numericValue?: number; numericValue?: number;
}; };
const lighthouseAuditSchema = z.object({
score: z.number().nullable().optional(),
displayValue: z.string().optional(),
numericValue: z.number().optional(),
});
const psiResponseSchema = z
.object({
lighthouseResult: z
.object({
finalDisplayedUrl: z.string().optional(),
lighthouseVersion: z.string().optional(),
categories: z
.record(
z.string(),
z.object({ score: z.number().nullable().optional() }),
)
.optional()
.default({}),
audits: z
.record(z.string(), lighthouseAuditSchema)
.optional()
.default({}),
})
.optional(),
})
.passthrough();
const psiErrorSchema = z
.object({
error: z
.object({
message: z.string().optional(),
})
.optional(),
})
.passthrough();
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
}
function normalizeInputUrl(input: string): string { function normalizeInputUrl(input: string): string {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) { if (!trimmed) {
@ -76,15 +120,9 @@ function asMetric(audit: LighthouseAudit | undefined): PsiAuditMetric {
} }
function extractErrorMessage(payload: unknown): string | null { function extractErrorMessage(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null; const parsed = psiErrorSchema.safeParse(payload);
if (!parsed.success) return null;
const asRecord = payload as Record<string, unknown>; return parsed.data.error?.message ?? null;
const error = asRecord.error;
if (!error || typeof error !== "object") return null;
const message = (error as Record<string, unknown>).message;
return typeof message === "string" ? message : null;
} }
async function runAudit(input: { async function runAudit(input: {
@ -110,43 +148,29 @@ async function runAudit(input: {
params.append("key", apiKey); params.append("key", apiKey);
const response = await fetch(`${PSI_ENDPOINT}?${params.toString()}`); const response = await fetch(`${PSI_ENDPOINT}?${params.toString()}`);
const payload = (await response.json().catch(() => null)) as Record< const payload: unknown = await response.json().catch(() => null);
string,
unknown
> | null;
if (!response.ok) { if (!response.ok) {
const message = extractErrorMessage(payload); const message = extractErrorMessage(payload);
throw new Error(message ?? `PSI request failed (${response.status})`); throw new Error(message ?? `PSI request failed (${response.status})`);
} }
const lighthouseResult = (payload?.lighthouseResult ?? null) as Record< const parsedPayload = psiResponseSchema.safeParse(payload);
string, if (!parsedPayload.success || !parsedPayload.data.lighthouseResult) {
unknown
> | null;
if (!lighthouseResult) {
throw new Error("PSI returned an invalid response"); throw new Error("PSI returned an invalid response");
} }
const categories = (lighthouseResult.categories ?? {}) as Record< const lighthouseResult = parsedPayload.data.lighthouseResult;
string,
{ score?: number | null } const categories = lighthouseResult.categories ?? {};
>; const audits: Record<string, LighthouseAudit> = lighthouseResult.audits ?? {};
const audits = (lighthouseResult.audits ?? {}) as Record<
string,
LighthouseAudit
>;
return { return {
requestedUrl: normalizedUrl, requestedUrl: normalizedUrl,
finalUrl: finalUrl: lighthouseResult.finalDisplayedUrl ?? normalizedUrl,
(lighthouseResult.finalDisplayedUrl as string | undefined) ??
normalizedUrl,
strategy: input.strategy, strategy: input.strategy,
fetchedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(),
lighthouseVersion: lighthouseVersion: lighthouseResult.lighthouseVersion ?? null,
(lighthouseResult.lighthouseVersion as string | undefined) ?? null,
scores: { scores: {
performance: asScore(categories.performance?.score), performance: asScore(categories.performance?.score),
accessibility: asScore(categories.accessibility?.score), accessibility: asScore(categories.accessibility?.score),
@ -161,7 +185,7 @@ async function runAudit(input: {
speedIndex: asMetric(audits["speed-index"]), speedIndex: asMetric(audits["speed-index"]),
timeToInteractive: asMetric(audits.interactive), timeToInteractive: asMetric(audits.interactive),
}, },
rawPayload: payload ?? {}, rawPayload: isRecord(payload) ? payload : {},
}; };
} }

View File

@ -27,17 +27,18 @@ export async function fetchResearchRows(
const seen = new Set<string>(); const seen = new Set<string>();
for (const item of items) { for (const item of items) {
const keyword = item.keyword_data?.keyword; const keywordData = item.keyword_data;
const keyword = keywordData.keyword;
if (!keyword) continue; if (!keyword) continue;
const normalizedKeyword = normalizeKeyword(keyword); const normalizedKeyword = normalizeKeyword(keyword);
if (seen.has(normalizedKeyword)) continue; if (seen.has(normalizedKeyword)) continue;
seen.add(normalizedKeyword); seen.add(normalizedKeyword);
const keywordInfo = item.keyword_data const keywordInfo = keywordData.keyword_info_normalized_with_clickstream
?.keyword_info_normalized_with_clickstream?.search_volume ?.search_volume
? item.keyword_data.keyword_info_normalized_with_clickstream ? keywordData.keyword_info_normalized_with_clickstream
: item.keyword_data?.keyword_info; : keywordData.keyword_info;
rows.push({ rows.push({
keyword: normalizedKeyword, keyword: normalizedKeyword,
@ -47,13 +48,11 @@ export async function fetchResearchRows(
month: entry.month, month: entry.month,
searchVolume: entry.search_volume ?? 0, searchVolume: entry.search_volume ?? 0,
})), })),
cpc: item.keyword_data?.keyword_info?.cpc ?? null, cpc: keywordData.keyword_info?.cpc ?? null,
competition: item.keyword_data?.keyword_info?.competition ?? null, competition: keywordData.keyword_info?.competition ?? null,
keywordDifficulty: keywordDifficulty:
item.keyword_data?.keyword_properties?.keyword_difficulty ?? null, keywordData.keyword_properties?.keyword_difficulty ?? null,
intent: normalizeIntent( intent: normalizeIntent(keywordData.search_intent_info?.main_intent),
item.keyword_data?.search_intent_info?.main_intent,
),
}); });
} }

View File

@ -7,11 +7,20 @@ import type {
} from "@/types/schemas/keywords"; } from "@/types/schemas/keywords";
import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords"; import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords";
import { normalizeKeyword } from "./helpers"; import { normalizeKeyword } from "./helpers";
import { z } from "zod";
const monthlySearchSchema = z.object({
year: z.number().int().positive(),
month: z.number().int().min(1).max(12),
searchVolume: z.number().int().nonnegative(),
});
function parseMonthlySearches(payload: string | null): MonthlySearch[] { function parseMonthlySearches(payload: string | null): MonthlySearch[] {
if (!payload) return []; if (!payload) return [];
try { try {
return JSON.parse(payload) as MonthlySearch[]; const parsed = JSON.parse(payload);
const result = z.array(monthlySearchSchema).safeParse(parsed);
return result.success ? result.data : [];
} catch (error) { } catch (error) {
console.error("keywords.saved.parse-monthly-searches failed:", error); console.error("keywords.saved.parse-monthly-searches failed:", error);
return []; return [];

View File

@ -3,9 +3,28 @@ import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
import type { SerpResultItem } from "@/types/keywords"; import type { SerpResultItem } from "@/types/keywords";
import { normalizeKeyword } from "./helpers"; import { normalizeKeyword } from "./helpers";
import { z } from "zod";
const SERP_CACHE_TTL_SECONDS = 12 * 60 * 60; const SERP_CACHE_TTL_SECONDS = 12 * 60 * 60;
const serpResultItemSchema = z.object({
rank: z.number().int(),
title: z.string(),
url: z.string(),
domain: z.string(),
description: z.string(),
etv: z.number().nullable(),
estimatedPaidTrafficCost: z.number().nullable(),
referringDomains: z.number().nullable(),
backlinks: z.number().nullable(),
isNew: z.boolean(),
rankChange: z.number().nullable(),
});
const serpCacheSchema = z.object({
items: z.array(serpResultItemSchema),
});
export async function getSerpAnalysis(input: { export async function getSerpAnalysis(input: {
keyword: string; keyword: string;
locationCode: number; locationCode: number;
@ -19,9 +38,10 @@ export async function getSerpAnalysis(input: {
languageCode: input.languageCode, languageCode: input.languageCode,
}); });
const cached = await getCached<{ items: SerpResultItem[] }>(cacheKey); const cachedRaw = await getCached(cacheKey);
if (cached && cached.items.length > 0) { const cached = serpCacheSchema.safeParse(cachedRaw);
return cached; if (cached.success && cached.data.items.length > 0) {
return cached.data;
} }
const snapshots = await fetchHistoricalSerpsRaw( const snapshots = await fetchHistoricalSerpsRaw(

View File

@ -13,7 +13,6 @@
"target": "ES2022", "target": "ES2022",
"allowJs": true, "allowJs": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
}, },