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:
parent
a3b1f6e967
commit
f75bbaeb37
@ -12,6 +12,11 @@
|
||||
"unicorn/no-array-sort": "error",
|
||||
"typescript/no-explicit-any": "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-self-assign": "error",
|
||||
"eslint/no-unreachable-loop": "error",
|
||||
|
||||
@ -146,11 +146,13 @@ pnpm run db:migrate:local
|
||||
```
|
||||
|
||||
Configure .env.local:
|
||||
|
||||
1. `cp .env.example .env.local`
|
||||
2. Add `AUTH_MODE=local_noauth` so that it doesn't expect Cloudflare Access
|
||||
3. Add `DATAFORSEO_API_KEY=yourkey`
|
||||
|
||||
Run Locally:
|
||||
|
||||
```
|
||||
# Option 1
|
||||
pnpm run dev
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"ignoreBinaries": [],
|
||||
"ignoreBinaries": ["portless"],
|
||||
"entry": [
|
||||
// Detect Tanstack Start Routes
|
||||
"src/router.tsx",
|
||||
@ -40,5 +40,6 @@
|
||||
"daisyui",
|
||||
"@tanstack/query-sync-storage-persister",
|
||||
"@tanstack/react-query-persist-client",
|
||||
"portless",
|
||||
],
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
"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",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"lint": "oxlint .",
|
||||
"lint:fix": "oxlint . --fix",
|
||||
"lint": "oxlint . --type-aware",
|
||||
"lint:fix": "oxlint . --type-aware --fix",
|
||||
"preview": "npm run build && vite preview --port 3001",
|
||||
"deploy": "npm run db:migrate:prod && npm run build && wrangler deploy",
|
||||
"cf-typegen": "wrangler types",
|
||||
@ -19,7 +19,7 @@
|
||||
"db:migrate:local": "wrangler d1 migrations apply DB --local",
|
||||
"db:migrate:prod": "wrangler d1 migrations apply DB --remote",
|
||||
"knip": "knip",
|
||||
"ci": "prettier --check . && knip && tsc --noEmit && oxlint ."
|
||||
"ci": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
|
||||
},
|
||||
"cloudflare": {
|
||||
"bindings": {
|
||||
@ -78,6 +78,7 @@
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"knip": "^5.66.4",
|
||||
"oxlint": "^1.50.0",
|
||||
"oxlint-tsgolint": "^0.15.0",
|
||||
"portless": "^0.5.2",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
69
pnpm-lock.yaml
generated
69
pnpm-lock.yaml
generated
@ -122,7 +122,10 @@ importers:
|
||||
version: 5.85.0(@types/node@22.19.11)(typescript@5.9.3)
|
||||
oxlint:
|
||||
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:
|
||||
specifier: ^0.5.2
|
||||
version: 0.5.2
|
||||
@ -1146,6 +1149,36 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@ -2602,6 +2635,10 @@ packages:
|
||||
oxc-resolver@11.18.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@ -3772,6 +3809,24 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.18.0':
|
||||
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':
|
||||
optional: true
|
||||
|
||||
@ -5168,7 +5223,16 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-ia32-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:
|
||||
'@oxlint/binding-android-arm-eabi': 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-ia32-msvc': 1.50.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.50.0
|
||||
oxlint-tsgolint: 0.15.0
|
||||
|
||||
parse5-htmlparser2-tree-adapter@7.1.0:
|
||||
dependencies:
|
||||
|
||||
@ -26,7 +26,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
<AuthConfigErrorCard
|
||||
message={message}
|
||||
onRetry={() => {
|
||||
router.invalidate();
|
||||
void router.invalidate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -39,7 +39,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.invalidate();
|
||||
void router.invalidate();
|
||||
}}
|
||||
className="btn btn-neutral btn-sm uppercase"
|
||||
>
|
||||
|
||||
@ -120,15 +120,24 @@ function LaunchView({
|
||||
projectId: string;
|
||||
onAuditStarted: (auditId: string) => void;
|
||||
}) {
|
||||
const minPages = 10;
|
||||
const maxPagesLimit = 10_000;
|
||||
const launchForm = useForm({
|
||||
defaultValues: {
|
||||
type LaunchFormValues = {
|
||||
url: string;
|
||||
maxPagesInput: string;
|
||||
runPsi: boolean;
|
||||
psiMode: "auto" | "all";
|
||||
};
|
||||
|
||||
const defaultLaunchValues: LaunchFormValues = {
|
||||
url: "",
|
||||
maxPagesInput: "50",
|
||||
runPsi: false,
|
||||
psiMode: "auto" as "auto" | "all",
|
||||
},
|
||||
psiMode: "auto",
|
||||
};
|
||||
|
||||
const minPages = 10;
|
||||
const maxPagesLimit = 10_000;
|
||||
const launchForm = useForm({
|
||||
defaultValues: defaultLaunchValues,
|
||||
});
|
||||
const settingsForm = useForm({
|
||||
defaultValues: {
|
||||
@ -416,11 +425,11 @@ function LaunchView({
|
||||
<select
|
||||
className="select select-bordered select-xs"
|
||||
value={field.state.value}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
field.handleChange(
|
||||
e.target.value as "auto" | "all",
|
||||
)
|
||||
}
|
||||
e.target.value === "all" ? "all" : "auto",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="auto">
|
||||
Auto sample (recommended)
|
||||
|
||||
@ -91,7 +91,7 @@ function PsiIssuesPage() {
|
||||
});
|
||||
|
||||
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>>(
|
||||
(acc, tab) => {
|
||||
|
||||
@ -86,12 +86,14 @@ function DomainOverviewPage() {
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
const [pendingSearch, setPendingSearch] = useState(searchText);
|
||||
|
||||
const controlsForm = useForm({
|
||||
defaultValues: {
|
||||
const defaultControlValues: DomainControlsValues = {
|
||||
domain: domainInput,
|
||||
subdomains: includeSubdomains,
|
||||
sort: sortMode,
|
||||
} as DomainControlsValues,
|
||||
};
|
||||
|
||||
const controlsForm = useForm({
|
||||
defaultValues: defaultControlValues,
|
||||
});
|
||||
|
||||
const {
|
||||
@ -490,8 +492,7 @@ function DomainOverviewPage() {
|
||||
className="select select-bordered lg:col-span-2"
|
||||
value={field.state.value}
|
||||
onChange={(e) => {
|
||||
const next = e.target
|
||||
.value as DomainControlsValues["sort"];
|
||||
const next = toSortMode(e.target.value) ?? "rank";
|
||||
field.handleChange(next);
|
||||
applySort(next, getDefaultSortOrder(next));
|
||||
}}
|
||||
|
||||
@ -77,6 +77,10 @@ const RESULT_LIMITS = [150, 300, 500] as const;
|
||||
type ResultLimit = (typeof RESULT_LIMITS)[number];
|
||||
type KeywordSource = "related" | "suggestions" | "ideas";
|
||||
|
||||
function isResultLimit(value: number): value is ResultLimit {
|
||||
return value === 150 || value === 300 || value === 500;
|
||||
}
|
||||
|
||||
type KeywordControlsValues = {
|
||||
keyword: string;
|
||||
locationCode: number;
|
||||
@ -200,12 +204,14 @@ function KeywordResearchPage() {
|
||||
const [selectedKeyword, setSelectedKeyword] =
|
||||
useState<KeywordResearchRow | null>(null);
|
||||
|
||||
const controlsForm = useForm({
|
||||
defaultValues: {
|
||||
const defaultControlValues: KeywordControlsValues = {
|
||||
keyword: keywordInput,
|
||||
locationCode,
|
||||
resultLimit,
|
||||
} as KeywordControlsValues,
|
||||
};
|
||||
|
||||
const controlsForm = useForm({
|
||||
defaultValues: defaultControlValues,
|
||||
});
|
||||
const [pendingInclude, setPendingInclude] = useState(includeText);
|
||||
const [pendingExclude, setPendingExclude] = useState(excludeText);
|
||||
@ -713,9 +719,10 @@ function KeywordResearchPage() {
|
||||
<select
|
||||
className="select select-bordered select-sm w-auto"
|
||||
value={field.state.value}
|
||||
onChange={(e) =>
|
||||
field.handleChange(Number(e.target.value) as ResultLimit)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
field.handleChange(isResultLimit(next) ? next : 150);
|
||||
}}
|
||||
>
|
||||
{RESULT_LIMITS.map((limit) => (
|
||||
<option key={limit} value={limit}>
|
||||
|
||||
@ -91,7 +91,7 @@ function PsiIssuesPage() {
|
||||
});
|
||||
|
||||
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>>(
|
||||
(acc, tab) => {
|
||||
|
||||
@ -79,18 +79,21 @@ function isProbablySitemapXml(
|
||||
|
||||
function getSitemapLocations(input: unknown): string[] {
|
||||
if (!input) return [];
|
||||
const entries: Array<{ loc?: string }> = Array.isArray(input)
|
||||
? (input as Array<{ loc?: string }>)
|
||||
: ([input] as Array<{ loc?: string }>);
|
||||
const entries = Array.isArray(input) ? input : [input];
|
||||
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");
|
||||
}
|
||||
|
||||
function isTimeoutError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const maybe = error as { name?: string };
|
||||
return maybe.name === "TimeoutError";
|
||||
return "name" in error && error.name === "TimeoutError";
|
||||
}
|
||||
|
||||
async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{
|
||||
|
||||
@ -50,7 +50,10 @@ export function analyzeHtml(
|
||||
|
||||
const headingOrder: number[] = [];
|
||||
$("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) {
|
||||
const level = parseInt(tag.charAt(1), 10);
|
||||
if (!isNaN(level)) headingOrder.push(level);
|
||||
|
||||
@ -45,7 +45,7 @@ export async function fetchPsiResult(
|
||||
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);
|
||||
} catch (error) {
|
||||
|
||||
@ -153,7 +153,7 @@ async function resolveAddressRecords(
|
||||
|
||||
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 [];
|
||||
|
||||
const expectedType = type === "A" ? 1 : 28;
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
DataforseoLabsGoogleHistoricalSerpsLiveRequestInfo,
|
||||
} from "dataforseo-client";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { z } from "zod";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -16,12 +17,12 @@ import { AppError } from "@/server/lib/errors";
|
||||
|
||||
function createAuthenticatedFetch() {
|
||||
return (url: RequestInfo, init?: RequestInit): Promise<Response> => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Basic ${env.DATAFORSEO_API_KEY}`);
|
||||
|
||||
const newInit: RequestInit = {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Authorization: `Basic ${env.DATAFORSEO_API_KEY}`,
|
||||
},
|
||||
headers,
|
||||
};
|
||||
return fetch(url, newInit);
|
||||
};
|
||||
@ -73,36 +74,214 @@ function assertOk<T extends { status_code?: number; status_message?: string }>(
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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(
|
||||
keyword: string,
|
||||
locationCode: number,
|
||||
@ -122,37 +301,14 @@ export async function fetchRelatedKeywordsRaw(
|
||||
});
|
||||
|
||||
const response = await api.googleRelatedKeywordsLive([req]);
|
||||
const task = assertOk(response);
|
||||
|
||||
const result = (task as { result?: Array<{ items?: unknown[] }> })
|
||||
.result?.[0];
|
||||
return (result?.items ?? []) as RelatedKeywordItem[];
|
||||
const task = assertOk<DataforseoTask>(response);
|
||||
return parseTaskItems(
|
||||
"google-related-keywords-live",
|
||||
task,
|
||||
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(
|
||||
keyword: string,
|
||||
locationCode: number,
|
||||
@ -173,11 +329,12 @@ export async function fetchKeywordSuggestionsRaw(
|
||||
});
|
||||
|
||||
const response = await api.googleKeywordSuggestionsLive([req]);
|
||||
const task = assertOk(response);
|
||||
|
||||
const result = (task as { result?: Array<{ items?: unknown[] }> })
|
||||
.result?.[0];
|
||||
return (result?.items ?? []) as LabsKeywordDataItem[];
|
||||
const task = assertOk<DataforseoTask>(response);
|
||||
return parseTaskItems(
|
||||
"google-keyword-suggestions-live",
|
||||
task,
|
||||
labsKeywordDataItemSchema,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchKeywordIdeasRaw(
|
||||
@ -199,24 +356,18 @@ export async function fetchKeywordIdeasRaw(
|
||||
});
|
||||
|
||||
const response = await api.googleKeywordIdeasLive([req]);
|
||||
const task = assertOk(response);
|
||||
|
||||
const result = (task as { result?: Array<{ items?: unknown[] }> })
|
||||
.result?.[0];
|
||||
return (result?.items ?? []) as LabsKeywordDataItem[];
|
||||
const task = assertOk<DataforseoTask>(response);
|
||||
return parseTaskItems(
|
||||
"google-keyword-ideas-live",
|
||||
task,
|
||||
labsKeywordDataItemSchema,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain API wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DomainMetricsItem = {
|
||||
metrics?: Record<
|
||||
string,
|
||||
{ etv?: number | null; count?: number | null } | undefined
|
||||
>;
|
||||
};
|
||||
|
||||
export async function fetchDomainRankOverviewRaw(
|
||||
target: string,
|
||||
locationCode: number,
|
||||
@ -231,43 +382,14 @@ export async function fetchDomainRankOverviewRaw(
|
||||
});
|
||||
|
||||
const response = await api.googleDomainRankOverviewLive([req]);
|
||||
const task = assertOk(response);
|
||||
|
||||
const result = (task as { result?: Array<{ items?: unknown[] }> })
|
||||
.result?.[0];
|
||||
return (result?.items ?? []) as DomainMetricsItem[];
|
||||
const task = assertOk<DataforseoTask>(response);
|
||||
return parseTaskItems(
|
||||
"google-domain-rank-overview-live",
|
||||
task,
|
||||
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(
|
||||
target: string,
|
||||
locationCode: number,
|
||||
@ -285,46 +407,18 @@ export async function fetchRankedKeywordsRaw(
|
||||
});
|
||||
|
||||
const response = await api.googleRankedKeywordsLive([req]);
|
||||
const task = assertOk(response);
|
||||
|
||||
const result = (task as { result?: Array<{ items?: unknown[] }> })
|
||||
.result?.[0];
|
||||
return (result?.items ?? []) as DomainRankedKeywordItem[];
|
||||
const task = assertOk<DataforseoTask>(response);
|
||||
return parseTaskItems(
|
||||
"google-ranked-keywords-live",
|
||||
task,
|
||||
domainRankedKeywordItemSchema,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(
|
||||
keyword: string,
|
||||
locationCode: number,
|
||||
@ -338,11 +432,12 @@ export async function fetchHistoricalSerpsRaw(
|
||||
});
|
||||
|
||||
const response = await api.googleHistoricalSerpsLive([req]);
|
||||
const task = assertOk(response);
|
||||
|
||||
const result = (task as { result?: Array<{ items?: unknown[] }> })
|
||||
.result?.[0];
|
||||
return (result?.items ?? []) as SerpSnapshot[];
|
||||
const task = assertOk<DataforseoTask>(response);
|
||||
return parseTaskItems(
|
||||
"google-historical-serps-live",
|
||||
task,
|
||||
serpSnapshotSchema,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -27,12 +27,12 @@ export function buildCacheKey(
|
||||
/**
|
||||
* 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");
|
||||
if (value === null) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -9,6 +9,25 @@ import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types";
|
||||
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: {
|
||||
userId: string;
|
||||
@ -103,7 +122,10 @@ async function getResults(auditId: string, userId: string) {
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
@ -137,13 +159,8 @@ async function getHistory(projectId: string, userId: string) {
|
||||
);
|
||||
|
||||
const didRunPsi = (configRaw: string | null) => {
|
||||
if (!configRaw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(configRaw) as Partial<AuditConfig>;
|
||||
return parsed.psiStrategy != null && parsed.psiStrategy !== "none";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const parsed = parseAuditConfig(configRaw);
|
||||
return parsed?.psiStrategy != null && parsed.psiStrategy !== "none";
|
||||
};
|
||||
|
||||
return auditList.map((a) => ({
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
} from "@/server/lib/dataforseo";
|
||||
import { sortBy } from "remeda";
|
||||
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Domain overview data is refreshed every 12 hours. */
|
||||
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;
|
||||
@ -38,6 +39,37 @@ type DomainOverviewResult = {
|
||||
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: {
|
||||
domain: string;
|
||||
includeSubdomains: boolean;
|
||||
@ -54,9 +86,10 @@ async function getOverview(input: {
|
||||
languageCode: input.languageCode,
|
||||
});
|
||||
|
||||
const cached = await getCached<DomainOverviewResult>(cacheKey);
|
||||
if (cached && cached.hasData) {
|
||||
return cached;
|
||||
const cachedRaw = await getCached(cacheKey);
|
||||
const cached = domainOverviewSchema.safeParse(cachedRaw);
|
||||
if (cached.success && cached.data.hasData) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// --- Fetch fresh from DataForSEO ---
|
||||
@ -205,7 +238,7 @@ function derivePages(
|
||||
relativePath: page.relativePath,
|
||||
organicTraffic: page.traffic,
|
||||
keywords: page.keywords,
|
||||
backlinks: null as number | null,
|
||||
backlinks: null,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
} from "@/server/lib/kv-cache";
|
||||
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@ -63,10 +64,58 @@ type EnrichedKeyword = {
|
||||
|
||||
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[] {
|
||||
if (!payload) return [];
|
||||
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) {
|
||||
console.error("keywords.saved.parse-monthly-searches failed:", error);
|
||||
return [];
|
||||
@ -93,7 +142,8 @@ async function fetchRelatedKeywordsWithData(
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
const kw = item.keyword_data?.keyword;
|
||||
const keywordData = item.keyword_data;
|
||||
const kw = keywordData.keyword;
|
||||
if (!kw) continue;
|
||||
|
||||
const normalizedKw = normalizeKeyword(kw);
|
||||
@ -101,10 +151,10 @@ async function fetchRelatedKeywordsWithData(
|
||||
seen.add(normalizedKw);
|
||||
|
||||
// Use clickstream-normalized volume if available, otherwise fall back to regular
|
||||
const keywordInfo = item.keyword_data
|
||||
?.keyword_info_normalized_with_clickstream?.search_volume
|
||||
? item.keyword_data?.keyword_info_normalized_with_clickstream
|
||||
: item.keyword_data?.keyword_info;
|
||||
const keywordInfo = keywordData.keyword_info_normalized_with_clickstream
|
||||
?.search_volume
|
||||
? keywordData.keyword_info_normalized_with_clickstream
|
||||
: keywordData.keyword_info;
|
||||
|
||||
rows.push({
|
||||
keyword: normalizedKw,
|
||||
@ -114,13 +164,11 @@ async function fetchRelatedKeywordsWithData(
|
||||
month: m.month,
|
||||
searchVolume: m.search_volume ?? 0,
|
||||
})),
|
||||
cpc: item.keyword_data?.keyword_info?.cpc ?? null,
|
||||
competition: item.keyword_data?.keyword_info?.competition ?? null,
|
||||
cpc: keywordData.keyword_info?.cpc ?? null,
|
||||
competition: keywordData.keyword_info?.competition ?? null,
|
||||
keywordDifficulty:
|
||||
item.keyword_data?.keyword_properties?.keyword_difficulty ?? null,
|
||||
intent: normalizeIntent(
|
||||
item.keyword_data?.search_intent_info?.main_intent,
|
||||
),
|
||||
keywordData.keyword_properties?.keyword_difficulty ?? null,
|
||||
intent: normalizeIntent(keywordData.search_intent_info?.main_intent),
|
||||
});
|
||||
}
|
||||
|
||||
@ -246,12 +294,9 @@ async function research(
|
||||
depth: 3, // bump when depth changes to bust stale cache
|
||||
});
|
||||
|
||||
type CachedResult = {
|
||||
rows: EnrichedKeyword[];
|
||||
source?: KeywordSource;
|
||||
usedFallback?: boolean;
|
||||
};
|
||||
const cached = await getCached<CachedResult>(cacheKey);
|
||||
const cachedRaw = await getCached(cacheKey);
|
||||
const cachedResult = cachedResultSchema.safeParse(cachedRaw);
|
||||
const cached = cachedResult.success ? cachedResult.data : null;
|
||||
|
||||
// Only serve cached results that actually have metric data. Previous
|
||||
// failed fetches may have cached rows with all-zero volume/cpc/competition.
|
||||
@ -505,9 +550,10 @@ async function getSerpAnalysis(input: {
|
||||
languageCode: input.languageCode,
|
||||
});
|
||||
|
||||
const cached = await getCached<{ items: SerpResultItem[] }>(cacheKey);
|
||||
if (cached && cached.items.length > 0) {
|
||||
return cached;
|
||||
const cachedRaw = await getCached(cacheKey);
|
||||
const cachedResult = serpCacheSchema.safeParse(cachedRaw);
|
||||
if (cachedResult.success && cachedResult.data.items.length > 0) {
|
||||
return cachedResult.data;
|
||||
}
|
||||
|
||||
const snapshots = await fetchHistoricalSerpsRaw(
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { sortBy } from "remeda";
|
||||
import { z } from "zod";
|
||||
|
||||
const PSI_CATEGORIES = [
|
||||
"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 {
|
||||
if (score == null || Number.isNaN(score)) return null;
|
||||
return Math.round(score * 100);
|
||||
@ -104,25 +149,22 @@ function parseIssues(
|
||||
payloadJson: string,
|
||||
categoryFilter?: PsiIssueCategory,
|
||||
): PsiIssue[] {
|
||||
let payload: Record<string, unknown>;
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||
payload = JSON.parse(payloadJson);
|
||||
} catch {
|
||||
throw new Error("Invalid Lighthouse payload JSON");
|
||||
}
|
||||
|
||||
const lighthouseResult = (payload.lighthouseResult ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const audits = (lighthouseResult.audits ?? {}) as Record<
|
||||
string,
|
||||
LighthouseAudit
|
||||
>;
|
||||
const categories = (lighthouseResult.categories ?? {}) as Record<
|
||||
string,
|
||||
LighthouseCategory
|
||||
>;
|
||||
const parsedPayload = psiPayloadSchema.safeParse(payload);
|
||||
if (!parsedPayload.success) {
|
||||
throw new Error("Invalid Lighthouse payload JSON");
|
||||
}
|
||||
|
||||
const audits: Record<string, LighthouseAudit> =
|
||||
parsedPayload.data.lighthouseResult.audits;
|
||||
const categories: Record<string, LighthouseCategory> =
|
||||
parsedPayload.data.lighthouseResult.categories;
|
||||
|
||||
const issues: PsiIssue[] = [];
|
||||
|
||||
@ -158,7 +200,7 @@ function parseIssues(
|
||||
: null;
|
||||
|
||||
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({
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const PSI_ENDPOINT =
|
||||
"https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
|
||||
const PSI_CATEGORIES = [
|
||||
@ -40,6 +42,48 @@ type LighthouseAudit = {
|
||||
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 {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
@ -76,15 +120,9 @@ function asMetric(audit: LighthouseAudit | undefined): PsiAuditMetric {
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
|
||||
const asRecord = payload as Record<string, unknown>;
|
||||
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;
|
||||
const parsed = psiErrorSchema.safeParse(payload);
|
||||
if (!parsed.success) return null;
|
||||
return parsed.data.error?.message ?? null;
|
||||
}
|
||||
|
||||
async function runAudit(input: {
|
||||
@ -110,43 +148,29 @@ async function runAudit(input: {
|
||||
params.append("key", apiKey);
|
||||
|
||||
const response = await fetch(`${PSI_ENDPOINT}?${params.toString()}`);
|
||||
const payload = (await response.json().catch(() => null)) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = extractErrorMessage(payload);
|
||||
throw new Error(message ?? `PSI request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const lighthouseResult = (payload?.lighthouseResult ?? null) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
if (!lighthouseResult) {
|
||||
const parsedPayload = psiResponseSchema.safeParse(payload);
|
||||
if (!parsedPayload.success || !parsedPayload.data.lighthouseResult) {
|
||||
throw new Error("PSI returned an invalid response");
|
||||
}
|
||||
|
||||
const categories = (lighthouseResult.categories ?? {}) as Record<
|
||||
string,
|
||||
{ score?: number | null }
|
||||
>;
|
||||
const audits = (lighthouseResult.audits ?? {}) as Record<
|
||||
string,
|
||||
LighthouseAudit
|
||||
>;
|
||||
const lighthouseResult = parsedPayload.data.lighthouseResult;
|
||||
|
||||
const categories = lighthouseResult.categories ?? {};
|
||||
const audits: Record<string, LighthouseAudit> = lighthouseResult.audits ?? {};
|
||||
|
||||
return {
|
||||
requestedUrl: normalizedUrl,
|
||||
finalUrl:
|
||||
(lighthouseResult.finalDisplayedUrl as string | undefined) ??
|
||||
normalizedUrl,
|
||||
finalUrl: lighthouseResult.finalDisplayedUrl ?? normalizedUrl,
|
||||
strategy: input.strategy,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
lighthouseVersion:
|
||||
(lighthouseResult.lighthouseVersion as string | undefined) ?? null,
|
||||
lighthouseVersion: lighthouseResult.lighthouseVersion ?? null,
|
||||
scores: {
|
||||
performance: asScore(categories.performance?.score),
|
||||
accessibility: asScore(categories.accessibility?.score),
|
||||
@ -161,7 +185,7 @@ async function runAudit(input: {
|
||||
speedIndex: asMetric(audits["speed-index"]),
|
||||
timeToInteractive: asMetric(audits.interactive),
|
||||
},
|
||||
rawPayload: payload ?? {},
|
||||
rawPayload: isRecord(payload) ? payload : {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -27,17 +27,18 @@ export async function fetchResearchRows(
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
const keyword = item.keyword_data?.keyword;
|
||||
const keywordData = item.keyword_data;
|
||||
const keyword = keywordData.keyword;
|
||||
if (!keyword) continue;
|
||||
|
||||
const normalizedKeyword = normalizeKeyword(keyword);
|
||||
if (seen.has(normalizedKeyword)) continue;
|
||||
seen.add(normalizedKeyword);
|
||||
|
||||
const keywordInfo = item.keyword_data
|
||||
?.keyword_info_normalized_with_clickstream?.search_volume
|
||||
? item.keyword_data.keyword_info_normalized_with_clickstream
|
||||
: item.keyword_data?.keyword_info;
|
||||
const keywordInfo = keywordData.keyword_info_normalized_with_clickstream
|
||||
?.search_volume
|
||||
? keywordData.keyword_info_normalized_with_clickstream
|
||||
: keywordData.keyword_info;
|
||||
|
||||
rows.push({
|
||||
keyword: normalizedKeyword,
|
||||
@ -47,13 +48,11 @@ export async function fetchResearchRows(
|
||||
month: entry.month,
|
||||
searchVolume: entry.search_volume ?? 0,
|
||||
})),
|
||||
cpc: item.keyword_data?.keyword_info?.cpc ?? null,
|
||||
competition: item.keyword_data?.keyword_info?.competition ?? null,
|
||||
cpc: keywordData.keyword_info?.cpc ?? null,
|
||||
competition: keywordData.keyword_info?.competition ?? null,
|
||||
keywordDifficulty:
|
||||
item.keyword_data?.keyword_properties?.keyword_difficulty ?? null,
|
||||
intent: normalizeIntent(
|
||||
item.keyword_data?.search_intent_info?.main_intent,
|
||||
),
|
||||
keywordData.keyword_properties?.keyword_difficulty ?? null,
|
||||
intent: normalizeIntent(keywordData.search_intent_info?.main_intent),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -7,11 +7,20 @@ import type {
|
||||
} from "@/types/schemas/keywords";
|
||||
import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords";
|
||||
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[] {
|
||||
if (!payload) return [];
|
||||
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) {
|
||||
console.error("keywords.saved.parse-monthly-searches failed:", error);
|
||||
return [];
|
||||
|
||||
@ -3,9 +3,28 @@ import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
|
||||
|
||||
import type { SerpResultItem } from "@/types/keywords";
|
||||
import { normalizeKeyword } from "./helpers";
|
||||
import { z } from "zod";
|
||||
|
||||
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: {
|
||||
keyword: string;
|
||||
locationCode: number;
|
||||
@ -19,9 +38,10 @@ export async function getSerpAnalysis(input: {
|
||||
languageCode: input.languageCode,
|
||||
});
|
||||
|
||||
const cached = await getCached<{ items: SerpResultItem[] }>(cacheKey);
|
||||
if (cached && cached.items.length > 0) {
|
||||
return cached;
|
||||
const cachedRaw = await getCached(cacheKey);
|
||||
const cached = serpCacheSchema.safeParse(cachedRaw);
|
||||
if (cached.success && cached.data.items.length > 0) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const snapshots = await fetchHistoricalSerpsRaw(
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
"target": "ES2022",
|
||||
"allowJs": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user