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",
|
"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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
69
pnpm-lock.yaml
generated
@ -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:
|
||||||
|
|||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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<{
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) => ({
|
||||||
|
|||||||
@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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 : {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 [];
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user