Initial public release

This commit is contained in:
Ben Senescu 2026-02-27 14:09:33 -05:00
commit e36abbf19b
102 changed files with 33163 additions and 0 deletions

11
.env.example Normal file
View File

@ -0,0 +1,11 @@
# Local app port
PORT=3001
# Every App / Gateway settings
VITE_APP_ID=super-seo
VITE_GATEWAY_URL=https://your-gateway-domain.example.com
GATEWAY_URL=https://your-gateway-domain.example.com
GATEWAY_APP_API_TOKEN=your_gateway_app_api_token
# DataForSEO Basic auth value: base64(login:password)
DATAFORSEO_API_KEY=base64_login_colon_password

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
.codex/
node_modules
package-lock.json
yarn.lock
.dev.vars
dist/
.wrangler
/tmp
.output/
.tanstack/
.DS_Store
.cache
.env
.env.local
.env.production
.vercel
.output
.nitro
/build/
/api/
/server/build
/public/build# Sentry Config File
.env.sentry-build-plugin
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
.tanstack
.logs/

32
.oxlintrc.json Normal file
View File

@ -0,0 +1,32 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "error",
"suspicious": "error"
},
"plugins": ["typescript", "import", "react", "unicorn", "oxc"],
"ignorePatterns": ["node_modules", "dist", ".output", "src/routeTree.gen.ts"],
"rules": {
"react/react-in-jsx-scope": "off",
"react/jsx-uses-react": "off",
"unicorn/no-array-sort": "error",
"typescript/no-explicit-any": "error",
"typescript/consistent-type-imports": "error",
"eslint/no-constant-binary-expression": "error",
"eslint/no-self-assign": "error",
"eslint/no-unreachable-loop": "error",
"eslint/no-unsafe-optional-chaining": "error",
"eslint/complexity": ["error", { "max": 40 }],
"eslint/max-lines": [
"error",
{ "max": 2000, "skipBlankLines": true, "skipComments": true }
],
"eslint/max-lines-per-function": [
"error",
{ "max": 1000, "skipBlankLines": true, "skipComments": true }
],
"eslint/max-depth": ["error", 4],
"eslint/max-params": ["error", 5],
"import/no-cycle": "error"
}
}

9
.prettierignore Normal file
View File

@ -0,0 +1,9 @@
**/build
**/public
pnpm-lock.yaml
routeTree.gen.ts
dist/
drizzle/
planning/
worker-configuration.d.ts

11
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
}
}

3
.worktreeinclude Normal file
View File

@ -0,0 +1,3 @@
.env.local
.wrangler
node_modules/

7
EveryAppLearnings.md Normal file
View File

@ -0,0 +1,7 @@
# EveryApp Learnings
Things to add to EveryApp skill files later.
## D1
- `db.batch()` is required for multi-row inserts — D1 has a 100 bind param limit per statement, so multi-row `INSERT VALUES (...), (...)` breaks. Use individual INSERT statements batched via `db.batch()` (up to 100 statements per call).

207
README.md Normal file
View File

@ -0,0 +1,207 @@
# OpenSEO
OpenSEO is an open source SEO tool for people getting started with SEO, or teams that want something simpler than SEMrush or Ahrefs without paying for another monthly SaaS subscription.
![OpenSEO demo (placeholder)](https://placehold.co/1200x675?text=OpenSEO+Demo+GIF+Coming+Soon)
## Why Use This
- Open source and self-hostable.
- No OpenSEO subscription.
- You own your deployment and data.
- Focused workflows instead of a giant, complex SEO suite.
## Main SEO Workflows
- Keyword research
- Find topics worth targeting, estimate demand, and prioritize what to write next.
- Domain insights
- Understand where your domain is gaining or losing visibility so you can focus on the pages that move revenue.
- Audits
- Catch technical issues early so your site is easier for search engines to crawl and rank.
## Pricing / Costs
OpenSEO is totally free to use. It works by pulling SEO data from DataForSEO, which is a paid third-party service unaffiliated with OpenSEO.
There are two separate things:
1. OpenSEO app cost: **$0 subscription**.
2. DataForSEO API usage: pay-as-you-go based on requests.
As of February 26, 2026, DataForSEOs public docs/pricing pages say:
- New accounts include **$1 free credit** to test the API.
- The minimum top-up/payment is **$50**.
That means you can try OpenSEO for free with the starter credit, then decide if/when to top up.
For OpenSEO-specific, per-workflow request estimates, see the internal [SEO API Cost Reference](#seo-api-cost-reference).
For current endpoint pricing and cost calculators, check:
- [DataForSEO Pricing](https://dataforseo.com/pricing)
- [DataForSEO API Documentation](https://docs.dataforseo.com/v3/)
## DataForSEO API Key Setup
OpenSEO expects `DATAFORSEO_API_KEY` as a Basic Auth value.
1. Go to [DataForSEO API Access](https://app.dataforseo.com/api-access).
2. Request API credentials by email (`API key by email` or `API password by email`).
3. Use your DataForSEO login + API password, then base64 encode `login:password`:
```sh
printf '%s' 'YOUR_LOGIN:YOUR_PASSWORD' | base64
```
Set that output as `DATAFORSEO_API_KEY` in your environment/secrets.
Note: even though the env var is named `DATAFORSEO_API_KEY`, this app sends it as HTTP Basic auth, so the value should be the base64 form of `login:password`.
## Local Development
### Prerequisites
- Node.js 20+
- [pnpm](https://pnpm.io/)
- A Cloudflare account
- Every App gateway set up (see [Every App](https://github.com/every-app/every-app))
- A DataForSEO account/API credentials
### Run Locally (Quick Test)
1. Copy env template:
```sh
cp .env.example .env.local
```
2. Install and run:
```sh
pnpm install
pnpm dev
```
App runs on `http://localhost:3001` by default (or `PORT` from `.env.local`).
Running locally is the fastest way to test core flows. In the future, local mode will not include some Cloudflare-backed capabilities (for example cron-based rank tracking and infrastructure-powered performance improvements for heavier audits).
### Shared Dev Server Workflow (for coding agents)
```sh
# terminal 1: start once and keep running
pnpm dev:agents
```
- `pnpm dev:agents` mirrors output to `.logs/dev-server.log` (gitignored).
- The log file is overwritten on each run.
- If you need a different port, set `PORT` in `.env.local` and restart.
### Database Commands
Generate migration:
```sh
pnpm run db:generate
```
Migrate local DB:
```sh
pnpm run db:migrate:local
```
## Self Hosting (Deploy on Cloudflare)
OpenSEO is built on [Every App](https://github.com/every-app/every-app), a platform for easily self-hosting open source apps in your own Cloudflare account.
### Prerequisites
1. Install [Node.js](https://nodejs.org/) (includes `npx`).
2. Create a Cloudflare account: [dash.cloudflare.com/sign-up](https://dash.cloudflare.com/sign-up)
3. Authenticate Wrangler:
```sh
npx wrangler login
```
4. Deploy the Every App Gateway (one-time per account):
```sh
npx everyapp gateway deploy
```
Follow the link returned by that command to create your gateway account.
### Deploy OpenSEO
```sh
git clone https://github.com/bensenescu/super-seo.git
cd super-seo
npx everyapp app deploy
```
After deploy, set your DataForSEO secret:
```sh
npx wrangler secret put DATAFORSEO_API_KEY
```
When prompted, paste the base64 value of `login:password` (using your DataForSEO login + API password).
## Roadmap
Top priorities right now:
- Rank tracking
- AI content workflows
If something important is missing, please join the Discord and request it. We prioritize community demand first.
Discord: [Join the OpenSEO community](https://discord.gg/c9uGs3cFXr)
## Contributing
Contributions are very welcome.
- Open an issue for bugs, UX friction, or feature requests.
- Open a PR if you want to implement a feature directly.
- Community-driven improvements are prioritized, and high-quality PRs are encouraged.
If you want to contribute but are unsure where to start, open an issue and describe what you want to build.
## SEO API Cost Reference
Use this section to estimate DataForSEO spend per request type. OpenSEO itself remains free; these are API usage costs only.
### Pricing sources
- DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
- Google PageSpeed Insights API docs: https://developers.google.com/speed/docs/insights/v5/get-started
### 1) Site audit
- No paid API calls in the current implementation.
### 2) Keyword research (`related` mode)
- Current billed cost pattern (from account usage logs):
- `0.02 + (0.0001 x returned_keywords)` USD
- Default app setting: `150` results per search (`$0.035` each).
- Available result tiers:
- 150 results = `$0.035`
- 300 results = `$0.05`
- 500 results = `$0.07`
### 3) Domain overview
- Standard domain overview request (with top 200 ranked keywords): `$0.0401` per domain.
- General formula if needed:
- `0.0201 + (0.0001 x ranked_keywords_returned)` USD
### Planning examples
- 100 keyword research requests at the default 150 results: `$3.50`
- 100 keyword research requests at 500 results each: `$7.00`
- 100 domain overviews (200 ranked keywords each): `$4.01`

13
drizzle-prod.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/db/schema.ts",
out: "./drizzle",
driver: "d1-http",
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_API_TOKEN!,
},
});

13
drizzle.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from "drizzle-kit";
import { getLocalD1Url } from "@every-app/sdk/cloudflare/server";
const localUrl = getLocalD1Url();
export default defineConfig({
dialect: "sqlite",
schema: "./src/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: localUrl || "", // Empty fallback for CI/non-dev environments
},
});

View File

@ -0,0 +1,146 @@
CREATE TABLE `audit_pages` (
`id` text PRIMARY KEY NOT NULL,
`audit_id` text NOT NULL,
`url` text NOT NULL,
`status_code` integer,
`redirect_url` text,
`title` text,
`meta_description` text,
`canonical_url` text,
`robots_meta` text,
`og_title` text,
`og_description` text,
`og_image` text,
`h1_count` integer DEFAULT 0 NOT NULL,
`h2_count` integer DEFAULT 0 NOT NULL,
`h3_count` integer DEFAULT 0 NOT NULL,
`h4_count` integer DEFAULT 0 NOT NULL,
`h5_count` integer DEFAULT 0 NOT NULL,
`h6_count` integer DEFAULT 0 NOT NULL,
`heading_order_json` text,
`word_count` integer DEFAULT 0 NOT NULL,
`images_total` integer DEFAULT 0 NOT NULL,
`images_missing_alt` integer DEFAULT 0 NOT NULL,
`images_json` text,
`internal_link_count` integer DEFAULT 0 NOT NULL,
`external_link_count` integer DEFAULT 0 NOT NULL,
`has_structured_data` integer DEFAULT false NOT NULL,
`hreflang_tags_json` text,
`is_indexable` integer DEFAULT true NOT NULL,
`response_time_ms` integer,
FOREIGN KEY (`audit_id`) REFERENCES `audits`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `audit_pages_audit_id_idx` ON `audit_pages` (`audit_id`);--> statement-breakpoint
CREATE TABLE `audit_psi_results` (
`id` text PRIMARY KEY NOT NULL,
`audit_id` text NOT NULL,
`page_id` text NOT NULL,
`strategy` text NOT NULL,
`performance_score` integer,
`accessibility_score` integer,
`best_practices_score` integer,
`seo_score` integer,
`lcp_ms` real,
`cls` real,
`inp_ms` real,
`ttfb_ms` real,
`error_message` text,
`r2_key` text,
`payload_size_bytes` integer,
FOREIGN KEY (`audit_id`) REFERENCES `audits`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`page_id`) REFERENCES `audit_pages`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `audit_psi_results_audit_id_idx` ON `audit_psi_results` (`audit_id`);--> statement-breakpoint
CREATE TABLE `audits` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`user_id` text NOT NULL,
`start_url` text NOT NULL,
`status` text DEFAULT 'running' NOT NULL,
`workflow_instance_id` text,
`config` text DEFAULT '{}' NOT NULL,
`pages_crawled` integer DEFAULT 0 NOT NULL,
`pages_total` integer DEFAULT 0 NOT NULL,
`psi_total` integer DEFAULT 0 NOT NULL,
`psi_completed` integer DEFAULT 0 NOT NULL,
`psi_failed` integer DEFAULT 0 NOT NULL,
`current_phase` text DEFAULT 'discovery',
`started_at` text DEFAULT (current_timestamp) NOT NULL,
`completed_at` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `audits_project_id_idx` ON `audits` (`project_id`);--> statement-breakpoint
CREATE INDEX `audits_user_id_idx` ON `audits` (`user_id`);--> statement-breakpoint
CREATE TABLE `keyword_metrics` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`keyword` text NOT NULL,
`location_code` integer NOT NULL,
`language_code` text DEFAULT 'en' NOT NULL,
`search_volume` integer,
`cpc` real,
`competition` real,
`keyword_difficulty` integer,
`intent` text,
`monthly_searches` text,
`fetched_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `projects` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`domain` text,
`pagespeed_api_key` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `psi_audit_results` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`requested_url` text NOT NULL,
`final_url` text NOT NULL,
`strategy` text NOT NULL,
`status` text DEFAULT 'completed' NOT NULL,
`performance_score` integer,
`accessibility_score` integer,
`best_practices_score` integer,
`seo_score` integer,
`first_contentful_paint` text,
`largest_contentful_paint` text,
`total_blocking_time` text,
`cumulative_layout_shift` text,
`speed_index` text,
`time_to_interactive` text,
`lighthouse_version` text,
`error_message` text,
`r2_key` text,
`payload_size_bytes` integer,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `psi_audit_results_project_created_idx` ON `psi_audit_results` (`project_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `psi_audit_results_project_strategy_idx` ON `psi_audit_results` (`project_id`,`strategy`);--> statement-breakpoint
CREATE TABLE `saved_keywords` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`keyword` text NOT NULL,
`location_code` integer DEFAULT 2840 NOT NULL,
`language_code` text DEFAULT 'en' NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `saved_keywords_unique_project_keyword_location` ON `saved_keywords` (`project_id`,`keyword`,`location_code`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);

View File

@ -0,0 +1,11 @@
DROP INDEX `saved_keywords_unique_project_keyword_location`;--> statement-breakpoint
CREATE UNIQUE INDEX `saved_keywords_unique_project_keyword_location_language` ON `saved_keywords` (`project_id`,`keyword`,`location_code`,`language_code`);--> statement-breakpoint
CREATE INDEX `saved_keywords_project_created_idx` ON `saved_keywords` (`project_id`,`created_at`);--> statement-breakpoint
DELETE FROM `keyword_metrics`
WHERE `id` NOT IN (
SELECT MAX(`id`)
FROM `keyword_metrics`
GROUP BY `keyword`, `location_code`, `language_code`
);--> statement-breakpoint
CREATE UNIQUE INDEX `keyword_metrics_unique_keyword_location_language` ON `keyword_metrics` (`keyword`,`location_code`,`language_code`);--> statement-breakpoint
CREATE INDEX `keyword_metrics_lookup_idx` ON `keyword_metrics` (`keyword`,`location_code`,`language_code`,`fetched_at`);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1772065863070,
"tag": "0000_fantastic_vanisher",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1772127913577,
"tag": "0001_round_unus",
"breakpoints": true
}
]
}

5
every-app.jsonc Normal file
View File

@ -0,0 +1,5 @@
{
"appId": "super-seo",
"displayName": "Super Seo",
"description": "Own your SEO. Research keywords and competitors on your terms.",
}

46
knip.jsonc Normal file
View File

@ -0,0 +1,46 @@
{
"ignoreBinaries": ["everyapp"],
"entry": [
// Detect Tanstack Start Routes
"src/router.tsx",
"src/routes/**/*.tsx",
// Drizzle config (plugin disabled due to cloudflare:workers import issues)
"drizzle.config.ts",
// DB index re-exports schema for convenience
"src/db/index.ts",
],
"project": ["**/*.{js,ts,tsx}", "!src/routeTree.gen.ts"],
"ignore": [
"drizzle-prod.config.ts",
"src/client/features/keywords/utils.ts",
"src/client/hooks/useDomainSearchHistory.ts",
"src/client/hooks/useSearchHistory.ts",
"src/server.ts",
"src/server/lib/audit/progress-kv.ts",
"src/server/lib/audit/types.ts",
"src/server/lib/errors.ts",
"src/server/services/PsiIssuesService.ts",
"src/server/workflows/SiteAuditWorkflow.ts",
"src/serverFunctions/keywords.ts",
"src/serverFunctions/psi.ts",
"src/types/schemas/audit.ts",
"src/types/schemas/psi.ts",
],
"ignoreFiles": [
"src/server/lib/serverFnErrorBoundary.ts",
"src/server/services/keyword-research/helpers.ts",
"src/server/services/keyword-research/projects.ts",
"src/server/services/keyword-research/research-data.ts",
"src/server/services/keyword-research/saved-keywords.ts",
"src/server/services/keyword-research/serp.ts",
],
// Disable Drizzle plugin - it tries to load drizzle.config.ts which imports cloudflare:workers
"drizzle": false,
"ignoreDependencies": [
// Tailwindcss used via @tailwindcss/vite plugin
"tailwindcss",
"daisyui",
"@tanstack/query-sync-storage-persister",
"@tanstack/react-query-persist-client",
],
}

13
opencode.jsonc Normal file
View File

@ -0,0 +1,13 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"context7": {
"type": "local",
"command": ["npx", "-y", "@upstash/context7-mcp"],
},
"every-app": {
"type": "local",
"command": ["npx", "-y", "@every-app/mcp"],
},
},
}

73
package.json Normal file
View File

@ -0,0 +1,73 @@
{
"name": "super-seo",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:agents": "BYPASS_GATEWAY_LOCAL_ONLY=true vite dev 2>&1 | tee .logs/dev-server.log",
"build": "vite build && tsc --noEmit",
"lint": "oxlint .",
"lint:fix": "oxlint . --fix",
"preview": "npm run build && vite preview --port 3001",
"deploy": "npm run db:migrate:prod && npm run build && wrangler deploy",
"cf-typegen": "wrangler types",
"types:check": "tsc --noEmit",
"format:check": "prettier --check .",
"format:write": "prettier . --write",
"db:generate": "drizzle-kit generate",
"db:migrate:local": "drizzle-kit migrate",
"db:migrate:prod": "npx everyapp app remote-d1-shell -- drizzle-kit migrate --config=drizzle-prod.config.ts",
"db:studio:local": "drizzle-kit studio",
"db:studio:prod": "npx everyapp app remote-d1-shell -- drizzle-kit studio --config=drizzle-prod.config.ts",
"knip": "knip",
"ci": "prettier --check . && knip && tsc --noEmit && oxlint ."
},
"dependencies": {
"@every-app/sdk": "^0.1.12",
"@tanstack/query-core": "^5.90.9",
"@tanstack/query-sync-storage-persister": "^5.90.14",
"@tanstack/react-form": "^1.25.0",
"@tanstack/react-query": "^5.90.9",
"@tanstack/react-query-persist-client": "^5.90.14",
"@tanstack/react-router": "^1.136.3",
"@tanstack/react-router-devtools": "^1.136.3",
"@tanstack/react-start": "^1.136.3",
"cheerio": "^1.2.0",
"cloudflare": "^5.2.0",
"daisyui": "^5.5.5",
"dataforseo-client": "^2.0.19",
"drizzle-orm": "^0.44.4",
"fast-xml-parser": "^5.4.1",
"jose": "^6.0.12",
"lucide-react": "^0.542.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.7.0",
"remeda": "^2.33.6",
"robots-parser": "^3.0.1",
"sonner": "^2.0.7",
"tailwindcss": "^4.1.16",
"zod": "^4.1.12"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.13.18",
"@cloudflare/workers-types": "^4.20251014.0",
"@libsql/client": "^0.15.15",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/devtools-vite": "^0.5.1",
"@tanstack/react-devtools": "^0.9.6",
"@types/node": "^22.18.13",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.6.0",
"drizzle-kit": "^0.31.4",
"knip": "^5.66.4",
"oxlint": "^1.50.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"vite": "^7.1.2",
"vite-tsconfig-paths": "^5.1.4",
"wrangler": "^4.45.3"
}
}

5581
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

19
public/site.webmanifest Normal file
View File

@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@ -0,0 +1,48 @@
import { Link, rootRouteId, useMatch, useRouter } from "@tanstack/react-router";
import type { ErrorComponentProps } from "@tanstack/react-router";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
const router = useRouter();
const isRoot = useMatch({
strict: false,
select: (state) => state.id === rootRouteId,
});
const message = getStandardErrorMessage(
error,
"Something went wrong. Please try again.",
);
return (
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">
<p className="text-center text-error">{message}</p>
<div className="flex gap-2 items-center flex-wrap">
<button
onClick={() => {
router.invalidate();
}}
className="btn btn-neutral btn-sm uppercase"
>
Try Again
</button>
{isRoot ? (
<Link to="/" className="btn btn-neutral btn-sm uppercase">
Home
</Link>
) : (
<Link
to="/"
className="btn btn-neutral btn-sm uppercase"
onClick={(e) => {
e.preventDefault();
window.history.back();
}}
>
Go Back
</Link>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
export function NotFound({ children }: { children?: React.ReactNode }) {
return (
<div className="space-y-2 p-4">
<h1 className="text-2xl">404</h1>
<div className="text-base-content/70">
{children || <p>The page you are looking for does not exist.</p>}
</div>
</div>
);
}

View File

@ -0,0 +1,112 @@
import { Link } from "@tanstack/react-router";
import { ChevronsUpDown, X } from "lucide-react";
import { projectNavItems } from "@/client/navigation/items";
interface SidebarProps {
currentPath: string;
projectId: string | null;
onNavigate?: () => void;
onClose?: () => void;
}
export function Sidebar({
currentPath,
projectId,
onNavigate,
onClose,
}: SidebarProps) {
// If we don't have a projectId yet (e.g., root redirect hasn't fired),
// don't render nav links since we can't build the URLs.
if (!projectId) {
return (
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col">
<div className="px-4 py-4 border-b border-base-300 flex items-center justify-between">
<a
href={import.meta.env.VITE_GATEWAY_URL}
target="_top"
className="font-semibold text-base-content hover:text-primary transition-colors"
>
Every App
</a>
{onClose && (
<button
onClick={onClose}
className="btn btn-ghost btn-sm btn-circle"
aria-label="Close sidebar"
>
<X className="h-5 w-5" />
</button>
)}
</div>
<div className="flex-1 flex items-center justify-center">
<span className="loading loading-spinner loading-sm" />
</div>
</div>
);
}
return (
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col">
{/* Header */}
<div className="px-4 py-4 border-b border-base-300 flex items-center justify-between">
<a
href={import.meta.env.VITE_GATEWAY_URL}
target="_top"
className="font-semibold text-base-content hover:text-primary transition-colors"
>
Every App
</a>
{onClose && (
<button
onClick={onClose}
className="btn btn-ghost btn-sm btn-circle"
aria-label="Close sidebar"
>
<X className="h-5 w-5" />
</button>
)}
</div>
{/* Project picker */}
<div className="px-3 py-3 border-b border-base-300">
<div
className="tooltip tooltip-bottom w-full"
data-tip="Multiple projects coming soon"
>
<button className="btn btn-ghost btn-sm w-full justify-between font-medium text-sm cursor-default">
<span className="truncate">Default</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
</button>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 py-4 pl-3 overflow-y-auto">
{projectNavItems.map((item) => {
const Icon = item.icon;
const isActive = currentPath.includes(item.matchSegment);
return (
<Link
key={item.to}
to={item.to}
params={{ projectId }}
onClick={onNavigate}
className={`relative flex items-center gap-3 pl-4 pr-4 py-2 text-sm transition-colors ${
isActive
? "text-base-content font-medium"
: "text-base-content/60 hover:text-base-content hover:bg-base-200"
}`}
>
{isActive && (
<div className="absolute left-0 top-1 bottom-1 w-[3px] bg-primary rounded-r-full" />
)}
<Icon className="h-5 w-5" />
{item.label}
</Link>
);
})}
</nav>
</div>
);
}

View File

@ -0,0 +1,666 @@
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
ExternalLink,
Minus,
TrendingDown,
TrendingUp,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { sortBy } from "remeda";
import {
Area,
AreaChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type {
KeywordIntent,
KeywordResearchRow,
MonthlySearch,
SerpResultItem,
} from "@/types/keywords";
import { formatNumber, scoreTierClass } from "./utils";
export type SortField =
| "keyword"
| "searchVolume"
| "cpc"
| "competition"
| "keywordDifficulty";
export type SortDir = "asc" | "desc";
export function HeaderHelpLabel({
label,
helpText,
delayMs = 150,
}: {
label: string;
helpText: string;
delayMs?: number;
}) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerRef = useRef<HTMLSpanElement | null>(null);
const updatePosition = () => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
setPosition({
top: rect.top - 8,
left: rect.left + rect.width / 2,
});
};
const clearOpenTimeout = () => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
};
const scheduleOpen = () => {
clearOpenTimeout();
openTimeoutRef.current = setTimeout(() => {
updatePosition();
setIsOpen(true);
openTimeoutRef.current = null;
}, delayMs);
};
const closeNow = () => {
clearOpenTimeout();
setIsOpen(false);
};
useEffect(() => clearOpenTimeout, []);
useEffect(() => {
if (!isOpen) return;
updatePosition();
const handleReposition = () => updatePosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
};
}, [isOpen]);
return (
<span
ref={triggerRef}
className="relative inline-flex items-center"
onMouseEnter={scheduleOpen}
onMouseLeave={closeNow}
onFocus={scheduleOpen}
onBlur={closeNow}
onKeyDown={(e) => {
if (e.key === "Escape") closeNow();
}}
>
<span>{label}</span>
{isOpen && typeof document !== "undefined"
? createPortal(
<span
role="tooltip"
className="pointer-events-none fixed z-[1000] w-max max-w-56 -translate-x-1/2 -translate-y-full rounded-md border border-base-300 bg-base-100 px-2 py-1 text-[11px] font-normal normal-case leading-snug text-base-content shadow-md"
style={{ left: position.left, top: position.top }}
>
{helpText}
</span>,
document.body,
)
: null}
</span>
);
}
export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
return (
<div className="shrink-0 bg-base-100 border border-base-300 rounded-xl px-4 py-2.5 flex items-center gap-4 min-h-[48px]">
<div className="flex items-center gap-2 min-w-0 shrink-0">
<span className="font-bold text-base truncate max-w-[240px] capitalize">
{keyword.keyword}
</span>
<ScoreBadge value={keyword.keywordDifficulty} size="sm" />
</div>
<div className="w-px h-6 bg-base-300 shrink-0" />
<div className="flex items-center gap-4 text-sm flex-wrap min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-base-content/50">Vol</span>
<span className="font-semibold tabular-nums">
{formatNumber(keyword.searchVolume)}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-base-content/50">CPC</span>
<span className="font-semibold tabular-nums">
{keyword.cpc == null ? "-" : `$${keyword.cpc.toFixed(2)}`}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-base-content/50">Comp</span>
<span className="font-semibold tabular-nums">
{keyword.competition == null ? "-" : keyword.competition.toFixed(2)}
</span>
</div>
<IntentBadge intent={keyword.intent} />
</div>
</div>
);
}
export function KeywordRow({
row,
isSelected,
isActive,
onToggle,
onClick,
}: {
row: KeywordResearchRow;
isSelected: boolean;
isActive: boolean;
onToggle: () => void;
onClick: () => void;
}) {
return (
<div
className={`flex items-center gap-3 px-4 py-2 border-b border-base-200 text-sm hover:bg-base-200/50 transition-colors cursor-pointer ${
isActive ? "bg-primary/5 border-l-2 border-l-primary" : ""
}`}
onClick={onClick}
>
<input
type="checkbox"
className="checkbox checkbox-xs shrink-0"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
/>
<span
className="flex-1 min-w-0 truncate font-medium capitalize"
title={row.keyword}
>
{row.keyword}
</span>
<span className="w-16 text-right tabular-nums text-base-content/70">
{formatNumber(row.searchVolume)}
</span>
<span className="w-14 text-right tabular-nums text-base-content/70">
{row.cpc == null ? "-" : row.cpc.toFixed(2)}
</span>
<span className="w-12 text-right tabular-nums text-base-content/70">
{row.competition == null ? "-" : row.competition.toFixed(2)}
</span>
<div className="w-10 flex justify-end">
<ScoreBadge value={row.keywordDifficulty} size="sm" />
</div>
</div>
);
}
export function KeywordCard({
row,
isSelected,
isActive,
onToggle,
onClick,
}: {
row: KeywordResearchRow;
isSelected: boolean;
isActive: boolean;
onToggle: () => void;
onClick: () => void;
}) {
return (
<div
className={`bg-base-100 border border-base-300 rounded-lg p-3 space-y-2 cursor-pointer transition-colors ${
isActive ? "border-primary bg-primary/5" : "hover:bg-base-200/50"
}`}
onClick={onClick}
>
<div className="flex items-start gap-2">
<input
type="checkbox"
className="checkbox checkbox-sm shrink-0 mt-0.5"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="flex-1 font-semibold text-sm capitalize leading-tight">
{row.keyword}
</span>
<ScoreBadge value={row.keywordDifficulty} size="sm" />
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center">
<p className="text-base-content/50">Volume</p>
<p className="font-medium tabular-nums">
{formatNumber(row.searchVolume)}
</p>
</div>
<div className="text-center">
<p className="text-base-content/50">CPC</p>
<p className="font-medium tabular-nums">
{row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`}
</p>
</div>
<div className="text-center">
<p className="text-base-content/50">Comp.</p>
<p className="font-medium tabular-nums">
{row.competition == null ? "-" : row.competition.toFixed(2)}
</p>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<IntentBadge intent={row.intent} />
</div>
</div>
);
}
export function SerpAnalysisCard({
items,
loading,
error,
onRetry,
page,
pageSize,
onPageChange,
}: {
items: SerpResultItem[];
loading: boolean;
error?: string | null;
onRetry?: () => void;
page: number;
pageSize: number;
onPageChange: (p: number) => void;
}) {
const totalPages = Math.ceil(items.length / pageSize);
const pageItems = items.slice(page * pageSize, (page + 1) * pageSize);
return (
<div>
{loading ? (
<div className="space-y-3" aria-busy>
<div className="skeleton h-3 w-40" />
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="grid grid-cols-[24px_minmax(0,1fr)_72px_92px_82px_56px] items-center gap-2"
>
<div className="skeleton h-3 w-4" />
<div className="space-y-1">
<div className="skeleton h-3 w-10/12" />
<div className="skeleton h-2.5 w-7/12" />
</div>
<div className="skeleton h-3 w-12 justify-self-end" />
<div className="skeleton h-3 w-16 justify-self-end" />
<div className="skeleton h-3 w-16 justify-self-end" />
<div className="skeleton h-3 w-10 justify-self-center" />
</div>
))}
</div>
</div>
) : error ? (
<div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error space-y-2">
<p>{error}</p>
{onRetry ? (
<button className="btn btn-xs" onClick={onRetry}>
Retry
</button>
) : null}
</div>
) : items.length === 0 ? (
<div className="text-center py-6 text-base-content/40 text-sm">
No SERP data available for this keyword
</div>
) : (
<>
<div className="text-xs text-base-content/50 mb-3">
{items.length} organic results
</div>
<div className="overflow-x-auto">
<table className="table table-xs w-full">
<thead>
<tr className="text-xs text-base-content/60">
<th className="w-8">#</th>
<th>Page</th>
<th className="text-right w-20">Traffic</th>
<th className="text-right w-20">Ref. Domains</th>
<th className="text-right w-20">Backlinks</th>
<th className="text-center w-16">Change</th>
</tr>
</thead>
<tbody>
{pageItems.map((item) => (
<tr
key={`${item.rank}-${item.url}`}
className="hover:bg-base-200/50"
>
<td className="font-mono text-base-content/50 text-xs">
{item.rank}
</td>
<td className="max-w-[280px]">
<div className="flex flex-col gap-0.5">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary hover:underline truncate flex items-center gap-1"
title={item.title}
>
{item.title || item.url}
<ExternalLink className="size-3 shrink-0 opacity-40" />
</a>
<span className="text-xs text-base-content/40 truncate">
{item.domain}
</span>
</div>
</td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.etv)}
</td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.referringDomains)}
</td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.backlinks)}
</td>
<td className="text-center">
<RankChangeBadge
change={item.rankChange}
isNew={item.isNew}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-base-200">
<span className="text-xs text-base-content/50">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-1">
<button
className="btn btn-ghost btn-xs"
disabled={page === 0}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="size-3.5" />
Prev
</button>
<button
className="btn btn-ghost btn-xs"
disabled={page >= totalPages - 1}
onClick={() => onPageChange(page + 1)}
>
Next
<ChevronRight className="size-3.5" />
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
function RankChangeBadge({
change,
isNew,
}: {
change: number | null;
isNew: boolean;
}) {
if (isNew) {
return <span className="badge badge-xs badge-success">NEW</span>;
}
if (change == null || change === 0) {
return <Minus className="size-3 text-base-content/30 mx-auto" />;
}
if (change > 0) {
return (
<span className="inline-flex items-center gap-0.5 text-success text-xs font-medium">
<TrendingUp className="size-3" />+{change}
</span>
);
}
return (
<span className="inline-flex items-center gap-0.5 text-error text-xs font-medium">
<TrendingDown className="size-3" />
{change}
</span>
);
}
function ScoreBadge({
value,
size = "sm",
}: {
value: number | null;
size?: "sm" | "lg";
}) {
if (value == null) return null;
const tierClass = scoreTierClass(value);
const sizeClasses =
size === "lg"
? "size-9 text-sm font-bold"
: "size-6 text-[10px] font-semibold";
return (
<span
className={`score-badge ${tierClass} inline-flex items-center justify-center rounded-full ${sizeClasses}`}
>
{value}
</span>
);
}
export function AreaTrendChart({ trend }: { trend: MonthlySearch[] }) {
const sorted = sortBy(trend, (item) => item.year * 100 + item.month);
const last12 = sorted.slice(-12);
const containerRef = useRef<HTMLDivElement | null>(null);
const [chartWidth, setChartWidth] = useState(0);
if (last12.length === 0) return null;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const update = () => {
setChartWidth(container.clientWidth);
};
update();
const observer = new ResizeObserver(update);
observer.observe(container);
return () => {
observer.disconnect();
};
}, []);
const monthLabels = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const data = last12.map((m) => ({
month: monthLabels[m.month - 1],
year: m.year,
searchVolume: m.searchVolume,
label: `${monthLabels[m.month - 1]} ${m.year}`,
}));
return (
<div
ref={containerRef}
className="w-full h-[210px] min-w-0"
aria-label="Search trend chart"
>
{chartWidth > 0 ? (
<AreaChart
width={chartWidth}
height={210}
data={data}
margin={{ top: 8, right: 8, left: 0, bottom: 4 }}
accessibilityLayer
>
<defs>
<linearGradient id="trendGrad" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--color-primary)"
stopOpacity="var(--trend-fill-start-opacity)"
/>
<stop
offset="100%"
stopColor="var(--color-primary)"
stopOpacity="var(--trend-fill-end-opacity)"
/>
</linearGradient>
</defs>
<CartesianGrid
stroke="var(--trend-grid-color)"
strokeDasharray="2 4"
vertical={true}
horizontal={true}
/>
<XAxis
dataKey="month"
tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(value: number | string) =>
formatNumber(Number(value))
}
tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }}
width={56}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--trend-tooltip-bg)",
border: "1px solid var(--trend-tooltip-border)",
borderRadius: "10px",
boxShadow: "0 8px 24px var(--trend-tooltip-shadow)",
color: "var(--color-base-content)",
}}
/>
<Area
type="monotone"
dataKey="searchVolume"
name="Search volume"
stroke="var(--color-primary)"
strokeWidth={2}
fill="url(#trendGrad)"
isAnimationActive={false}
dot={{ r: 3, fill: "var(--color-primary)", strokeWidth: 0 }}
activeDot={{ r: 5, fill: "var(--color-primary)" }}
/>
</AreaChart>
) : null}
</div>
);
}
export function SortHeader({
label,
helpText,
field,
current,
dir,
onToggle,
className,
}: {
label: string;
helpText?: string;
field: SortField;
current: SortField;
dir: SortDir;
onToggle: (f: SortField) => void;
className?: string;
}) {
const isActive = field === current;
return (
<button
className={`inline-flex items-center gap-0.5 hover:text-primary transition-colors cursor-pointer select-none ${className ?? ""}`}
onClick={() => onToggle(field)}
>
{helpText ? <HeaderHelpLabel label={label} helpText={helpText} /> : label}
{isActive &&
(dir === "asc" ? (
<ChevronUp className="size-3" />
) : (
<ChevronDown className="size-3" />
))}
</button>
);
}
function IntentBadge({ intent }: { intent: KeywordIntent }) {
const colors: Record<KeywordIntent, string> = {
informational: "badge-info",
commercial: "badge-warning",
transactional: "badge-success",
navigational: "badge-primary",
unknown: "badge-ghost",
};
const shortLabels: Record<KeywordIntent, string> = {
informational: "Info",
commercial: "Comm",
transactional: "Trans",
navigational: "Nav",
unknown: "?",
};
return (
<span className={`badge badge-sm ${colors[intent]}`}>
{shortLabels[intent]}
</span>
);
}

View File

@ -0,0 +1,62 @@
export const LOCATIONS: Record<number, string> = {
2840: "US",
2826: "UK",
2276: "DE",
2250: "FR",
2036: "AU",
2124: "CA",
2356: "IN",
2076: "BR",
};
const LOCATION_LANGUAGE: Record<number, string> = {
2840: "en",
2826: "en",
2276: "de",
2250: "fr",
2036: "en",
2124: "en",
2356: "en",
2076: "pt",
};
export function getLanguageCode(locationCode: number): string {
return LOCATION_LANGUAGE[locationCode] ?? "en";
}
export function competitionLabel(score: number | null): string {
if (score == null) return "N/A";
const pct = Math.round(score * 100);
if (pct <= 33) return "low";
if (pct <= 66) return "medium";
return "high";
}
export function scoreTierClass(value: number | null): string {
if (value == null) return "score-tier-na";
if (value <= 20) return "score-tier-1";
if (value <= 35) return "score-tier-2";
if (value <= 50) return "score-tier-3";
if (value <= 65) return "score-tier-4";
if (value <= 80) return "score-tier-5";
return "score-tier-6";
}
export function parseTerms(value: string): string[] {
return value
.toLowerCase()
.split(/[,+]/)
.map((term) => term.trim())
.filter(Boolean);
}
export function formatNumber(value: number | null | undefined): string {
if (value == null) return "-";
return new Intl.NumberFormat().format(value);
}
export function csvEscape(value: string | number | null | undefined): string {
if (value == null) return "";
const text = String(value).replace(/"/g, '""');
return `"${text}"`;
}

View File

@ -0,0 +1,121 @@
import { useState, useEffect, useCallback } from "react";
type DomainSortMode = "rank" | "traffic" | "volume";
type DomainTab = "keywords" | "pages";
export interface DomainSearchHistoryItem {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
tab: DomainTab;
search?: string;
timestamp: number;
}
type AddDomainSearchInput = Omit<DomainSearchHistoryItem, "timestamp">;
const MAX_HISTORY = 20;
function storageKey(projectId: string) {
return `domain-search-history:${projectId}`;
}
function loadHistory(projectId: string): DomainSearchHistoryItem[] {
try {
const raw = localStorage.getItem(storageKey(projectId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(item): item is DomainSearchHistoryItem =>
item &&
typeof item.domain === "string" &&
typeof item.subdomains === "boolean" &&
(item.sort === "rank" ||
item.sort === "traffic" ||
item.sort === "volume") &&
(item.tab === "keywords" || item.tab === "pages") &&
(item.search === undefined || typeof item.search === "string") &&
typeof item.timestamp === "number",
)
.slice(0, MAX_HISTORY);
} catch {
return [];
}
}
function saveHistory(projectId: string, items: DomainSearchHistoryItem[]) {
try {
localStorage.setItem(storageKey(projectId), JSON.stringify(items));
} catch {
// storage full or unavailable - silently ignore
}
}
function normalizeSearchText(value: string | undefined): string {
return value?.trim() ?? "";
}
function isSameSearch(
a: DomainSearchHistoryItem,
b: AddDomainSearchInput,
): boolean {
return (
a.domain === b.domain &&
a.subdomains === b.subdomains &&
a.sort === b.sort &&
a.tab === b.tab &&
normalizeSearchText(a.search) === normalizeSearchText(b.search)
);
}
export function useDomainSearchHistory(projectId: string) {
const [history, setHistory] = useState<DomainSearchHistoryItem[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setHistory(loadHistory(projectId));
setIsLoaded(true);
}, [projectId]);
const addSearch = useCallback(
(item: AddDomainSearchInput) => {
setHistory((prev) => {
const filtered = prev.filter(
(existing) => !isSameSearch(existing, item),
);
const next = [
{
...item,
search: normalizeSearchText(item.search) || undefined,
timestamp: Date.now(),
},
...filtered,
].slice(0, MAX_HISTORY);
saveHistory(projectId, next);
return next;
});
},
[projectId],
);
const removeHistoryItem = useCallback(
(timestamp: number) => {
setHistory((prev) => {
const next = prev.filter((item) => item.timestamp !== timestamp);
saveHistory(projectId, next);
return next;
});
},
[projectId],
);
const clearHistory = useCallback(() => {
setHistory([]);
saveHistory(projectId, []);
}, [projectId]);
return { history, isLoaded, addSearch, clearHistory, removeHistoryItem };
}

View File

@ -0,0 +1,92 @@
import { useState, useEffect, useCallback } from "react";
export interface SearchHistoryItem {
keyword: string;
locationCode: number;
locationName: string;
timestamp: number;
}
const MAX_HISTORY = 20;
function storageKey(projectId: string) {
return `search-history:${projectId}`;
}
function loadHistory(projectId: string): SearchHistoryItem[] {
try {
const raw = localStorage.getItem(storageKey(projectId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(item): item is SearchHistoryItem =>
item &&
typeof item.keyword === "string" &&
typeof item.locationCode === "number" &&
typeof item.locationName === "string" &&
typeof item.timestamp === "number",
)
.slice(0, MAX_HISTORY);
} catch {
return [];
}
}
function saveHistory(projectId: string, items: SearchHistoryItem[]) {
try {
localStorage.setItem(storageKey(projectId), JSON.stringify(items));
} catch {
// storage full or unavailable — silently ignore
}
}
export function useSearchHistory(projectId: string) {
const [history, setHistory] = useState<SearchHistoryItem[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
// Load from localStorage on mount / when projectId changes
useEffect(() => {
setHistory(loadHistory(projectId));
setIsLoaded(true);
}, [projectId]);
const addSearch = useCallback(
(keyword: string, locationCode: number, locationName: string) => {
setHistory((prev) => {
// Remove any existing entry for the same keyword+location
const filtered = prev.filter(
(item) =>
!(item.keyword === keyword && item.locationCode === locationCode),
);
const next = [
{ keyword, locationCode, locationName, timestamp: Date.now() },
...filtered,
].slice(0, MAX_HISTORY);
saveHistory(projectId, next);
return next;
});
},
[projectId],
);
const removeHistoryItem = useCallback(
(timestamp: number) => {
setHistory((prev) => {
const next = prev.filter((item) => item.timestamp !== timestamp);
saveHistory(projectId, next);
return next;
});
},
[projectId],
);
const clearHistory = useCallback(() => {
setHistory([]);
saveHistory(projectId, []);
}, [projectId]);
return { history, isLoaded, addSearch, clearHistory, removeHistoryItem };
}

View File

@ -0,0 +1,21 @@
import { isErrorCode, type ErrorCode } from "@/shared/error-codes";
const STANDARD_MESSAGES: Record<ErrorCode, string> = {
UNAUTHENTICATED: "Please sign in and try again.",
FORBIDDEN: "You do not have access to this resource.",
NOT_FOUND: "The requested resource was not found.",
VALIDATION_ERROR: "Please check your input and try again.",
CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.",
RATE_LIMITED: "Too many requests. Please wait and try again.",
CONFLICT: "This request conflicts with existing data.",
INTERNAL_ERROR: "Something went wrong. Please try again.",
};
export function getStandardErrorMessage(
error: unknown,
fallback: string = STANDARD_MESSAGES.INTERNAL_ERROR,
): string {
if (!(error instanceof Error)) return fallback;
if (isErrorCode(error.message)) return STANDARD_MESSAGES[error.message];
return fallback;
}

View File

@ -0,0 +1,34 @@
import { Bookmark, Bot, ClipboardCheck, Globe, Search } from "lucide-react";
export const projectNavItems = [
{
to: "/p/$projectId/keywords" as const,
label: "Keyword Research",
icon: Search,
matchSegment: "/keywords",
},
{
to: "/p/$projectId/saved" as const,
label: "Saved Keywords",
icon: Bookmark,
matchSegment: "/saved",
},
{
to: "/p/$projectId/domain" as const,
label: "Domain Overview",
icon: Globe,
matchSegment: "/domain",
},
{
to: "/p/$projectId/audit" as const,
label: "Site Audit",
icon: ClipboardCheck,
matchSegment: "/audit",
},
{
to: "/p/$projectId/ai" as const,
label: "AI",
icon: Bot,
matchSegment: "/ai",
},
] as const;

256
src/client/styles/app.css Normal file
View File

@ -0,0 +1,256 @@
@import "tailwindcss";
@import "./view-transitions.css";
@theme {
--container-8xl: 88rem;
}
:root {
--trend-grid-color: oklch(56% 0.01 255 / 0.28);
--trend-axis-color: oklch(42% 0.01 255 / 0.85);
--trend-fill-start-opacity: 0.32;
--trend-fill-end-opacity: 0.05;
--trend-tooltip-bg: oklch(99% 0.005 255 / 0.96);
--trend-tooltip-border: oklch(84% 0.01 255 / 0.7);
--trend-tooltip-shadow: oklch(0% 0 0 / 0.1);
}
@plugin "daisyui" {
/* @property isn't supported by some browsers and gives a noisy warning https://github.com/saadeghi/daisyui/issues/3882 */
exclude: properties;
}
/* Light theme */
@plugin "daisyui/theme" {
name: "todo";
default: true;
prefersdark: false;
color-scheme: light;
--color-base-100: oklch(100% 0 0);
--color-base-200: oklch(97% 0 0);
--color-base-300: oklch(92% 0 0);
--color-base-content: oklch(20% 0 0);
--color-primary: oklch(55% 0.18 260);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(55% 0.15 145);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(55% 0.18 260);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(25% 0 0);
--color-neutral-content: oklch(100% 0 0);
--color-info: oklch(70% 0.15 230);
--color-info-content: oklch(100% 0 0);
--color-success: oklch(65% 0.18 145);
--color-success-content: oklch(100% 0 0);
--color-warning: oklch(80% 0.15 80);
--color-warning-content: oklch(100% 0 0);
--color-error: oklch(65% 0.2 25);
--color-error-content: oklch(100% 0 0);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.75rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
/* Dark theme - auto-activates via prefers-color-scheme */
@plugin "daisyui/theme" {
name: "todo-dark";
prefersdark: true;
color-scheme: dark;
--color-base-100: oklch(18% 0 0);
--color-base-200: oklch(14% 0 0);
--color-base-300: oklch(22% 0 0);
--color-base-content: oklch(92% 0 0);
--color-primary: oklch(60% 0.18 260);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(60% 0.15 145);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(60% 0.18 260);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(85% 0 0);
--color-neutral-content: oklch(20% 0 0);
--color-info: oklch(70% 0.15 230);
--color-info-content: oklch(100% 0 0);
--color-success: oklch(65% 0.18 145);
--color-success-content: oklch(100% 0 0);
--color-warning: oklch(80% 0.15 80);
--color-warning-content: oklch(100% 0 0);
--color-error: oklch(65% 0.2 25);
--color-error-content: oklch(100% 0 0);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.75rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
html,
body {
@apply m-0 bg-base-200;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100%;
overflow: hidden;
}
/* Prevent Safari mobile from zooming on input focus (requires 16px minimum) */
input,
textarea,
select {
@apply text-base;
}
/* Global input focus styling - use primary color instead of default ring */
.input:focus,
.input:focus-visible {
outline: none;
border-color: var(--color-primary);
background-color: color-mix(in oklab, var(--color-primary) 10%, transparent);
}
/* Safe area utilities for mobile devices */
@layer utilities {
.pb-safe {
padding-bottom: calc(env(safe-area-inset-bottom, 0.5rem) + 0.5rem);
}
.pt-safe {
padding-top: max(env(safe-area-inset-top, 0), 12px);
}
}
/* Override DaisyUI dock's fixed positioning to use flex layout instead */
/* This allows the TabBar to work correctly with Safari's browser toolbar */
.dock {
position: relative;
bottom: auto;
left: auto;
right: auto;
border-top: none;
height: 4rem;
padding-bottom: 0;
}
/* Touch device optimizations */
@media (pointer: coarse) {
html {
touch-action: none;
}
body {
touch-action: pan-x pan-y;
}
}
/* Custom alert styling with darker borders and transparent background */
.alert {
@apply flex gap-3 rounded-lg border p-4 text-base-content/70;
}
.alert-warning {
@apply border-warning/70 bg-warning/40 text-base-content/90;
}
.alert-info {
@apply border-info/70 bg-info/40 text-base-content/90;
}
.alert-success {
@apply border-success/70 bg-success/40 text-base-content/90;
}
.alert-error {
@apply border-error/70 bg-error/40 text-base-content/90;
}
/* Keyword difficulty score badges */
.score-badge {
@apply border text-white;
border-color: color-mix(in oklab, currentColor 26%, transparent);
}
.score-tier-1 {
background-color: #22c55e;
}
.score-tier-2 {
background-color: #6bd84d;
}
.score-tier-3 {
background-color: #eab308;
}
.score-tier-4 {
background-color: #f97316;
}
.score-tier-5 {
background-color: #ef4444;
}
.score-tier-6 {
background-color: #dc2626;
}
@media (prefers-color-scheme: dark) {
:root {
--trend-grid-color: oklch(78% 0.01 255 / 0.22);
--trend-axis-color: oklch(84% 0.01 255 / 0.62);
--trend-fill-start-opacity: 0.24;
--trend-fill-end-opacity: 0.03;
--trend-tooltip-bg: oklch(21% 0.01 255 / 0.95);
--trend-tooltip-border: oklch(40% 0.01 255 / 0.65);
--trend-tooltip-shadow: oklch(0% 0 0 / 0.35);
}
.score-badge {
color: oklch(96% 0.01 95);
filter: saturate(1.26) brightness(1.14);
}
.score-tier-1 {
background-color: oklch(58% 0.12 148);
}
.score-tier-2 {
background-color: oklch(60% 0.115 132);
}
.score-tier-3 {
background-color: oklch(66% 0.11 92);
}
.score-tier-4 {
background-color: oklch(64% 0.12 56);
}
.score-tier-5 {
background-color: oklch(62% 0.12 36);
}
.score-tier-6 {
background-color: oklch(58% 0.11 30);
}
.alert-warning {
@apply border-warning/60 bg-warning/20;
}
.alert-info {
@apply border-info/60 bg-info/20;
}
.alert-success {
@apply border-success/60 bg-success/20;
}
.alert-error {
@apply border-error/60 bg-error/20;
}
}

View File

@ -0,0 +1,55 @@
/* ============================================
View Transitions API - Route Animations
============================================ */
/* Opt-in to view transitions */
@view-transition {
navigation: auto;
}
/* Named view transitions - only animate main content */
.main-content {
view-transition-name: main-content;
}
/* Exclude sidebar and tab bar from view transitions entirely */
.sidebar,
.tab-bar {
view-transition-name: none;
}
/* Fade transition for all navigation */
::view-transition-old(main-content) {
animation: vt-fade-out 150ms ease-out forwards;
}
::view-transition-new(main-content) {
animation: vt-fade-in 150ms ease-out forwards;
}
/* Keyframes */
@keyframes vt-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes vt-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* Reduce motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(main-content),
::view-transition-new(main-content) {
animation: none;
}
}

View File

@ -0,0 +1 @@
export { queryClient } from "./queryClient";

View File

@ -0,0 +1,10 @@
import { QueryClient } from "@tanstack/query-core";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60,
staleTime: 1000 * 60 * 5, // 5 minutes — show cached data instantly, refetch in background after
},
},
});

9
src/db/index.ts Normal file
View File

@ -0,0 +1,9 @@
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
import { env } from "cloudflare:workers";
// Helper function to get the database instance from D1 binding
export const db = drizzle(env.DB, { schema });
// Export schema for use in other files
export { schema };

262
src/db/schema.ts Normal file
View File

@ -0,0 +1,262 @@
import {
sqliteTable,
text,
integer,
real,
uniqueIndex,
index,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
// Users table
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// Projects for keyword research
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
domain: text("domain"),
// PSI keys are used for Google API abuse-control, not direct billing.
// We still keep handling explicit to make the tradeoff obvious.
pagespeedApiKey: text("pagespeed_api_key"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// Saved keywords within projects
export const savedKeywords = sqliteTable(
"saved_keywords",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
keyword: text("keyword").notNull(),
locationCode: integer("location_code").notNull().default(2840),
languageCode: text("language_code").notNull().default("en"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("saved_keywords_unique_project_keyword_location_language").on(
table.projectId,
table.keyword,
table.locationCode,
table.languageCode,
),
index("saved_keywords_project_created_idx").on(
table.projectId,
table.createdAt,
),
],
);
// Keyword metrics cache
export const keywordMetrics = sqliteTable(
"keyword_metrics",
{
id: integer("id").primaryKey({ autoIncrement: true }),
keyword: text("keyword").notNull(),
locationCode: integer("location_code").notNull(),
languageCode: text("language_code").notNull().default("en"),
searchVolume: integer("search_volume"),
cpc: real("cpc"),
competition: real("competition"),
keywordDifficulty: integer("keyword_difficulty"),
intent: text("intent"),
monthlySearches: text("monthly_searches"),
fetchedAt: text("fetched_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("keyword_metrics_unique_keyword_location_language").on(
table.keyword,
table.locationCode,
table.languageCode,
),
index("keyword_metrics_lookup_idx").on(
table.keyword,
table.locationCode,
table.languageCode,
table.fetchedAt,
),
],
);
// ============================================================================
// Site Audit tables
// ============================================================================
// One row per audit run
export const audits = sqliteTable(
"audits",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
startUrl: text("start_url").notNull(),
status: text("status", {
enum: ["running", "completed", "failed"],
})
.notNull()
.default("running"),
workflowInstanceId: text("workflow_instance_id"),
// JSON config: { maxPages, psiStrategy, psiApiKey? }
config: text("config").notNull().default("{}"),
// Progress & summary
pagesCrawled: integer("pages_crawled").notNull().default(0),
pagesTotal: integer("pages_total").notNull().default(0),
psiTotal: integer("psi_total").notNull().default(0),
psiCompleted: integer("psi_completed").notNull().default(0),
psiFailed: integer("psi_failed").notNull().default(0),
currentPhase: text("current_phase").default("discovery"),
startedAt: text("started_at")
.notNull()
.default(sql`(current_timestamp)`),
completedAt: text("completed_at"),
},
(table) => [
index("audits_project_id_idx").on(table.projectId),
index("audits_user_id_idx").on(table.userId),
],
);
// One row per crawled page
export const auditPages = sqliteTable(
"audit_pages",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
url: text("url").notNull(),
statusCode: integer("status_code"),
redirectUrl: text("redirect_url"),
// Metadata
title: text("title"),
metaDescription: text("meta_description"),
canonicalUrl: text("canonical_url"),
robotsMeta: text("robots_meta"),
// Open Graph
ogTitle: text("og_title"),
ogDescription: text("og_description"),
ogImage: text("og_image"),
// Headings
h1Count: integer("h1_count").notNull().default(0),
h2Count: integer("h2_count").notNull().default(0),
h3Count: integer("h3_count").notNull().default(0),
h4Count: integer("h4_count").notNull().default(0),
h5Count: integer("h5_count").notNull().default(0),
h6Count: integer("h6_count").notNull().default(0),
headingOrderJson: text("heading_order_json"), // JSON array of heading levels
// Content
wordCount: integer("word_count").notNull().default(0),
// Images
imagesTotal: integer("images_total").notNull().default(0),
imagesMissingAlt: integer("images_missing_alt").notNull().default(0),
imagesJson: text("images_json"), // JSON array of {src, alt} objects
// Links
internalLinkCount: integer("internal_link_count").notNull().default(0),
externalLinkCount: integer("external_link_count").notNull().default(0),
// Structured data
hasStructuredData: integer("has_structured_data", { mode: "boolean" })
.notNull()
.default(false),
// Hreflang
hreflangTagsJson: text("hreflang_tags_json"), // JSON array of hreflang values
// Indexability
isIndexable: integer("is_indexable", { mode: "boolean" })
.notNull()
.default(true),
// Performance
responseTimeMs: integer("response_time_ms"),
},
(table) => [index("audit_pages_audit_id_idx").on(table.auditId)],
);
// One row per PSI test (mobile + desktop per page)
export const auditPsiResults = sqliteTable(
"audit_psi_results",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
pageId: text("page_id")
.notNull()
.references(() => auditPages.id, { onDelete: "cascade" }),
strategy: text("strategy", { enum: ["mobile", "desktop"] }).notNull(),
performanceScore: integer("performance_score"),
accessibilityScore: integer("accessibility_score"),
bestPracticesScore: integer("best_practices_score"),
seoScore: integer("seo_score"),
lcpMs: real("lcp_ms"),
cls: real("cls"),
inpMs: real("inp_ms"),
ttfbMs: real("ttfb_ms"),
errorMessage: text("error_message"),
r2Key: text("r2_key"),
payloadSizeBytes: integer("payload_size_bytes"),
},
(table) => [index("audit_psi_results_audit_id_idx").on(table.auditId)],
);
// One row per on-demand PSI check (full raw in R2)
export const psiAuditResults = sqliteTable(
"psi_audit_results",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
requestedUrl: text("requested_url").notNull(),
finalUrl: text("final_url").notNull(),
strategy: text("strategy", { enum: ["mobile", "desktop"] }).notNull(),
status: text("status", { enum: ["completed", "failed"] })
.notNull()
.default("completed"),
performanceScore: integer("performance_score"),
accessibilityScore: integer("accessibility_score"),
bestPracticesScore: integer("best_practices_score"),
seoScore: integer("seo_score"),
firstContentfulPaint: text("first_contentful_paint"),
largestContentfulPaint: text("largest_contentful_paint"),
totalBlockingTime: text("total_blocking_time"),
cumulativeLayoutShift: text("cumulative_layout_shift"),
speedIndex: text("speed_index"),
timeToInteractive: text("time_to_interactive"),
lighthouseVersion: text("lighthouse_version"),
errorMessage: text("error_message"),
r2Key: text("r2_key"),
payloadSizeBytes: integer("payload_size_bytes"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
index("psi_audit_results_project_created_idx").on(
table.projectId,
table.createdAt,
),
index("psi_audit_results_project_strategy_idx").on(
table.projectId,
table.strategy,
),
],
);

20
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,20 @@
// Custom environment variable type definitions
// These extend the auto-generated Env interface from worker-configuration.d.ts
declare namespace Cloudflare {
interface Env {
R2: R2Bucket;
// Gateway URL
GATEWAY_URL: string;
// Optional machine token used for app-to-gateway requests
GATEWAY_APP_API_TOKEN?: string;
// Legacy alias retained for backwards compatibility
APP_TOKEN?: string;
// DataForSEO API Basic auth value (base64 of login:password)
DATAFORSEO_API_KEY: string;
}
}

View File

@ -0,0 +1,57 @@
import { createMiddleware } from "@tanstack/react-start";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import {
authenticateRequest,
getAuthConfig,
} from "@every-app/sdk/tanstack/server";
import { AppError } from "@/server/lib/errors";
import { logServerError } from "@/server/lib/logger";
export const ensureUserMiddleware = createMiddleware({
type: "function",
}).server(async (c) => {
const { next } = c;
const authConfig = getAuthConfig();
const session = await authenticateRequest(authConfig);
if (!session) {
throw new AppError("UNAUTHENTICATED");
}
if (!session.email) {
throw new AppError("UNAUTHENTICATED");
}
const userId = session.sub;
// Check if user exists
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
});
if (!user) {
try {
await db.insert(users).values({
id: userId,
email: session.email,
});
} catch (error) {
logServerError("auth.ensure-user.create", error, { userId });
throw new AppError("INTERNAL_ERROR");
}
}
const userEmail = user?.email || session.email;
return next({
context: {
userId,
userEmail,
session,
},
});
});

300
src/routeTree.gen.ts Normal file
View File

@ -0,0 +1,300 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as PProjectIdRouteRouteImport } from './routes/p/$projectId/route'
import { Route as PProjectIdIndexRouteImport } from './routes/p/$projectId/index'
import { Route as PProjectIdSavedRouteImport } from './routes/p/$projectId/saved'
import { Route as PProjectIdKeywordsRouteImport } from './routes/p/$projectId/keywords'
import { Route as PProjectIdDomainRouteImport } from './routes/p/$projectId/domain'
import { Route as PProjectIdAuditRouteImport } from './routes/p/$projectId/audit'
import { Route as PProjectIdAiRouteImport } from './routes/p/$projectId/ai'
import { Route as PProjectIdAuditIndexRouteImport } from './routes/p/$projectId/audit/index'
import { Route as PProjectIdPsiIssuesResultIdRouteImport } from './routes/p/$projectId/psi/issues/$resultId'
import { Route as PProjectIdAuditIssuesResultIdRouteImport } from './routes/p/$projectId/audit/issues/$resultId'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const PProjectIdRouteRoute = PProjectIdRouteRouteImport.update({
id: '/p/$projectId',
path: '/p/$projectId',
getParentRoute: () => rootRouteImport,
} as any)
const PProjectIdIndexRoute = PProjectIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const PProjectIdSavedRoute = PProjectIdSavedRouteImport.update({
id: '/saved',
path: '/saved',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const PProjectIdKeywordsRoute = PProjectIdKeywordsRouteImport.update({
id: '/keywords',
path: '/keywords',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const PProjectIdDomainRoute = PProjectIdDomainRouteImport.update({
id: '/domain',
path: '/domain',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const PProjectIdAuditRoute = PProjectIdAuditRouteImport.update({
id: '/audit',
path: '/audit',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const PProjectIdAiRoute = PProjectIdAiRouteImport.update({
id: '/ai',
path: '/ai',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const PProjectIdAuditIndexRoute = PProjectIdAuditIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PProjectIdAuditRoute,
} as any)
const PProjectIdPsiIssuesResultIdRoute =
PProjectIdPsiIssuesResultIdRouteImport.update({
id: '/psi/issues/$resultId',
path: '/psi/issues/$resultId',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const PProjectIdAuditIssuesResultIdRoute =
PProjectIdAuditIssuesResultIdRouteImport.update({
id: '/issues/$resultId',
path: '/issues/$resultId',
getParentRoute: () => PProjectIdAuditRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/p/$projectId': typeof PProjectIdRouteRouteWithChildren
'/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
'/p/$projectId/domain': typeof PProjectIdDomainRoute
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
'/p/$projectId/saved': typeof PProjectIdSavedRoute
'/p/$projectId/': typeof PProjectIdIndexRoute
'/p/$projectId/audit/': typeof PProjectIdAuditIndexRoute
'/p/$projectId/audit/issues/$resultId': typeof PProjectIdAuditIssuesResultIdRoute
'/p/$projectId/psi/issues/$resultId': typeof PProjectIdPsiIssuesResultIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/domain': typeof PProjectIdDomainRoute
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
'/p/$projectId/saved': typeof PProjectIdSavedRoute
'/p/$projectId': typeof PProjectIdIndexRoute
'/p/$projectId/audit': typeof PProjectIdAuditIndexRoute
'/p/$projectId/audit/issues/$resultId': typeof PProjectIdAuditIssuesResultIdRoute
'/p/$projectId/psi/issues/$resultId': typeof PProjectIdPsiIssuesResultIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/p/$projectId': typeof PProjectIdRouteRouteWithChildren
'/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
'/p/$projectId/domain': typeof PProjectIdDomainRoute
'/p/$projectId/keywords': typeof PProjectIdKeywordsRoute
'/p/$projectId/saved': typeof PProjectIdSavedRoute
'/p/$projectId/': typeof PProjectIdIndexRoute
'/p/$projectId/audit/': typeof PProjectIdAuditIndexRoute
'/p/$projectId/audit/issues/$resultId': typeof PProjectIdAuditIssuesResultIdRoute
'/p/$projectId/psi/issues/$resultId': typeof PProjectIdPsiIssuesResultIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/p/$projectId'
| '/p/$projectId/ai'
| '/p/$projectId/audit'
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
| '/p/$projectId/saved'
| '/p/$projectId/'
| '/p/$projectId/audit/'
| '/p/$projectId/audit/issues/$resultId'
| '/p/$projectId/psi/issues/$resultId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/p/$projectId/ai'
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
| '/p/$projectId/saved'
| '/p/$projectId'
| '/p/$projectId/audit'
| '/p/$projectId/audit/issues/$resultId'
| '/p/$projectId/psi/issues/$resultId'
id:
| '__root__'
| '/'
| '/p/$projectId'
| '/p/$projectId/ai'
| '/p/$projectId/audit'
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
| '/p/$projectId/saved'
| '/p/$projectId/'
| '/p/$projectId/audit/'
| '/p/$projectId/audit/issues/$resultId'
| '/p/$projectId/psi/issues/$resultId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
PProjectIdRouteRoute: typeof PProjectIdRouteRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/p/$projectId': {
id: '/p/$projectId'
path: '/p/$projectId'
fullPath: '/p/$projectId'
preLoaderRoute: typeof PProjectIdRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/p/$projectId/': {
id: '/p/$projectId/'
path: '/'
fullPath: '/p/$projectId/'
preLoaderRoute: typeof PProjectIdIndexRouteImport
parentRoute: typeof PProjectIdRouteRoute
}
'/p/$projectId/saved': {
id: '/p/$projectId/saved'
path: '/saved'
fullPath: '/p/$projectId/saved'
preLoaderRoute: typeof PProjectIdSavedRouteImport
parentRoute: typeof PProjectIdRouteRoute
}
'/p/$projectId/keywords': {
id: '/p/$projectId/keywords'
path: '/keywords'
fullPath: '/p/$projectId/keywords'
preLoaderRoute: typeof PProjectIdKeywordsRouteImport
parentRoute: typeof PProjectIdRouteRoute
}
'/p/$projectId/domain': {
id: '/p/$projectId/domain'
path: '/domain'
fullPath: '/p/$projectId/domain'
preLoaderRoute: typeof PProjectIdDomainRouteImport
parentRoute: typeof PProjectIdRouteRoute
}
'/p/$projectId/audit': {
id: '/p/$projectId/audit'
path: '/audit'
fullPath: '/p/$projectId/audit'
preLoaderRoute: typeof PProjectIdAuditRouteImport
parentRoute: typeof PProjectIdRouteRoute
}
'/p/$projectId/ai': {
id: '/p/$projectId/ai'
path: '/ai'
fullPath: '/p/$projectId/ai'
preLoaderRoute: typeof PProjectIdAiRouteImport
parentRoute: typeof PProjectIdRouteRoute
}
'/p/$projectId/audit/': {
id: '/p/$projectId/audit/'
path: '/'
fullPath: '/p/$projectId/audit/'
preLoaderRoute: typeof PProjectIdAuditIndexRouteImport
parentRoute: typeof PProjectIdAuditRoute
}
'/p/$projectId/psi/issues/$resultId': {
id: '/p/$projectId/psi/issues/$resultId'
path: '/psi/issues/$resultId'
fullPath: '/p/$projectId/psi/issues/$resultId'
preLoaderRoute: typeof PProjectIdPsiIssuesResultIdRouteImport
parentRoute: typeof PProjectIdRouteRoute
}
'/p/$projectId/audit/issues/$resultId': {
id: '/p/$projectId/audit/issues/$resultId'
path: '/issues/$resultId'
fullPath: '/p/$projectId/audit/issues/$resultId'
preLoaderRoute: typeof PProjectIdAuditIssuesResultIdRouteImport
parentRoute: typeof PProjectIdAuditRoute
}
}
}
interface PProjectIdAuditRouteChildren {
PProjectIdAuditIndexRoute: typeof PProjectIdAuditIndexRoute
PProjectIdAuditIssuesResultIdRoute: typeof PProjectIdAuditIssuesResultIdRoute
}
const PProjectIdAuditRouteChildren: PProjectIdAuditRouteChildren = {
PProjectIdAuditIndexRoute: PProjectIdAuditIndexRoute,
PProjectIdAuditIssuesResultIdRoute: PProjectIdAuditIssuesResultIdRoute,
}
const PProjectIdAuditRouteWithChildren = PProjectIdAuditRoute._addFileChildren(
PProjectIdAuditRouteChildren,
)
interface PProjectIdRouteRouteChildren {
PProjectIdAiRoute: typeof PProjectIdAiRoute
PProjectIdAuditRoute: typeof PProjectIdAuditRouteWithChildren
PProjectIdDomainRoute: typeof PProjectIdDomainRoute
PProjectIdKeywordsRoute: typeof PProjectIdKeywordsRoute
PProjectIdSavedRoute: typeof PProjectIdSavedRoute
PProjectIdIndexRoute: typeof PProjectIdIndexRoute
PProjectIdPsiIssuesResultIdRoute: typeof PProjectIdPsiIssuesResultIdRoute
}
const PProjectIdRouteRouteChildren: PProjectIdRouteRouteChildren = {
PProjectIdAiRoute: PProjectIdAiRoute,
PProjectIdAuditRoute: PProjectIdAuditRouteWithChildren,
PProjectIdDomainRoute: PProjectIdDomainRoute,
PProjectIdKeywordsRoute: PProjectIdKeywordsRoute,
PProjectIdSavedRoute: PProjectIdSavedRoute,
PProjectIdIndexRoute: PProjectIdIndexRoute,
PProjectIdPsiIssuesResultIdRoute: PProjectIdPsiIssuesResultIdRoute,
}
const PProjectIdRouteRouteWithChildren = PProjectIdRouteRoute._addFileChildren(
PProjectIdRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
PProjectIdRouteRoute: PProjectIdRouteRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

16
src/router.tsx Normal file
View File

@ -0,0 +1,16 @@
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "./client/components/DefaultCatchBoundary";
import { NotFound } from "./client/components/NotFound";
export function getRouter() {
const router = createTanStackRouter({
routeTree,
defaultPreload: "intent",
defaultErrorComponent: DefaultCatchBoundary,
defaultNotFoundComponent: () => <NotFound />,
scrollRestoration: true,
});
return router;
}

223
src/routes/__root.tsx Normal file
View File

@ -0,0 +1,223 @@
/// <reference types="vite/client" />
import {
ClientOnly,
HeadContent,
Link,
Scripts,
createRootRoute,
Outlet,
useLocation,
} from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { TanStackDevtools } from "@tanstack/react-devtools";
import { QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { useState } from "react";
import { Menu, ChevronsUpDown } from "lucide-react";
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
import { NotFound } from "@/client/components/NotFound";
import appCss from "@/client/styles/app.css?url";
import { Toaster } from "sonner";
import { Sidebar } from "@/client/components/Sidebar";
import { EmbeddedAppProvider } from "@every-app/sdk/tanstack";
import { queryClient } from "@/client/tanstack-db";
import { projectNavItems } from "@/client/navigation/items";
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1, viewport-fit=cover",
},
{
name: "apple-mobile-web-app-capable",
content: "yes",
},
{
name: "apple-mobile-web-app-status-bar-style",
content: "black-translucent",
},
],
links: [
{ rel: "stylesheet", href: appCss },
{
rel: "apple-touch-icon",
sizes: "180x180",
href: "/apple-touch-icon.png",
},
{
rel: "icon",
type: "image/png",
sizes: "32x32",
href: "/favicon-32x32.png",
},
{
rel: "icon",
type: "image/png",
sizes: "16x16",
href: "/favicon-16x16.png",
},
{ rel: "manifest", href: "/site.webmanifest", color: "#fffff" },
{ rel: "icon", href: "/favicon.ico" },
],
scripts: [],
}),
component: AppLayout,
errorComponent: DefaultCatchBoundary,
notFoundComponent: () => <NotFound />,
shellComponent: RootDocument,
});
function AppLayout() {
const location = useLocation();
const [drawerOpen, setDrawerOpen] = useState(false);
// Extract projectId from the current path
const projectIdMatch = location.pathname.match(/^\/p\/([^/]+)/);
const projectId = projectIdMatch?.[1] ?? null;
return (
<div className="flex flex-col h-[100dvh] bg-base-200">
{/* Top Navbar */}
<div className="navbar bg-base-100 border-b border-base-300 shrink-0 gap-2">
{/* Mobile: hamburger + title */}
<div className="flex-none flex items-center md:hidden">
<button
type="button"
className="btn btn-square btn-ghost"
aria-label="Toggle sidebar"
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen(true)}
>
<Menu className="h-6 w-6" />
</button>
<span className="font-semibold text-base-content ml-1">
Every App
</span>
</div>
{/* Desktop: EveryApp brand + nav links (left) */}
<div className="hidden md:flex items-center gap-1">
<a
href={import.meta.env.VITE_GATEWAY_URL}
target="_top"
className="text-lg font-semibold text-base-content hover:text-primary transition-colors px-2"
>
Every App
</a>
{projectId &&
projectNavItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname.includes(item.matchSegment);
return (
<Link
key={item.to}
to={item.to}
params={{ projectId }}
className={`btn btn-sm gap-2 ${
isActive
? "bg-primary/10 text-primary font-medium border-transparent"
: "btn-ghost text-base-content/60 hover:text-base-content"
}`}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Desktop: project switcher (right-aligned) */}
<div className="flex-none hidden md:flex">
<div
className="tooltip tooltip-left before:whitespace-nowrap"
data-tip="Multiple projects coming soon"
>
<button className="btn btn-ghost btn-sm font-medium text-sm gap-1 cursor-default">
<span className="truncate">Default</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
</button>
</div>
</div>
</div>
{/* Mobile: drawer layout */}
<div className="flex-1 min-h-0 md:hidden">
<div className="h-full overflow-auto">
<Outlet />
</div>
{drawerOpen ? (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close sidebar"
className="absolute inset-0 bg-black/45"
onClick={() => setDrawerOpen(false)}
/>
<div className="absolute left-0 top-0 h-full">
<Sidebar
currentPath={location.pathname}
projectId={projectId}
onNavigate={() => setDrawerOpen(false)}
onClose={() => setDrawerOpen(false)}
/>
</div>
</div>
) : null}
</div>
{/* Desktop: plain content area */}
<div className="hidden md:block flex-1 min-h-0 overflow-auto">
<Outlet />
</div>
</div>
);
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<HeadContent />
</head>
<body>
<ClientOnly>
<QueryClientProvider client={queryClient}>
<EmbeddedAppProvider appId={import.meta.env.VITE_APP_ID}>
<>
{children}
<Toaster
position="bottom-right"
mobileOffset={{ bottom: 100 }}
/>
{import.meta.env.DEV ? (
<TanStackDevtools
config={{ position: "bottom-right" }}
eventBusConfig={{ connectToServerBus: true }}
plugins={[
{
name: "TanStack Router",
render: <TanStackRouterDevtoolsPanel />,
defaultOpen: true,
},
]}
/>
) : null}
</>
</EmbeddedAppProvider>
</QueryClientProvider>
</ClientOnly>
<Scripts />
</body>
</html>
);
}

43
src/routes/index.tsx Normal file
View File

@ -0,0 +1,43 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
import { getOrCreateDefaultProject } from "@/serverFunctions/keywords";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
export const Route = createFileRoute("/")({
component: IndexRedirect,
});
function IndexRedirect() {
const navigate = useNavigate();
const { mutate, error, isError } = useMutation({
mutationFn: () => getOrCreateDefaultProject(),
onSuccess: (project) => {
void navigate({
to: "/p/$projectId/keywords",
params: { projectId: project.id },
});
},
});
useEffect(() => {
mutate();
}, [mutate]);
if (isError) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-error">
{getStandardErrorMessage(error, "Failed to load. Please try again.")}
</p>
</div>
);
}
return (
<div className="flex items-center justify-center h-full">
<span className="loading loading-spinner loading-md" />
</div>
);
}

View File

@ -0,0 +1,128 @@
import { createFileRoute } from "@tanstack/react-router";
import { ArrowUpRight, Bot, Compass, Lightbulb, Sparkles } from "lucide-react";
const DISCORD_URL = "https://discord.gg/c9uGs3cFXr";
const SUPPORT_EMAIL = "ben@everyapp.com";
const DATAFORSEO_MCP_DOCS_URL =
"https://dataforseo.com/help-center/setting-up-the-official-dataforseo-mcp-server-simple-guide";
export const Route = createFileRoute("/p/$projectId/ai")({
component: AiPage,
});
function AiPage() {
return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4">
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<div className="flex items-center gap-2 text-primary">
<Sparkles className="size-5" />
<span className="text-sm font-semibold uppercase tracking-wide">
Coming Soon
</span>
</div>
<h1 className="text-2xl font-semibold">
AI-powered features are coming soon
</h1>
<p className="text-sm text-base-content/70 max-w-3xl">
We want this to be community driven. If there&apos;s a workflow
you want solved first, let me know!
</p>
<div className="text-sm text-base-content/80">
Message us on{" "}
<a
className="link link-primary"
href={DISCORD_URL}
target="_blank"
rel="noreferrer"
>
Discord
</a>{" "}
or email me at{" "}
<a className="link link-primary" href={`mailto:${SUPPORT_EMAIL}`}>
{SUPPORT_EMAIL}
</a>
.
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<div className="flex items-center gap-2">
<Sparkles className="size-4 text-primary" />
<h2 className="card-title text-base">
Planned: Content Assistant
</h2>
</div>
<p className="text-sm text-base-content/70">
Generate blog post drafts using your saved keywords, business
context, and general strategy.
</p>
</div>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<div className="flex items-center gap-2">
<Bot className="size-4 text-primary" />
<h2 className="card-title text-base">
Planned: SEO Research Agent
</h2>
</div>
<p className="text-sm text-base-content/70">
Ask SEO questions, run focused research, and get help using the
app without leaving your workflow.
</p>
</div>
</div>
<div className="card bg-base-100 border border-base-300 md:col-span-2">
<div className="card-body gap-3">
<div className="flex items-center gap-2">
<Lightbulb className="size-4 text-primary" />
<h2 className="card-title text-base">
Content Assistant workflow today
</h2>
</div>
<p className="text-sm text-base-content/70">
If you want content generation right now, make a local folder
and use Claude Code, Claude/Cowork, Cursor, Codex, or a similar
coding agent. Paste in your keywords, business plan, and
strategy, then iterate with the agent until the draft is right.
</p>
</div>
</div>
<div className="card bg-base-100 border border-base-300 md:col-span-2">
<div className="card-body gap-3">
<div className="flex items-center gap-2">
<Compass className="size-4 text-primary" />
<h2 className="card-title text-base">
DataForSEO MCP for agentic workflows
</h2>
</div>
<p className="text-sm text-base-content/70">
If you want the best agentic path for DataForSEO API data, use
the official DataForSEO MCP server setup guide.
</p>
<div>
<a
className="btn btn-outline btn-sm"
href={DATAFORSEO_MCP_DOCS_URL}
target="_blank"
rel="noreferrer"
>
Open DataForSEO MCP docs
<ArrowUpRight className="size-3.5" />
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/p/$projectId/audit")({
component: SiteAuditLayout,
});
function SiteAuditLayout() {
return <Outlet />;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,554 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery } from "@tanstack/react-query";
import type { ReactNode } from "react";
import {
ChevronDown,
Copy,
Download,
ExternalLink,
FileWarning,
Info,
TriangleAlert,
} from "lucide-react";
import { toast } from "sonner";
import { exportPsiBySource, getPsiIssuesBySource } from "@/serverFunctions/psi";
import { psiIssuesSearchSchema } from "@/types/schemas/psi";
const categoryTabs = [
"all",
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
type CategoryTab = (typeof categoryTabs)[number];
type IssueCategory = Exclude<CategoryTab, "all">;
type ExportPayload = {
mode: "full" | "issues" | "category";
category?: IssueCategory;
};
type PsiIssue = {
auditKey: string;
category: IssueCategory;
severity: "critical" | "warning" | "info";
score?: number | null;
title: string;
displayValue?: string | null;
description?: string | null;
impactMs?: number | null;
impactBytes?: number | null;
items: string[];
};
export const Route = createFileRoute("/p/$projectId/audit/issues/$resultId")({
validateSearch: psiIssuesSearchSchema,
component: PsiIssuesPage,
});
function PsiIssuesPage() {
const { projectId, resultId } = Route.useParams();
const { source, category } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const issuesQuery = useQuery({
queryKey: ["psiIssuesBySource", projectId, source, resultId, category],
queryFn: () =>
getPsiIssuesBySource({
data: {
projectId,
source,
resultId,
category: category === "all" ? undefined : category,
},
}),
});
const summaryQuery = useQuery({
queryKey: ["psiIssuesSummary", projectId, source, resultId],
queryFn: () =>
getPsiIssuesBySource({
data: {
projectId,
source,
resultId,
},
}),
});
const exportMutation = useMutation({
mutationFn: (data: ExportPayload) =>
exportPsiBySource({
data: {
projectId,
source,
resultId,
...data,
},
}),
});
const visibleIssues = (issuesQuery.data?.issues ?? []) as PsiIssue[];
const allIssues = (summaryQuery.data?.issues ?? visibleIssues) as PsiIssue[];
const categoryCounts = categoryTabs.reduce<Record<CategoryTab, number>>(
(acc, tab) => {
if (tab === "all") {
acc[tab] = allIssues.length;
return acc;
}
acc[tab] = allIssues.filter((issue) => issue.category === tab).length;
return acc;
},
{
all: allIssues.length,
performance: 0,
accessibility: 0,
"best-practices": 0,
seo: 0,
},
);
const severityCounts = {
critical: visibleIssues.filter((issue) => issue.severity === "critical")
.length,
warning: visibleIssues.filter((issue) => issue.severity === "warning")
.length,
info: visibleIssues.filter((issue) => issue.severity === "info").length,
};
const exportCurrentCategory: ExportPayload =
category === "all"
? { mode: "issues" }
: {
mode: "category",
category,
};
const selectedCategoryLabel = categoryLabel(category);
const runExport = async (data: ExportPayload) => {
try {
const exported = await exportMutation.mutateAsync(data);
downloadTextFile(exported.filename, exported.content, "application/json");
toast.success("Download started");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to export payload";
toast.error(message);
}
};
const runExportCsv = (issues: PsiIssue[], variant: "all" | "current") => {
const filename = `psi-${variant}-${categorySlug(category)}-issues.csv`;
downloadTextFile(filename, issuesToCsv(issues), "text/csv");
toast.success("CSV download started");
};
const runCopy = async (data: ExportPayload, toastMessage: string) => {
try {
const exported = await exportMutation.mutateAsync(data);
await navigator.clipboard.writeText(exported.content);
toast.success(toastMessage);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to copy payload";
toast.error(message);
}
};
const isBusy = exportMutation.isPending;
return (
<div className="px-4 py-3 md:px-6 md:py-4 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4">
<div className="flex items-center justify-between gap-3">
<button
className="btn btn-ghost btn-sm px-2"
onClick={() =>
navigate({
to: "/p/$projectId/audit",
params: { projectId },
})
}
>
&larr; Back to Site Audit
</button>
<span className="text-xs text-base-content/60">
{issuesQuery.data?.createdAt
? `Scanned ${new Date(issuesQuery.data.createdAt).toLocaleString()}`
: "Reading latest issues..."}
</span>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body py-5 gap-4">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">PSI Issues</h1>
<p className="text-sm text-base-content/70 break-all">
{issuesQuery.data?.finalUrl ?? "Loading URL..."}
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="badge border border-error/30 bg-error/10 text-error/80 gap-1">
<FileWarning className="size-3" />
Critical {severityCounts.critical}
</span>
<span className="badge border border-warning/30 bg-warning/10 text-warning/80 gap-1">
<TriangleAlert className="size-3" />
Warning {severityCounts.warning}
</span>
<span className="badge border border-info/30 bg-info/10 text-info/80 gap-1">
<Info className="size-3" />
Info {severityCounts.info}
</span>
</div>
</div>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<div className="sticky top-0 z-[2] -mx-2 px-2 py-2 bg-base-100/95 backdrop-blur-sm border-b border-base-300/60">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-4">
{categoryTabs.map((tab) => (
<button
key={tab}
className={`pb-2 border-b-2 text-sm font-medium transition-colors ${
category === tab
? "border-primary text-base-content"
: "border-transparent text-base-content/60 hover:text-base-content"
}`}
onClick={() =>
navigate({
search: (prev) => ({
...prev,
category: tab,
}),
replace: true,
})
}
>
<span>{categoryLabel(tab)}</span>
<span className="ml-1 text-xs opacity-70">
({categoryCounts[tab]})
</span>
</button>
))}
</div>
<div className="flex items-center gap-2">
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
className="btn btn-sm gap-1"
>
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-72"
>
<li className="menu-title">
<span>Copy</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy(
exportCurrentCategory,
`Copied ${selectedCategoryLabel.toLowerCase()} issues`,
)
}
>
<Copy className="size-4" />
Copy {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy({ mode: "issues" }, "Copied all issues")
}
>
<Copy className="size-4" />
Copy all issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy(
{ mode: "full" },
"Copied full Lighthouse report",
)
}
>
<Copy className="size-4" />
Copy full Lighthouse report
</button>
</li>
<li className="menu-title">
<span>Download JSON</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport(exportCurrentCategory)}
>
Download {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport({ mode: "issues" })}
>
Download all issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport({ mode: "full" })}
>
Download full Lighthouse report
</button>
</li>
<li className="menu-title">
<span>Download CSV</span>
</li>
<li>
<button
disabled={!visibleIssues.length}
onClick={() => runExportCsv(visibleIssues, "current")}
>
Download {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={!allIssues.length}
onClick={() => runExportCsv(allIssues, "all")}
>
Download all issues
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
{issuesQuery.isLoading ? (
<p className="text-sm text-base-content/60">Loading issues...</p>
) : visibleIssues.length ? (
<div className="space-y-3">
{visibleIssues.map((issue) => (
<div
key={`${issue.category}-${issue.auditKey}`}
className="card bg-base-200/30 border border-base-300"
>
<div className="card-body p-5 gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="badge badge-outline">
{issue.category}
</span>
<span
className={`badge border ${severityBadgeClass(issue.severity)} gap-1`}
>
{severityIcon(issue.severity)}
{issue.severity}
</span>
{issue.score != null && (
<div
className="tooltip tooltip-top"
data-tip="Lighthouse score from 0-100 for this audit. Lower means larger opportunity for improvement."
>
<span className="badge badge-ghost cursor-help">
Score {issue.score}
</span>
</div>
)}
</div>
{(issue.impactMs != null ||
issue.impactBytes != null) && (
<span className="text-xs text-base-content/60">
Impact {issue.impactMs ?? 0}ms /{" "}
{issue.impactBytes ?? 0} bytes
</span>
)}
</div>
<p className="font-semibold leading-tight">
{issue.title}
</p>
{issue.displayValue && (
<p className="text-sm text-base-content/70">
{issue.displayValue}
</p>
)}
{issue.description && (
<div className="text-sm text-base-content/80 leading-relaxed">
{renderInlineMarkdown(issue.description)}
</div>
)}
{issue.items.length > 0 && (
<details className="text-sm bg-base-100 rounded-box border border-base-300/80 px-3 py-2">
<summary className="cursor-pointer font-medium text-base-content/75">
Affected items ({issue.items.length})
</summary>
<div className="mt-2 space-y-2">
{issue.items.map((item) => (
<pre
key={`${issue.auditKey}-${item}`}
className="bg-base-200/60 p-2 rounded-box overflow-x-auto text-xs"
>
{item}
</pre>
))}
</div>
</details>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-base-content/60">
No unresolved issues for this category.
</p>
)}
</div>
</div>
</div>
</div>
);
}
function categoryLabel(category: CategoryTab) {
if (category === "best-practices") return "Best practices";
if (category === "all") return "All";
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
}
function categorySlug(category: CategoryTab) {
return category === "all" ? "all" : category;
}
function issuesToCsv(issues: PsiIssue[]) {
const headers = [
"Category",
"Severity",
"Score",
"Title",
"Display Value",
"Description",
"Impact (ms)",
"Impact (bytes)",
"Affected Items",
];
const rows = issues.map((issue) => [
issue.category,
issue.severity,
issue.score ?? "",
issue.title,
issue.displayValue ?? "",
issue.description ?? "",
issue.impactMs ?? "",
issue.impactBytes ?? "",
issue.items.length,
]);
return [
headers.map(csvEscape).join(","),
...rows.map((row) => row.map(csvEscape).join(",")),
].join("\n");
}
function csvEscape(value: string | number) {
const text = String(value);
if (text.includes(",") || text.includes('"') || text.includes("\n")) {
return `"${text.replaceAll('"', '""')}"`;
}
return text;
}
function renderInlineMarkdown(markdown: string): ReactNode {
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
const nodes: ReactNode[] = [];
let cursor = 0;
let match = linkPattern.exec(markdown);
while (match) {
const [raw, label, href] = match;
const index = match.index;
if (index > cursor) {
nodes.push(markdown.slice(cursor, index));
}
nodes.push(
<a
key={`${href}-${index}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="link link-primary inline-flex items-center gap-1"
>
{label}
<ExternalLink className="size-3" />
</a>,
);
cursor = index + raw.length;
match = linkPattern.exec(markdown);
}
if (cursor < markdown.length) {
nodes.push(markdown.slice(cursor));
}
if (!nodes.length) {
return markdown;
}
return nodes;
}
function downloadTextFile(filename: string, content: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
function severityBadgeClass(severity: "critical" | "warning" | "info") {
if (severity === "critical")
return "border-error/30 bg-error/10 text-error/80";
if (severity === "warning")
return "border-warning/35 bg-warning/10 text-warning/80";
return "border-info/30 bg-info/10 text-info/80";
}
function severityIcon(severity: "critical" | "warning" | "info") {
if (severity === "critical") return <FileWarning className="size-3" />;
if (severity === "warning") return <TriangleAlert className="size-3" />;
return <Info className="size-3" />;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/p/$projectId/")({
beforeLoad: ({ params }) => {
throw redirect({
to: "/p/$projectId/keywords",
params: { projectId: params.projectId },
});
},
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,554 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery } from "@tanstack/react-query";
import type { ReactNode } from "react";
import {
ChevronDown,
Copy,
Download,
ExternalLink,
FileWarning,
Info,
TriangleAlert,
} from "lucide-react";
import { toast } from "sonner";
import { exportPsiBySource, getPsiIssuesBySource } from "@/serverFunctions/psi";
import { psiIssuesSearchSchema } from "@/types/schemas/psi";
const categoryTabs = [
"all",
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
type CategoryTab = (typeof categoryTabs)[number];
type IssueCategory = Exclude<CategoryTab, "all">;
type ExportPayload = {
mode: "full" | "issues" | "category";
category?: IssueCategory;
};
type PsiIssue = {
auditKey: string;
category: IssueCategory;
severity: "critical" | "warning" | "info";
score?: number | null;
title: string;
displayValue?: string | null;
description?: string | null;
impactMs?: number | null;
impactBytes?: number | null;
items: string[];
};
export const Route = createFileRoute("/p/$projectId/psi/issues/$resultId")({
validateSearch: psiIssuesSearchSchema,
component: PsiIssuesPage,
});
function PsiIssuesPage() {
const { projectId, resultId } = Route.useParams();
const { source, category } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const issuesQuery = useQuery({
queryKey: ["psiIssuesBySource", projectId, source, resultId, category],
queryFn: () =>
getPsiIssuesBySource({
data: {
projectId,
source,
resultId,
category: category === "all" ? undefined : category,
},
}),
});
const summaryQuery = useQuery({
queryKey: ["psiIssuesSummary", projectId, source, resultId],
queryFn: () =>
getPsiIssuesBySource({
data: {
projectId,
source,
resultId,
},
}),
});
const exportMutation = useMutation({
mutationFn: (data: ExportPayload) =>
exportPsiBySource({
data: {
projectId,
source,
resultId,
...data,
},
}),
});
const visibleIssues = (issuesQuery.data?.issues ?? []) as PsiIssue[];
const allIssues = (summaryQuery.data?.issues ?? visibleIssues) as PsiIssue[];
const categoryCounts = categoryTabs.reduce<Record<CategoryTab, number>>(
(acc, tab) => {
if (tab === "all") {
acc[tab] = allIssues.length;
return acc;
}
acc[tab] = allIssues.filter((issue) => issue.category === tab).length;
return acc;
},
{
all: allIssues.length,
performance: 0,
accessibility: 0,
"best-practices": 0,
seo: 0,
},
);
const severityCounts = {
critical: visibleIssues.filter((issue) => issue.severity === "critical")
.length,
warning: visibleIssues.filter((issue) => issue.severity === "warning")
.length,
info: visibleIssues.filter((issue) => issue.severity === "info").length,
};
const exportCurrentCategory: ExportPayload =
category === "all"
? { mode: "issues" }
: {
mode: "category",
category,
};
const selectedCategoryLabel = categoryLabel(category);
const runExport = async (data: ExportPayload) => {
try {
const exported = await exportMutation.mutateAsync(data);
downloadTextFile(exported.filename, exported.content, "application/json");
toast.success("Download started");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to export payload";
toast.error(message);
}
};
const runExportCsv = (issues: PsiIssue[], variant: "all" | "current") => {
const filename = `psi-${variant}-${categorySlug(category)}-issues.csv`;
downloadTextFile(filename, issuesToCsv(issues), "text/csv");
toast.success("CSV download started");
};
const runCopy = async (data: ExportPayload, toastMessage: string) => {
try {
const exported = await exportMutation.mutateAsync(data);
await navigator.clipboard.writeText(exported.content);
toast.success(toastMessage);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to copy payload";
toast.error(message);
}
};
const isBusy = exportMutation.isPending;
return (
<div className="px-4 py-3 md:px-6 md:py-4 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4">
<div className="flex items-center justify-between gap-3">
<button
className="btn btn-ghost btn-sm px-2"
onClick={() =>
navigate({
to: source === "site" ? "/p/$projectId/audit" : "..",
params: { projectId },
})
}
>
&larr; Back to {source === "site" ? "Site Audit" : "PSI"}
</button>
<span className="text-xs text-base-content/60">
{issuesQuery.data?.createdAt
? `Scanned ${new Date(issuesQuery.data.createdAt).toLocaleString()}`
: "Reading latest issues..."}
</span>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body py-5 gap-4">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">PSI Issues</h1>
<p className="text-sm text-base-content/70 break-all">
{issuesQuery.data?.finalUrl ?? "Loading URL..."}
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="badge border border-error/30 bg-error/10 text-error/80 gap-1">
<FileWarning className="size-3" />
Critical {severityCounts.critical}
</span>
<span className="badge border border-warning/30 bg-warning/10 text-warning/80 gap-1">
<TriangleAlert className="size-3" />
Warning {severityCounts.warning}
</span>
<span className="badge border border-info/30 bg-info/10 text-info/80 gap-1">
<Info className="size-3" />
Info {severityCounts.info}
</span>
</div>
</div>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<div className="sticky top-0 z-[2] -mx-2 px-2 py-2 bg-base-100/95 backdrop-blur-sm border-b border-base-300/60">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-4">
{categoryTabs.map((tab) => (
<button
key={tab}
className={`pb-2 border-b-2 text-sm font-medium transition-colors ${
category === tab
? "border-primary text-base-content"
: "border-transparent text-base-content/60 hover:text-base-content"
}`}
onClick={() =>
navigate({
search: (prev) => ({
...prev,
category: tab,
}),
replace: true,
})
}
>
<span>{categoryLabel(tab)}</span>
<span className="ml-1 text-xs opacity-70">
({categoryCounts[tab]})
</span>
</button>
))}
</div>
<div className="flex items-center gap-2">
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
className="btn btn-sm gap-1"
>
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-72"
>
<li className="menu-title">
<span>Copy</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy(
exportCurrentCategory,
`Copied ${selectedCategoryLabel.toLowerCase()} issues`,
)
}
>
<Copy className="size-4" />
Copy {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy({ mode: "issues" }, "Copied all issues")
}
>
<Copy className="size-4" />
Copy all issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy(
{ mode: "full" },
"Copied full Lighthouse report",
)
}
>
<Copy className="size-4" />
Copy full Lighthouse report
</button>
</li>
<li className="menu-title">
<span>Download JSON</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport(exportCurrentCategory)}
>
Download {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport({ mode: "issues" })}
>
Download all issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport({ mode: "full" })}
>
Download full Lighthouse report
</button>
</li>
<li className="menu-title">
<span>Download CSV</span>
</li>
<li>
<button
disabled={!visibleIssues.length}
onClick={() => runExportCsv(visibleIssues, "current")}
>
Download {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={!allIssues.length}
onClick={() => runExportCsv(allIssues, "all")}
>
Download all issues
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
{issuesQuery.isLoading ? (
<p className="text-sm text-base-content/60">Loading issues...</p>
) : visibleIssues.length ? (
<div className="space-y-3">
{visibleIssues.map((issue) => (
<div
key={`${issue.category}-${issue.auditKey}`}
className="card bg-base-200/30 border border-base-300"
>
<div className="card-body p-5 gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="badge badge-outline">
{issue.category}
</span>
<span
className={`badge border ${severityBadgeClass(issue.severity)} gap-1`}
>
{severityIcon(issue.severity)}
{issue.severity}
</span>
{issue.score != null && (
<div
className="tooltip tooltip-top"
data-tip="Lighthouse score from 0-100 for this audit. Lower means larger opportunity for improvement."
>
<span className="badge badge-ghost cursor-help">
Score {issue.score}
</span>
</div>
)}
</div>
{(issue.impactMs != null ||
issue.impactBytes != null) && (
<span className="text-xs text-base-content/60">
Impact {issue.impactMs ?? 0}ms /{" "}
{issue.impactBytes ?? 0} bytes
</span>
)}
</div>
<p className="font-semibold leading-tight">
{issue.title}
</p>
{issue.displayValue && (
<p className="text-sm text-base-content/70">
{issue.displayValue}
</p>
)}
{issue.description && (
<div className="text-sm text-base-content/80 leading-relaxed">
{renderInlineMarkdown(issue.description)}
</div>
)}
{issue.items.length > 0 && (
<details className="text-sm bg-base-100 rounded-box border border-base-300/80 px-3 py-2">
<summary className="cursor-pointer font-medium text-base-content/75">
Affected items ({issue.items.length})
</summary>
<div className="mt-2 space-y-2">
{issue.items.map((item) => (
<pre
key={`${issue.auditKey}-${item}`}
className="bg-base-200/60 p-2 rounded-box overflow-x-auto text-xs"
>
{item}
</pre>
))}
</div>
</details>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-base-content/60">
No unresolved issues for this category.
</p>
)}
</div>
</div>
</div>
</div>
);
}
function categoryLabel(category: CategoryTab) {
if (category === "best-practices") return "Best practices";
if (category === "all") return "All";
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
}
function categorySlug(category: CategoryTab) {
return category === "all" ? "all" : category;
}
function issuesToCsv(issues: PsiIssue[]) {
const headers = [
"Category",
"Severity",
"Score",
"Title",
"Display Value",
"Description",
"Impact (ms)",
"Impact (bytes)",
"Affected Items",
];
const rows = issues.map((issue) => [
issue.category,
issue.severity,
issue.score ?? "",
issue.title,
issue.displayValue ?? "",
issue.description ?? "",
issue.impactMs ?? "",
issue.impactBytes ?? "",
issue.items.length,
]);
return [
headers.map(csvEscape).join(","),
...rows.map((row) => row.map(csvEscape).join(",")),
].join("\n");
}
function csvEscape(value: string | number) {
const text = String(value);
if (text.includes(",") || text.includes('"') || text.includes("\n")) {
return `"${text.replaceAll('"', '""')}"`;
}
return text;
}
function renderInlineMarkdown(markdown: string): ReactNode {
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
const nodes: ReactNode[] = [];
let cursor = 0;
let match = linkPattern.exec(markdown);
while (match) {
const [raw, label, href] = match;
const index = match.index;
if (index > cursor) {
nodes.push(markdown.slice(cursor, index));
}
nodes.push(
<a
key={`${href}-${index}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="link link-primary inline-flex items-center gap-1"
>
{label}
<ExternalLink className="size-3" />
</a>,
);
cursor = index + raw.length;
match = linkPattern.exec(markdown);
}
if (cursor < markdown.length) {
nodes.push(markdown.slice(cursor));
}
if (!nodes.length) {
return markdown;
}
return nodes;
}
function downloadTextFile(filename: string, content: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
function severityBadgeClass(severity: "critical" | "warning" | "info") {
if (severity === "critical")
return "border-error/30 bg-error/10 text-error/80";
if (severity === "warning")
return "border-warning/35 bg-warning/10 text-warning/80";
return "border-info/30 bg-info/10 text-info/80";
}
function severityIcon(severity: "critical" | "warning" | "info") {
if (severity === "critical") return <FileWarning className="size-3" />;
if (severity === "warning") return <TriangleAlert className="size-3" />;
return <Info className="size-3" />;
}

View File

@ -0,0 +1,44 @@
import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useCurrentUser } from "@every-app/sdk/tanstack";
import { getProject } from "@/serverFunctions/keywords";
export const Route = createFileRoute("/p/$projectId")({
component: ProjectLayout,
});
function ProjectLayout() {
const params = Route.useParams();
const { projectId } = params;
const navigate = useNavigate();
const user = useCurrentUser();
const {
data: project,
isLoading,
isError,
} = useQuery({
queryKey: ["project", projectId],
queryFn: () => getProject({ data: { projectId } }),
enabled: user !== null,
});
useEffect(() => {
if (user !== null && !isLoading && (isError || !project)) {
void navigate({ to: "/" });
}
}, [isLoading, isError, project, navigate, user]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<span className="loading loading-spinner loading-md" />
</div>
);
}
if (!project) return null;
return <Outlet />;
}

View File

@ -0,0 +1,238 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getSavedKeywords,
removeSavedKeyword,
} from "@/serverFunctions/keywords";
import { Trash2, Download, Search, Loader2, AlertCircle } from "lucide-react";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
export const Route = createFileRoute("/p/$projectId/saved")({
component: SavedKeywordsPage,
});
function SavedKeywordsPage() {
const { projectId } = Route.useParams();
const queryClient = useQueryClient();
const [removeError, setRemoveError] = useState<string | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
const { data: savedKeywordsData, isLoading } = useQuery({
queryKey: ["savedKeywords", projectId],
queryFn: () => getSavedKeywords({ data: { projectId } }),
});
const savedKeywords = savedKeywordsData?.rows ?? [];
const removeMutation = useMutation({
mutationFn: (savedKeywordId: string) =>
removeSavedKeyword({ data: { savedKeywordId } }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
});
toast.success("Keyword removed");
},
onError: (error) => {
setRemoveError(getStandardErrorMessage(error, "Remove failed."));
},
});
const handleRemoveKeyword = (savedKeywordId: string) => {
setRemoveError(null);
setRemovingId(savedKeywordId);
removeMutation.mutate(savedKeywordId, {
onSettled: () => {
setRemovingId((current) =>
current === savedKeywordId ? null : current,
);
},
});
};
const exportCsv = () => {
if (savedKeywords.length === 0) {
toast.error("No keywords to export");
return;
}
const headers = [
"Keyword",
"Volume",
"CPC",
"Competition",
"Difficulty",
"Intent",
"Fetched At",
];
const csvRows = savedKeywords.map((kw) =>
[
csvEscape(kw.keyword),
kw.searchVolume ?? "",
kw.cpc?.toFixed(2) ?? "",
kw.competition?.toFixed(2) ?? "",
kw.keywordDifficulty ?? "",
kw.intent ?? "",
kw.fetchedAt ?? "",
].join(","),
);
const csv = [headers.join(","), ...csvRows].join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "saved-keywords.csv";
link.click();
URL.revokeObjectURL(url);
};
return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Saved Keywords</h1>
<p className="text-sm text-base-content/70">
Keywords you&apos;ve saved from keyword research.
</p>
</div>
{savedKeywords.length > 0 && (
<button className="btn btn-sm" onClick={exportCsv}>
<Download className="size-4" /> Export CSV
</button>
)}
</div>
{/* Keyword list */}
{isLoading ? (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3" aria-busy>
<div className="skeleton h-4 w-48" />
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="grid grid-cols-8 gap-3 items-center"
>
<div className="skeleton h-4 col-span-2" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
</div>
))}
</div>
</div>
) : savedKeywords.length === 0 ? (
<div className="card bg-base-100 border border-base-300">
<div className="card-body text-center py-12 text-base-content/50">
<Search className="size-8 mx-auto mb-2 opacity-40" />
<p>
No saved keywords yet. Use the Keyword Research page to find and
save keywords.
</p>
</div>
</div>
) : (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
{removeError ? (
<div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error flex items-start gap-2">
<AlertCircle className="size-4 shrink-0 mt-0.5" />
<span>{removeError}</span>
</div>
) : null}
<p className="text-sm text-base-content/70">
{savedKeywords.length} saved keyword
{savedKeywords.length !== 1 ? "s" : ""}
</p>
<div className="overflow-x-auto">
<table className="table table-zebra table-sm">
<thead>
<tr>
<th>Keyword</th>
<th>Volume</th>
<th>CPC</th>
<th>Competition</th>
<th>Difficulty</th>
<th>Intent</th>
<th>Last Fetched</th>
<th></th>
</tr>
</thead>
<tbody>
{savedKeywords.map((kw) => (
<tr key={kw.id}>
<td className="font-medium">{kw.keyword}</td>
<td>{formatNumber(kw.searchVolume)}</td>
<td>
{kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`}
</td>
<td>
{kw.competition == null
? "-"
: kw.competition.toFixed(2)}
</td>
<td>
<DifficultyBadge value={kw.keywordDifficulty} />
</td>
<td>
<span className="badge badge-sm badge-ghost">
{kw.intent ?? "?"}
</span>
</td>
<td className="text-xs text-base-content/50">
{kw.fetchedAt
? new Date(kw.fetchedAt).toLocaleDateString()
: "-"}
</td>
<td>
<button
className="btn btn-ghost btn-xs text-error"
onClick={() => handleRemoveKeyword(kw.id)}
disabled={removingId === kw.id}
title="Remove"
>
{removingId === kw.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</div>
);
}
function DifficultyBadge({ value }: { value: number | null }) {
if (value == null)
return <span className="badge badge-ghost badge-sm">-</span>;
if (value < 30)
return <span className="badge badge-success badge-sm">{value}</span>;
if (value <= 60)
return <span className="badge badge-warning badge-sm">{value}</span>;
return <span className="badge badge-error badge-sm">{value}</span>;
}
function formatNumber(value: number | null | undefined) {
if (value == null) return "-";
return new Intl.NumberFormat().format(value);
}
function csvEscape(value: string | number | null | undefined): string {
if (value == null) return "";
const text = String(value).replace(/"/g, '""');
return `"${text}"`;
}

8
src/server.ts Normal file
View File

@ -0,0 +1,8 @@
import handler from "@tanstack/react-start/server-entry";
// Export Workflow classes as named exports
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
export default {
fetch: handler.fetch,
};

View File

@ -0,0 +1,243 @@
/**
* robots.txt and sitemap.xml discovery for the site audit crawler.
*/
import robotsParser from "robots-parser";
import { XMLParser } from "fast-xml-parser";
import { isSameOrigin, normalizeUrl } from "./url-utils";
const SITEMAP_FETCH_TIMEOUT_MS = 15_000;
const MAX_SITEMAP_DEPTH = 3;
const MAX_SITEMAP_DOCS = 300;
const SITEMAP_CONCURRENCY = 5;
const SITEMAP_RETRIES = 1;
const xmlParser = new XMLParser({
ignoreAttributes: false,
isArray: (name) => name === "sitemap" || name === "url",
});
export interface RobotsResult {
isAllowed: (url: string) => boolean;
sitemapUrls: string[];
}
/**
* Fetch and parse robots.txt for a given origin.
* Returns a helper to check if URLs are allowed + discovered sitemap URLs.
*/
export async function fetchRobotsTxt(origin: string): Promise<RobotsResult> {
const robotsUrl = `${origin}/robots.txt`;
try {
const response = await fetch(robotsUrl, {
headers: { "User-Agent": "SuperSEO-Audit/1.0" },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
// No robots.txt = everything allowed
return {
isAllowed: () => true,
sitemapUrls: [],
};
}
const text = await response.text();
const robots = robotsParser(robotsUrl, text);
return {
isAllowed: (url: string) => robots.isAllowed(url) ?? true,
sitemapUrls: robots.getSitemaps(),
};
} catch (error) {
console.warn("Failed to fetch robots.txt:", error);
return {
isAllowed: () => true,
sitemapUrls: [],
};
}
}
/**
* Fetch and parse a sitemap (supports sitemap index recursion).
* Returns a flat list of page URLs found.
*/
function isProbablySitemapXml(
contentType: string | null,
body: string,
): boolean {
if (contentType?.toLowerCase().includes("xml")) {
return true;
}
const trimmed = body.trimStart().toLowerCase();
return (
trimmed.startsWith("<?xml") ||
trimmed.startsWith("<urlset") ||
trimmed.startsWith("<sitemapindex")
);
}
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 }>);
return entries
.map((entry) => entry.loc)
.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";
}
async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{
nestedSitemaps: string[];
pageUrls: string[];
timedOut: boolean;
}> {
const normalizedSitemapUrl = normalizeUrl(sitemapUrl);
if (!normalizedSitemapUrl) {
return { nestedSitemaps: [], pageUrls: [], timedOut: false };
}
let lastError: unknown = null;
for (let attempt = 0; attempt <= SITEMAP_RETRIES; attempt++) {
try {
const response = await fetch(normalizedSitemapUrl, {
headers: { "User-Agent": "SuperSEO-Audit/1.0" },
signal: AbortSignal.timeout(SITEMAP_FETCH_TIMEOUT_MS),
});
const finalUrl = normalizeUrl(response.url, normalizedSitemapUrl);
if (!finalUrl || !isSameOrigin(finalUrl, normalizedSitemapUrl)) {
return { nestedSitemaps: [], pageUrls: [], timedOut: false };
}
if (!response.ok) {
return { nestedSitemaps: [], pageUrls: [], timedOut: false };
}
const body = await response.text();
if (!isProbablySitemapXml(response.headers.get("content-type"), body)) {
return { nestedSitemaps: [], pageUrls: [], timedOut: false };
}
const parsed = xmlParser.parse(body);
const nestedSitemaps = getSitemapLocations(parsed.sitemapindex?.sitemap)
.map((loc) => normalizeUrl(loc, finalUrl))
.filter((loc): loc is string => loc !== null);
const pageUrls = getSitemapLocations(parsed.urlset?.url)
.map((loc) => normalizeUrl(loc, finalUrl))
.filter((loc): loc is string => loc !== null);
return { nestedSitemaps, pageUrls, timedOut: false };
} catch (error) {
lastError = error;
if (!isTimeoutError(error) || attempt === SITEMAP_RETRIES) {
break;
}
}
}
return {
nestedSitemaps: [],
pageUrls: [],
timedOut: isTimeoutError(lastError),
};
}
/**
* Discover all page URLs from robots.txt + sitemaps for an origin.
* Also tries the default /sitemap.xml if not listed in robots.txt.
*/
export async function discoverUrls(
origin: string,
maxPages = 50,
): Promise<{ urls: string[]; robots: RobotsResult; sitemapUrls: Set<string> }> {
const robots = await fetchRobotsTxt(origin);
// Collect sitemap URLs: from robots.txt + default location
const sitemapSources = new Set(robots.sitemapUrls);
sitemapSources.add(`${origin}/sitemap.xml`);
const maxDiscoveredUrls = Math.min(Math.max(maxPages * 20, 500), 50_000);
const allUrls = new Set<string>();
const queue: Array<{ url: string; depth: number }> = Array.from(
sitemapSources,
)
.map((url) => normalizeUrl(url, origin))
.filter((url): url is string => url !== null)
.filter((url) => isSameOrigin(url, origin))
.map((url) => ({ url, depth: MAX_SITEMAP_DEPTH }));
const seenSitemapDocs = new Set<string>();
let fetchedDocs = 0;
let failedDocs = 0;
let timedOutDocs = 0;
while (queue.length > 0 && allUrls.size < maxDiscoveredUrls) {
if (fetchedDocs >= MAX_SITEMAP_DOCS) {
break;
}
const batch = queue.splice(0, SITEMAP_CONCURRENCY);
await Promise.all(
batch.map(async ({ url, depth }) => {
const normalizedUrl = normalizeUrl(url);
if (
!normalizedUrl ||
!isSameOrigin(normalizedUrl, origin) ||
depth <= 0 ||
seenSitemapDocs.has(normalizedUrl)
) {
return;
}
seenSitemapDocs.add(normalizedUrl);
fetchedDocs += 1;
const result = await fetchSitemapDocumentWithRetry(normalizedUrl);
if (
result.pageUrls.length === 0 &&
result.nestedSitemaps.length === 0
) {
failedDocs += 1;
if (result.timedOut) {
timedOutDocs += 1;
}
return;
}
for (const pageUrl of result.pageUrls) {
if (!isSameOrigin(pageUrl, origin)) continue;
if (allUrls.size >= maxDiscoveredUrls) break;
allUrls.add(pageUrl);
}
if (depth <= 1) return;
for (const nestedUrl of result.nestedSitemaps) {
if (!isSameOrigin(nestedUrl, origin)) continue;
if (!seenSitemapDocs.has(nestedUrl)) {
queue.push({ url: nestedUrl, depth: depth - 1 });
}
}
}),
);
}
if (failedDocs > 0) {
console.warn(
`Sitemap discovery completed with partial failures for ${origin}: fetched=${fetchedDocs}, failed=${failedDocs}, timedOut=${timedOutDocs}, discoveredUrls=${allUrls.size}`,
);
}
return {
urls: Array.from(allUrls),
robots,
sitemapUrls: allUrls,
};
}

View File

@ -0,0 +1,131 @@
/**
* HTML page analyzer using cheerio.
*
* Extracts SEO-relevant data from a page's HTML:
* title, meta description, headings, images, links, canonical, OG tags,
* structured data, robots meta, word count, hreflang.
*/
import * as cheerio from "cheerio";
import { normalizeUrl, isSameOrigin } from "./url-utils";
import type { PageAnalysis } from "./types";
/**
* Analyze an HTML string and extract all SEO-relevant data.
*/
export function analyzeHtml(
html: string,
pageUrl: string,
statusCode: number,
responseTimeMs: number,
redirectUrl: string | null = null,
): PageAnalysis {
const $ = cheerio.load(html);
// --- Title ---
const title = $("title").first().text().trim();
// --- Meta description ---
const metaDescription =
$('meta[name="description"]').first().attr("content")?.trim() ?? "";
// --- Canonical ---
const canonical = $('link[rel="canonical"]').first().attr("href") ?? null;
// --- Robots meta ---
const robotsMeta = $('meta[name="robots"]').first().attr("content") ?? null;
// --- Open Graph ---
const ogTitle =
$('meta[property="og:title"]').first().attr("content") ?? null;
const ogDescription =
$('meta[property="og:description"]').first().attr("content") ?? null;
const ogImage =
$('meta[property="og:image"]').first().attr("content") ?? null;
// --- Headings ---
const h1s: string[] = [];
$("h1").each((_, el) => {
h1s.push($(el).text().trim());
});
const headingOrder: number[] = [];
$("h1, h2, h3, h4, h5, h6").each((_, el) => {
const tag = (el as unknown as { tagName?: string }).tagName?.toLowerCase();
if (tag) {
const level = parseInt(tag.charAt(1), 10);
if (!isNaN(level)) headingOrder.push(level);
}
});
// --- Word count (visible text in body) ---
// Remove script/style/noscript tags, then count words in remaining text
const bodyClone = $("body").clone();
bodyClone.find("script, style, noscript, svg").remove();
const bodyText = bodyClone.text().replace(/\s+/g, " ").trim();
const wordCount = bodyText ? bodyText.split(/\s+/).length : 0;
// --- Images ---
const images: Array<{ src: string | null; alt: string | null }> = [];
$("img").each((_, el) => {
images.push({
src: $(el).attr("src") ?? null,
alt: $(el).attr("alt") ?? null,
});
});
// --- Links ---
const internalLinks: string[] = [];
const externalLinks: string[] = [];
$("a[href]").each((_, el) => {
const href = $(el).attr("href");
if (!href) return;
// Skip javascript:, mailto:, tel:, #anchors
if (/^(javascript:|mailto:|tel:|#)/.test(href)) return;
const resolved = normalizeUrl(href, pageUrl);
if (!resolved) return;
if (isSameOrigin(resolved, pageUrl)) {
internalLinks.push(resolved);
} else {
externalLinks.push(resolved);
}
});
// --- Structured data (JSON-LD) ---
let hasStructuredData = false;
$('script[type="application/ld+json"]').each(() => {
hasStructuredData = true;
});
// --- Hreflang ---
const hreflangTags: string[] = [];
$('link[rel="alternate"][hreflang]').each((_, el) => {
const hreflang = $(el).attr("hreflang");
if (hreflang) hreflangTags.push(hreflang);
});
return {
url: pageUrl,
statusCode,
redirectUrl,
responseTimeMs,
title,
metaDescription,
canonical,
robotsMeta,
ogTitle,
ogDescription,
ogImage,
h1s,
headingOrder,
wordCount,
images,
internalLinks,
externalLinks,
hasStructuredData,
hreflangTags,
};
}

View File

@ -0,0 +1,81 @@
/**
* KV-based live crawl progress.
*
* During a crawl, each crawled URL is appended to a KV key so the UI can
* poll for a live feed of crawled pages (most recent first).
*
* The KV entry auto-expires after 30 minutes it's only needed while
* the audit is running. Once finalized, we explicitly delete it.
*/
import { env } from "cloudflare:workers";
const KV_PREFIX = "audit-progress:";
const TTL_SECONDS = 30 * 60; // 30 minutes
const MAX_ENTRIES = 300;
export interface CrawledUrlEntry {
url: string;
statusCode: number;
title: string;
/** Unix timestamp ms when this page was crawled */
crawledAt: number;
}
function key(auditId: string): string {
return `${KV_PREFIX}${auditId}`;
}
/**
* Append a crawled URL entry to the progress list.
* Newest entries are prepended so the array is sorted newest-first.
*/
async function pushCrawledUrl(
auditId: string,
entry: CrawledUrlEntry,
): Promise<void> {
await pushCrawledUrls(auditId, [entry]);
}
/**
* Append multiple crawled URL entries in one KV write.
* New entries are prepended and the list is capped.
*/
async function pushCrawledUrls(
auditId: string,
nextEntries: CrawledUrlEntry[],
): Promise<void> {
if (nextEntries.length === 0) return;
const k = key(auditId);
const existing = await env.KV.get(k, "text");
const entries: CrawledUrlEntry[] = existing ? JSON.parse(existing) : [];
const merged = [...nextEntries, ...entries].slice(0, MAX_ENTRIES);
await env.KV.put(k, JSON.stringify(merged), {
expirationTtl: TTL_SECONDS,
});
}
/**
* Read all crawled URL entries for a running audit.
* Returns newest-first.
*/
async function getCrawledUrls(auditId: string): Promise<CrawledUrlEntry[]> {
const data = await env.KV.get(key(auditId), "text");
if (!data) return [];
return JSON.parse(data);
}
/**
* Delete the progress key (called after audit completes).
*/
async function clear(auditId: string): Promise<void> {
await env.KV.delete(key(auditId));
}
export const AuditProgressKV = {
pushCrawledUrl,
pushCrawledUrls,
getCrawledUrls,
clear,
} as const;

176
src/server/lib/audit/psi.ts Normal file
View File

@ -0,0 +1,176 @@
/**
* Google PageSpeed Insights (PSI) API client and sampling logic.
*/
import { detectUrlTemplate } from "./url-utils";
import type { PsiResult, PsiStrategy } from "./types";
interface PsiSamplePage {
url: string;
statusCode: number;
}
const PSI_API_URL =
"https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
/**
* Fetch PageSpeed Insights results for a single URL.
* Retries up to 3 times with exponential backoff.
*/
export async function fetchPsiResult(
url: string,
pageId: string,
strategy: "mobile" | "desktop",
apiKey: string,
): Promise<PsiResult> {
// Build URL with multiple category params (PSI API allows repeated 'category')
const apiUrl = `${PSI_API_URL}?url=${encodeURIComponent(url)}&strategy=${strategy}&key=${encodeURIComponent(apiKey)}&category=performance&category=accessibility&category=best-practices&category=seo`;
let lastError: Error | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
if (attempt > 0) {
// Exponential backoff: 2s, 4s
await new Promise((resolve) =>
setTimeout(resolve, 2000 * Math.pow(2, attempt - 1)),
);
}
const response = await fetch(apiUrl, {
signal: AbortSignal.timeout(60_000), // PSI can be slow
});
if (!response.ok) {
const text = await response.text();
throw new Error(`PSI API ${response.status}: ${text.slice(0, 200)}`);
}
const data = (await response.json()) as PsiApiResponse;
return parsePsiResponse(data, url, pageId, strategy);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.warn(
`PSI attempt ${attempt + 1} failed for ${url}:`,
lastError.message,
);
}
}
// All retries exhausted — return null scores
console.error(`PSI failed after 3 attempts for ${url}:`, lastError?.message);
return {
url,
pageId,
strategy,
performanceScore: null,
accessibilityScore: null,
bestPracticesScore: null,
seoScore: null,
lcpMs: null,
cls: null,
inpMs: null,
ttfbMs: null,
errorMessage: lastError?.message ?? "PSI request failed",
};
}
/**
* Select which pages to run PSI on, based on the chosen strategy.
*/
export function selectPsiSample(
pages: PsiSamplePage[],
startUrl: string,
strategy: PsiStrategy,
): string[] {
if (strategy === "none") return [];
// Only consider pages that loaded successfully
const validPages = pages.filter(
(p) => p.statusCode >= 200 && p.statusCode < 300,
);
if (strategy === "all") {
return validPages.map((p) => p.url);
}
if (strategy === "manual") {
// manual = user picks after crawl; for now return empty
return [];
}
// strategy === "auto": homepage + 1 per URL pattern, capped at 10
const selected = new Set<string>();
// Always include the start URL / homepage
const startPage = validPages.find((p) => p.url === startUrl);
if (startPage) selected.add(startPage.url);
// Group by URL template pattern
const templateGroups = new Map<string, PsiSamplePage>();
for (const page of validPages) {
if (selected.has(page.url)) continue;
const template = detectUrlTemplate(new URL(page.url).pathname);
if (!templateGroups.has(template)) {
templateGroups.set(template, page);
}
}
// Add one page per template group
for (const [, page] of templateGroups) {
if (selected.size >= 10) break;
selected.add(page.url);
}
return Array.from(selected);
}
// ─── PSI API Response Types ──────────────────────────────────────────────────
interface PsiApiResponse {
lighthouseResult?: {
categories?: {
performance?: { score?: number | null };
accessibility?: { score?: number | null };
"best-practices"?: { score?: number | null };
seo?: { score?: number | null };
};
audits?: {
"largest-contentful-paint"?: { numericValue?: number };
"cumulative-layout-shift"?: { numericValue?: number };
"interaction-to-next-paint"?: { numericValue?: number };
"server-response-time"?: { numericValue?: number };
};
};
}
function parsePsiResponse(
data: PsiApiResponse,
url: string,
pageId: string,
strategy: "mobile" | "desktop",
): PsiResult {
const categories = data.lighthouseResult?.categories;
const audits = data.lighthouseResult?.audits;
return {
url,
pageId,
strategy,
performanceScore: scoreToPercent(categories?.performance?.score),
accessibilityScore: scoreToPercent(categories?.accessibility?.score),
bestPracticesScore: scoreToPercent(categories?.["best-practices"]?.score),
seoScore: scoreToPercent(categories?.seo?.score),
lcpMs: audits?.["largest-contentful-paint"]?.numericValue ?? null,
cls: audits?.["cumulative-layout-shift"]?.numericValue ?? null,
inpMs: audits?.["interaction-to-next-paint"]?.numericValue ?? null,
ttfbMs: audits?.["server-response-time"]?.numericValue ?? null,
rawPayloadJson: JSON.stringify(data),
};
}
/** PSI scores come as 0-1 floats; convert to 0-100 integers. */
function scoreToPercent(score: number | null | undefined): number | null {
if (score == null) return null;
return Math.round(score * 100);
}

View File

@ -0,0 +1,69 @@
/**
* Shared types for the site audit system.
*/
export type PsiStrategy = "auto" | "all" | "manual" | "none";
export type AuditStatus = "running" | "completed" | "failed";
export interface AuditConfig {
maxPages: number;
psiStrategy: PsiStrategy;
psiApiKey?: string;
}
/** Data extracted from a single page via cheerio. */
export interface PageAnalysis {
url: string;
statusCode: number;
redirectUrl: string | null;
responseTimeMs: number;
// Head metadata
title: string;
metaDescription: string;
canonical: string | null;
robotsMeta: string | null;
ogTitle: string | null;
ogDescription: string | null;
ogImage: string | null;
// Headings
h1s: string[];
headingOrder: number[];
// Content
wordCount: number;
// Images
images: Array<{ src: string | null; alt: string | null }>;
// Links (raw href values from the HTML)
internalLinks: string[];
externalLinks: string[];
// Structured data
hasStructuredData: boolean;
// Hreflang
hreflangTags: string[];
}
/** PSI result for a single URL+strategy. */
export interface PsiResult {
url: string;
pageId: string;
strategy: "mobile" | "desktop";
performanceScore: number | null;
accessibilityScore: number | null;
bestPracticesScore: number | null;
seoScore: number | null;
lcpMs: number | null;
cls: number | null;
inpMs: number | null;
ttfbMs: number | null;
errorMessage?: string | null;
r2Key?: string | null;
payloadSizeBytes?: number | null;
rawPayloadJson?: string | null;
}

View File

@ -0,0 +1,220 @@
import { AppError } from "@/server/lib/errors";
const BLOCKED_HOSTS = new Set([
"localhost",
"metadata.google.internal",
"metadata",
"169.254.169.254",
"100.100.100.200",
]);
const BLOCKED_HOST_SUFFIXES = [
".localhost",
".local",
".localdomain",
".internal",
".home.arpa",
];
const DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query";
function normalizeHost(hostname: string): string {
let host = hostname.toLowerCase().trim();
if (host.startsWith("[") && host.endsWith("]")) {
host = host.slice(1, -1);
}
if (host.includes("%")) {
host = host.split("%", 1)[0];
}
if (host.endsWith(".")) {
host = host.slice(0, -1);
}
return host;
}
function isPrivateIpv4(host: string): boolean {
const parts = normalizeHost(host)
.split(".")
.map((x) => Number(x));
if (
parts.length !== 4 ||
parts.some((x) => !Number.isInteger(x) || x < 0 || x > 255)
) {
return false;
}
const [a, b] = parts;
if (a === 10) return true;
if (a === 127) return true;
if (a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
if (a === 198 && (b === 18 || b === 19)) return true;
if (a >= 224) return true;
return false;
}
function parseMappedIpv4FromIpv6(host: string): string | null {
const normalized = normalizeHost(host);
if (!normalized.startsWith("::ffff:")) return null;
const mapped = normalized.slice("::ffff:".length);
if (/^\d+\.\d+\.\d+\.\d+$/.test(mapped)) {
return mapped;
}
const segments = mapped.split(":").filter(Boolean);
if (segments.length !== 2) return null;
const high = Number.parseInt(segments[0], 16);
const low = Number.parseInt(segments[1], 16);
if (
!Number.isFinite(high) ||
!Number.isFinite(low) ||
high < 0 ||
high > 0xffff ||
low < 0 ||
low > 0xffff
) {
return null;
}
const a = (high >> 8) & 0xff;
const b = high & 0xff;
const c = (low >> 8) & 0xff;
const d = low & 0xff;
return `${a}.${b}.${c}.${d}`;
}
function isPrivateIpv6(host: string): boolean {
const value = normalizeHost(host);
if (value === "::1" || value === "::") return true;
if (value.startsWith("fc") || value.startsWith("fd")) return true;
if (
value.startsWith("fe8") ||
value.startsWith("fe9") ||
value.startsWith("fea") ||
value.startsWith("feb")
) {
return true;
}
const mappedIpv4 = parseMappedIpv4FromIpv6(value);
if (mappedIpv4 && isPrivateIpv4(mappedIpv4)) {
return true;
}
return false;
}
function isIpLiteral(host: string): boolean {
const normalized = normalizeHost(host);
return /^\d+\.\d+\.\d+\.\d+$/.test(normalized) || normalized.includes(":");
}
function isBlockedHost(hostname: string): boolean {
const host = normalizeHost(hostname);
if (!host) return true;
if (BLOCKED_HOSTS.has(host)) return true;
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
return true;
}
if (isIpLiteral(host)) {
return isPrivateIpv4(host) || isPrivateIpv6(host);
}
return false;
}
type DnsJsonAnswer = {
type?: number;
data?: string;
};
type DnsJsonResponse = {
Status?: number;
Answer?: DnsJsonAnswer[];
};
async function resolveAddressRecords(
hostname: string,
type: "A" | "AAAA",
): Promise<string[]> {
const response = await fetch(
`${DOH_ENDPOINT}?name=${encodeURIComponent(hostname)}&type=${type}`,
{
headers: { Accept: "application/dns-json" },
signal: AbortSignal.timeout(2_500),
},
);
if (!response.ok) return [];
const body = (await response.json()) as DnsJsonResponse;
if (body.Status !== 0 || !Array.isArray(body.Answer)) return [];
const expectedType = type === "A" ? 1 : 28;
return body.Answer.filter(
(answer): answer is Required<Pick<DnsJsonAnswer, "data" | "type">> =>
answer.type === expectedType && typeof answer.data === "string",
).map((answer) => normalizeHost(answer.data));
}
async function hostnameResolvesToBlockedAddress(
hostname: string,
): Promise<boolean> {
const host = normalizeHost(hostname);
if (!host || isIpLiteral(host)) return false;
try {
const [v4, v6] = await Promise.all([
resolveAddressRecords(host, "A"),
resolveAddressRecords(host, "AAAA"),
]);
const addresses = [...v4, ...v6];
if (addresses.length === 0) return false;
return addresses.some(
(address) => isPrivateIpv4(address) || isPrivateIpv6(address),
);
} catch {
return false;
}
}
export async function normalizeAndValidateStartUrl(
input: string,
): Promise<string> {
let raw = input.trim();
if (!raw) throw new AppError("VALIDATION_ERROR");
if (!raw.startsWith("http://") && !raw.startsWith("https://")) {
raw = `https://${raw}`;
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new AppError("VALIDATION_ERROR");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new AppError("VALIDATION_ERROR");
}
if (isBlockedHost(parsed.hostname)) {
throw new AppError("CRAWL_TARGET_BLOCKED");
}
if (await hostnameResolvesToBlockedAddress(parsed.hostname)) {
throw new AppError("CRAWL_TARGET_BLOCKED");
}
parsed.hash = "";
return parsed.toString();
}

View File

@ -0,0 +1,133 @@
/**
* URL normalization and utility functions for the site audit crawler.
*/
/**
* Normalize a URL for deduplication:
* - Resolve relative URLs against a base
* - Strip fragments (#...)
* - Sort query parameters
* - Lowercase the hostname
* - Remove trailing slash (except for root path "/")
*/
export function normalizeUrl(url: string, base?: string): string | null {
try {
const parsed = new URL(url, base);
// Only crawl HTTP(S)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return null;
}
// Strip fragment
parsed.hash = "";
// Sort query params for consistent dedup
parsed.searchParams.sort();
// Lowercase hostname
parsed.hostname = parsed.hostname.toLowerCase();
// Remove trailing slash (but keep "/" for root)
let normalized = parsed.toString();
if (normalized.endsWith("/") && parsed.pathname !== "/") {
normalized = normalized.slice(0, -1);
}
return normalized;
} catch {
return null;
}
}
function getEffectivePort(parsed: URL): string {
if (parsed.port) return parsed.port;
return parsed.protocol === "https:" ? "443" : "80";
}
function areEquivalentHostnames(a: string, b: string): boolean {
const hostA = a.toLowerCase();
const hostB = b.toLowerCase();
if (hostA === hostB) return true;
return hostA === `www.${hostB}` || hostB === `www.${hostA}`;
}
/**
* Check if a URL belongs to the same crawl boundary as the crawl target.
*
* Rules:
* - Hostname must match exactly.
* - Same protocol/port is always allowed.
* - http -> https upgrade on default ports is allowed.
*/
export function isSameOrigin(url: string, origin: string): boolean {
try {
const parsedUrl = new URL(url);
const parsedOrigin = new URL(origin);
if (!areEquivalentHostnames(parsedUrl.hostname, parsedOrigin.hostname)) {
return false;
}
const originProtocol = parsedOrigin.protocol.toLowerCase();
const urlProtocol = parsedUrl.protocol.toLowerCase();
const originPort = getEffectivePort(parsedOrigin);
const urlPort = getEffectivePort(parsedUrl);
if (originProtocol === urlProtocol) {
return originPort === urlPort;
}
const isHttpToHttpsUpgrade =
originProtocol === "http:" &&
urlProtocol === "https:" &&
originPort === "80" &&
urlPort === "443";
return isHttpToHttpsUpgrade;
} catch {
return false;
}
}
/**
* Detect a URL template pattern by replacing path segments that look like
* dynamic values (IDs, slugs, dates) with `:param`.
*
* Examples:
* /blog/my-great-post /blog/:slug
* /products/12345 /products/:id
* /users/john-doe/settings /users/:slug/settings
*/
export function detectUrlTemplate(pathname: string): string {
const segments = pathname.split("/").filter(Boolean);
const normalized = segments.map((segment) => {
// Pure numeric IDs
if (/^\d+$/.test(segment)) return ":id";
// UUIDs
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
segment,
)
)
return ":uuid";
// Date-like segments (2024-01-15)
if (/^\d{4}-\d{2}-\d{2}$/.test(segment)) return ":date";
// Slug-like: contains hyphens and is more than 2 segments (to avoid short
// path parts like "my-account" that are likely fixed routes)
if (segment.includes("-") && segment.split("-").length > 2) return ":slug";
return segment;
});
return "/" + normalized.join("/");
}
/**
* Extract the origin (protocol + hostname + port) from a URL string.
*/
export function getOrigin(url: string): string {
return new URL(url).origin;
}

View File

@ -0,0 +1,404 @@
import {
DataforseoLabsApi,
DataforseoLabsGoogleRelatedKeywordsLiveRequestInfo,
DataforseoLabsGoogleKeywordSuggestionsLiveRequestInfo,
DataforseoLabsGoogleKeywordIdeasLiveRequestInfo,
DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo,
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
DataforseoLabsGoogleHistoricalSerpsLiveRequestInfo,
} from "dataforseo-client";
import { env } from "cloudflare:workers";
import { AppError } from "@/server/lib/errors";
// ---------------------------------------------------------------------------
// SDK client factories (lazily created per-request using the env secret)
// ---------------------------------------------------------------------------
function createAuthenticatedFetch() {
return (url: RequestInfo, init?: RequestInit): Promise<Response> => {
const newInit: RequestInit = {
...init,
headers: {
...init?.headers,
Authorization: `Basic ${env.DATAFORSEO_API_KEY}`,
},
};
return fetch(url, newInit);
};
}
const API_BASE = "https://api.dataforseo.com";
function getLabsApi() {
return new DataforseoLabsApi(API_BASE, { fetch: createAuthenticatedFetch() });
}
// ---------------------------------------------------------------------------
// Response helpers
// ---------------------------------------------------------------------------
/**
* Validate that the top-level response and first task both succeeded.
* Throws a descriptive error on failure. Returns the first task.
*/
function assertOk<T extends { status_code?: number; status_message?: string }>(
response: {
status_code?: number;
status_message?: string;
tasks?: T[];
} | null,
): T {
if (!response) {
throw new AppError(
"INTERNAL_ERROR",
"DataForSEO returned an empty response",
);
}
if (response.status_code !== 20000) {
throw new AppError(
"INTERNAL_ERROR",
response.status_message || "DataForSEO request failed",
);
}
const task = response.tasks?.[0];
if (!task) {
throw new AppError("INTERNAL_ERROR", "DataForSEO response missing task");
}
if (task.status_code !== 20000) {
throw new AppError(
"INTERNAL_ERROR",
task.status_message || "DataForSEO task failed",
);
}
return task;
}
// ---------------------------------------------------------------------------
// 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,
languageCode: string,
limit: number,
depth: number = 3,
): Promise<RelatedKeywordItem[]> {
const api = getLabsApi();
const req = new DataforseoLabsGoogleRelatedKeywordsLiveRequestInfo({
keyword,
location_code: locationCode,
language_code: languageCode,
limit,
depth,
include_clickstream_data: true,
include_serp_info: false,
});
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[];
}
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,
languageCode: string,
limit: number,
): Promise<LabsKeywordDataItem[]> {
const api = getLabsApi();
const req = new DataforseoLabsGoogleKeywordSuggestionsLiveRequestInfo({
keyword,
location_code: locationCode,
language_code: languageCode,
limit,
include_clickstream_data: true,
include_serp_info: false,
include_seed_keyword: true,
ignore_synonyms: false,
exact_match: false,
});
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[];
}
export async function fetchKeywordIdeasRaw(
keyword: string,
locationCode: number,
languageCode: string,
limit: number,
): Promise<LabsKeywordDataItem[]> {
const api = getLabsApi();
const req = new DataforseoLabsGoogleKeywordIdeasLiveRequestInfo({
keywords: [keyword],
location_code: locationCode,
language_code: languageCode,
limit,
include_clickstream_data: true,
include_serp_info: false,
ignore_synonyms: false,
closely_variants: false,
});
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[];
}
// ---------------------------------------------------------------------------
// Domain API wrappers
// ---------------------------------------------------------------------------
type DomainMetricsItem = {
metrics?: Record<
string,
{ etv?: number | null; count?: number | null } | undefined
>;
};
export async function fetchDomainRankOverviewRaw(
target: string,
locationCode: number,
languageCode: string,
): Promise<DomainMetricsItem[]> {
const api = getLabsApi();
const req = new DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo({
target,
location_code: locationCode,
language_code: languageCode,
limit: 1,
});
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[];
}
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,
languageCode: string,
limit: number,
orderBy?: string[],
): Promise<DomainRankedKeywordItem[]> {
const api = getLabsApi();
const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({
target,
location_code: locationCode,
language_code: languageCode,
limit,
order_by: orderBy,
});
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[];
}
// ---------------------------------------------------------------------------
// 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,
languageCode: string,
): Promise<SerpSnapshot[]> {
const api = getLabsApi();
const req = new DataforseoLabsGoogleHistoricalSerpsLiveRequestInfo({
keyword,
location_code: locationCode,
language_code: languageCode,
});
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[];
}
// ---------------------------------------------------------------------------
// Domain utility functions (unchanged)
// ---------------------------------------------------------------------------
export function toRelativePath(url: string | null | undefined): string | null {
if (!url) return null;
try {
const parsed = new URL(url);
return `${parsed.pathname}${parsed.search}` || "/";
} catch {
return null;
}
}
export function normalizeDomainInput(
input: string,
includeSubdomains: boolean,
): string {
const trimmed = input.trim().toLowerCase();
if (!trimmed) {
throw new AppError("VALIDATION_ERROR", "Domain is required");
}
const withProtocol = /^https?:\/\//.test(trimmed)
? trimmed
: `https://${trimmed}`;
const host = new URL(withProtocol).hostname.replace(/^www\./, "");
if (includeSubdomains) {
return host;
}
return toRootDomain(host);
}
function toRootDomain(host: string): string {
const parts = host.split(".").filter(Boolean);
if (parts.length <= 2) return host;
const knownSecondLevel = new Set([
"co.uk",
"org.uk",
"ac.uk",
"com.au",
"co.jp",
]);
const lastTwo = `${parts[parts.length - 2]}.${parts[parts.length - 1]}`;
const lastThree = `${parts[parts.length - 3]}.${lastTwo}`;
if (knownSecondLevel.has(lastTwo) && parts.length >= 3) {
return lastThree;
}
return lastTwo;
}

27
src/server/lib/errors.ts Normal file
View File

@ -0,0 +1,27 @@
import { isErrorCode, type ErrorCode } from "@/shared/error-codes";
export class AppError extends Error {
constructor(
public readonly code: ErrorCode,
message?: string,
) {
super(message ?? code);
this.name = "AppError";
}
}
export function asAppError(error: unknown): AppError | null {
if (error instanceof AppError) return error;
if (error instanceof Error && isErrorCode(error.message)) {
return new AppError(error.message, error.message);
}
return null;
}
export function toErrorCode(error: unknown): ErrorCode {
return asAppError(error)?.code ?? "INTERNAL_ERROR";
}
export function toClientError(error: unknown): Error {
return new Error(toErrorCode(error));
}

View File

@ -0,0 +1,64 @@
import { env } from "cloudflare:workers";
import { sortBy } from "remeda";
/**
* Cache TTL constants in seconds.
*/
export const CACHE_TTL = {
/** Related keyword research results */
researchResult: 86400,
} as const;
/**
* Build a deterministic cache key from an endpoint slug and input params.
* Uses FNV-1a hash for compactness.
*/
export function buildCacheKey(
prefix: string,
params: Record<string, unknown>,
): string {
const raw = JSON.stringify(
params,
sortBy(Object.keys(params), (key) => key),
);
return `${prefix}:${fnv1a(raw)}`;
}
/**
* Get a cached JSON value from KV. Returns null on miss.
*/
export async function getCached<T>(key: string): Promise<T | null> {
const value = await env.KV.get(key, "text");
if (value === null) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
/**
* Store a JSON value in KV with a TTL in seconds.
*/
export async function setCached<T>(
key: string,
data: T,
ttlSeconds: number,
): Promise<void> {
await env.KV.put(key, JSON.stringify(data), {
expirationTtl: ttlSeconds,
});
}
/**
* FNV-1a hash fast, good distribution for cache keys.
*/
function fnv1a(input: string): string {
let hash = 2166136261;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}

68
src/server/lib/logger.ts Normal file
View File

@ -0,0 +1,68 @@
import { toErrorCode } from "@/server/lib/errors";
type LogContext = Record<string, unknown>;
const SENSITIVE_KEY_PATTERN = /token|secret|password|key|email|authorization/i;
function sanitizeValue(key: string, value: unknown): unknown {
if (SENSITIVE_KEY_PATTERN.test(key)) return "[REDACTED]";
if (typeof value === "string") {
return value.length > 300 ? `${value.slice(0, 300)}...` : value;
}
if (Array.isArray(value)) {
return value.slice(0, 10).map((item) => sanitizeValue(key, item));
}
if (value && typeof value === "object") {
const output: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
output[k] = sanitizeValue(k, v);
}
return output;
}
return value;
}
function sanitizeContext(context: LogContext): LogContext {
const safe: LogContext = {};
for (const [key, value] of Object.entries(context)) {
safe[key] = sanitizeValue(key, value);
}
return safe;
}
export function logServerError(
operation: string,
error: unknown,
context: LogContext = {},
): void {
const code = toErrorCode(error);
const safeErrorMessage =
error instanceof Error
? sanitizeValue("message", error.message)
: "unknown";
const safeStack =
error instanceof Error && typeof error.stack === "string"
? sanitizeValue("stack", error.stack)
: undefined;
const safeCause =
error instanceof Error && "cause" in error
? sanitizeValue("cause", (error as { cause?: unknown }).cause)
: undefined;
console.error(
JSON.stringify({
level: "error",
operation,
code,
errorName: error instanceof Error ? error.name : "UnknownError",
message: safeErrorMessage,
stack: safeStack,
cause: safeCause,
context: sanitizeContext(context),
}),
);
}

43
src/server/lib/r2.ts Normal file
View File

@ -0,0 +1,43 @@
import { env } from "cloudflare:workers";
export async function putJsonToR2(
key: string,
payload: Record<string, unknown>,
): Promise<{ key: string; sizeBytes: number }> {
const body = JSON.stringify(payload);
await env.R2.put(key, body, {
httpMetadata: {
contentType: "application/json",
},
});
return {
key,
sizeBytes: Buffer.byteLength(body),
};
}
export async function getJsonFromR2(key: string): Promise<string> {
const object = await env.R2.get(key);
if (!object) {
throw new Error("Audit payload not found");
}
return object.text();
}
export async function putTextToR2(
key: string,
body: string,
): Promise<{ key: string; sizeBytes: number }> {
await env.R2.put(key, body, {
httpMetadata: {
contentType: "application/json",
},
});
return {
key,
sizeBytes: Buffer.byteLength(body),
};
}

View File

@ -0,0 +1,51 @@
export class PublicServerError extends Error {
constructor(message: string) {
super(message);
this.name = "PublicServerError";
}
}
interface ErrorBoundaryOptions<TArgs> {
fallbackMessage?: string;
passThroughMessages?: string[];
getLogContext?: (args: TArgs) => Record<string, unknown>;
}
export function withServerFnErrorBoundary<TArgs, TResult>(
operation: string,
handler: (args: TArgs) => Promise<TResult>,
options: ErrorBoundaryOptions<TArgs> = {},
) {
const passThroughMessages = new Set(options.passThroughMessages ?? []);
return async (args: TArgs): Promise<TResult> => {
try {
return await handler(args);
} catch (error) {
if (error instanceof PublicServerError) {
throw error;
}
if (error instanceof Error && passThroughMessages.has(error.message)) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
const cause =
error instanceof Error && "cause" in error
? (error as { cause?: unknown }).cause
: undefined;
const logContext = options.getLogContext?.(args);
console.error(`${operation} failed`, {
message,
cause,
stack: error instanceof Error ? error.stack : undefined,
...logContext,
});
throw new PublicServerError(
options.fallbackMessage ?? "Something went wrong. Please try again.",
);
}
};
}

View File

@ -0,0 +1,308 @@
/**
* Data access layer for site audit tables.
* All D1 interactions for audits, audit_pages, and audit_psi_results.
*/
import { db } from "@/db";
import { audits, auditPages, auditPsiResults, projects } from "@/db/schema";
import { and, eq, desc } from "drizzle-orm";
import type { PsiResult, AuditConfig } from "@/server/lib/audit/types";
// ─── Create ──────────────────────────────────────────────────────────────────
async function createAudit(data: {
id: string;
projectId: string;
userId: string;
startUrl: string;
workflowInstanceId: string;
config: AuditConfig;
}) {
await db.insert(audits).values({
id: data.id,
projectId: data.projectId,
userId: data.userId,
startUrl: data.startUrl,
workflowInstanceId: data.workflowInstanceId,
config: JSON.stringify(data.config),
status: "running",
});
}
// ─── Update ──────────────────────────────────────────────────────────────────
async function updateAuditProgress(
auditId: string,
data: {
pagesCrawled?: number;
pagesTotal?: number;
psiTotal?: number;
psiCompleted?: number;
psiFailed?: number;
currentPhase?: string;
},
) {
await db.update(audits).set(data).where(eq(audits.id, auditId));
}
async function completeAudit(
auditId: string,
data: {
pagesCrawled: number;
pagesTotal: number;
},
) {
await db
.update(audits)
.set({
status: "completed",
completedAt: new Date().toISOString(),
currentPhase: "completed",
...data,
})
.where(eq(audits.id, auditId));
}
async function failAudit(auditId: string) {
await db
.update(audits)
.set({
status: "failed",
completedAt: new Date().toISOString(),
currentPhase: "failed",
})
.where(eq(audits.id, auditId));
}
// ─── Batch write results (finalize step) ─────────────────────────────────────
/**
* Use db.batch() to send individual INSERT statements in a single round-trip.
* D1's batch API supports up to 100 *statements* per call each statement
* has its own bind params, so there's no per-statement param limit issue.
*/
async function batchWriteResults(
auditId: string,
pages: Array<{
id: string;
url: string;
statusCode: number;
redirectUrl: string | null;
title: string;
metaDescription: string;
canonicalUrl: string | null;
robotsMeta: string | null;
ogTitle: string | null;
ogDescription: string | null;
ogImage: string | null;
h1Count: number;
h2Count: number;
h3Count: number;
h4Count: number;
h5Count: number;
h6Count: number;
headingOrder: number[];
wordCount: number;
imagesTotal: number;
imagesMissingAlt: number;
images: Array<{ src: string | null; alt: string | null }>;
internalLinks: string[];
externalLinks: string[];
hasStructuredData: boolean;
hreflangTags: string[];
isIndexable: boolean;
responseTimeMs: number;
}>,
psiResults: PsiResult[],
) {
const BATCH_SIZE = 100; // D1 max statements per batch() call
// ── Pages ──────────────────────────────────────────────────────────
const pageStatements = pages.map((p) =>
db.insert(auditPages).values({
id: p.id,
auditId,
url: p.url,
statusCode: p.statusCode,
redirectUrl: p.redirectUrl,
// Metadata
title: p.title,
metaDescription: p.metaDescription,
canonicalUrl: p.canonicalUrl,
robotsMeta: p.robotsMeta,
// Open Graph
ogTitle: p.ogTitle,
ogDescription: p.ogDescription,
ogImage: p.ogImage,
// Headings
h1Count: p.h1Count,
h2Count: p.h2Count,
h3Count: p.h3Count,
h4Count: p.h4Count,
h5Count: p.h5Count,
h6Count: p.h6Count,
headingOrderJson: JSON.stringify(p.headingOrder),
// Content
wordCount: p.wordCount,
// Images
imagesTotal: p.imagesTotal,
imagesMissingAlt: p.imagesMissingAlt,
imagesJson: JSON.stringify(p.images),
// Links
internalLinkCount: p.internalLinks.length,
externalLinkCount: p.externalLinks.length,
// Structured data
hasStructuredData: p.hasStructuredData,
// Hreflang
hreflangTagsJson: JSON.stringify(p.hreflangTags),
// Indexability
isIndexable: p.isIndexable,
// Performance
responseTimeMs: p.responseTimeMs,
}),
);
for (let i = 0; i < pageStatements.length; i += BATCH_SIZE) {
const chunk = pageStatements.slice(i, i + BATCH_SIZE);
const [first, ...rest] = chunk;
await db.batch([first, ...rest]);
}
// ── PSI results ────────────────────────────────────────────────────
if (psiResults.length > 0) {
const psiStatements = psiResults.map((r) =>
db.insert(auditPsiResults).values({
id: crypto.randomUUID(),
auditId,
pageId: r.pageId,
strategy: r.strategy,
performanceScore: r.performanceScore,
accessibilityScore: r.accessibilityScore,
bestPracticesScore: r.bestPracticesScore,
seoScore: r.seoScore,
lcpMs: r.lcpMs,
cls: r.cls,
inpMs: r.inpMs,
ttfbMs: r.ttfbMs,
errorMessage: r.errorMessage ?? null,
r2Key: r.r2Key ?? null,
payloadSizeBytes: r.payloadSizeBytes ?? null,
}),
);
for (let i = 0; i < psiStatements.length; i += BATCH_SIZE) {
const chunk = psiStatements.slice(i, i + BATCH_SIZE);
const [first, ...rest] = chunk;
await db.batch([first, ...rest]);
}
}
}
// ─── Read ────────────────────────────────────────────────────────────────────
async function isProjectOwnedByUser(projectId: string, userId: string) {
const project = await db.query.projects.findFirst({
where: and(eq(projects.id, projectId), eq(projects.userId, userId)),
});
return Boolean(project);
}
async function getAuditForUser(auditId: string, userId: string) {
return db.query.audits.findFirst({
where: and(eq(audits.id, auditId), eq(audits.userId, userId)),
});
}
async function getAuditsByProjectForUser(projectId: string, userId: string) {
return db.query.audits.findMany({
where: and(eq(audits.projectId, projectId), eq(audits.userId, userId)),
orderBy: [desc(audits.startedAt)],
});
}
async function getAuditResultsForUser(auditId: string, userId: string) {
const audit = await getAuditForUser(auditId, userId);
if (!audit) {
return { audit: null, pages: [], psi: [] };
}
const [pages, psi] = await Promise.all([
db.query.auditPages.findMany({
where: eq(auditPages.auditId, auditId),
}),
db.query.auditPsiResults.findMany({
where: eq(auditPsiResults.auditId, auditId),
}),
]);
return { audit, pages, psi };
}
async function getPsiResultById(input: {
psiResultId: string;
projectId: string;
userId: string;
}) {
const project = await db.query.projects.findFirst({
where: and(
eq(projects.id, input.projectId),
eq(projects.userId, input.userId),
),
});
if (!project) {
throw new Error("Project not found");
}
const psi = await db.query.auditPsiResults.findFirst({
where: eq(auditPsiResults.id, input.psiResultId),
});
if (!psi) return null;
const parentAudit = await db.query.audits.findFirst({
where: and(
eq(audits.id, psi.auditId),
eq(audits.projectId, input.projectId),
eq(audits.userId, input.userId),
),
});
if (!parentAudit) {
throw new Error("Audit not found");
}
const page = await db.query.auditPages.findFirst({
where: eq(auditPages.id, psi.pageId),
});
return {
psi,
page,
audit: parentAudit,
};
}
// ─── Delete ──────────────────────────────────────────────────────────────────
async function deleteAuditForUser(auditId: string, userId: string) {
// Cascading deletes handle child tables
await db
.delete(audits)
.where(and(eq(audits.id, auditId), eq(audits.userId, userId)));
}
// ─── Export ──────────────────────────────────────────────────────────────────
export const AuditRepository = {
createAudit,
updateAuditProgress,
completeAudit,
failAudit,
batchWriteResults,
isProjectOwnedByUser,
getAuditForUser,
getAuditsByProjectForUser,
getAuditResultsForUser,
getPsiResultById,
deleteAuditForUser,
} as const;

View File

@ -0,0 +1,192 @@
import { and, count, desc, eq } from "drizzle-orm";
import { db } from "@/db";
import { keywordMetrics, projects, savedKeywords } from "@/db/schema";
import { AppError } from "@/server/lib/errors";
async function upsertKeywordMetric(params: {
keyword: string;
locationCode: number;
languageCode: string;
searchVolume: number | null;
cpc: number | null;
competition: number | null;
keywordDifficulty: number | null;
intent: string | null;
monthlySearchesJson: string;
}) {
const fetchedAt = new Date().toISOString();
await db
.insert(keywordMetrics)
.values({
keyword: params.keyword,
locationCode: params.locationCode,
languageCode: params.languageCode,
searchVolume: params.searchVolume,
cpc: params.cpc,
competition: params.competition,
keywordDifficulty: params.keywordDifficulty,
intent: params.intent,
monthlySearches: params.monthlySearchesJson,
fetchedAt,
})
.onConflictDoUpdate({
target: [
keywordMetrics.keyword,
keywordMetrics.locationCode,
keywordMetrics.languageCode,
],
set: {
searchVolume: params.searchVolume,
cpc: params.cpc,
competition: params.competition,
keywordDifficulty: params.keywordDifficulty,
intent: params.intent,
monthlySearches: params.monthlySearchesJson,
fetchedAt,
},
});
}
async function listProjects(userId: string) {
return db.query.projects.findMany({
where: eq(projects.userId, userId),
orderBy: desc(projects.createdAt),
});
}
async function getProject(projectId: string, userId: string) {
return db.query.projects.findFirst({
where: and(eq(projects.id, projectId), eq(projects.userId, userId)),
});
}
async function getProjectPsiApiKey(projectId: string, userId: string) {
const project = await getProject(projectId, userId);
if (!project) {
throw new AppError("NOT_FOUND");
}
return project.pagespeedApiKey ?? null;
}
async function setProjectPsiApiKey(
projectId: string,
userId: string,
apiKey: string,
) {
const project = await getProject(projectId, userId);
if (!project) {
throw new AppError("NOT_FOUND");
}
await db
.update(projects)
.set({ pagespeedApiKey: apiKey })
.where(and(eq(projects.id, projectId), eq(projects.userId, userId)));
}
async function clearProjectPsiApiKey(projectId: string, userId: string) {
const project = await getProject(projectId, userId);
if (!project) {
throw new AppError("NOT_FOUND");
}
await db
.update(projects)
.set({ pagespeedApiKey: null })
.where(and(eq(projects.id, projectId), eq(projects.userId, userId)));
}
async function createProject(userId: string, name: string, domain?: string) {
const id = crypto.randomUUID();
await db.insert(projects).values({
id,
userId,
name,
domain,
});
return id;
}
async function deleteProject(projectId: string, userId: string) {
const project = await getProject(projectId, userId);
if (!project) {
throw new AppError("NOT_FOUND");
}
// savedKeywords cascade-delete via FK
await db
.delete(projects)
.where(and(eq(projects.id, projectId), eq(projects.userId, userId)));
}
async function countSavedKeywords(projectId: string) {
const [result] = await db
.select({ value: count() })
.from(savedKeywords)
.where(eq(savedKeywords.projectId, projectId));
return result?.value ?? 0;
}
async function saveKeywordsToProject(params: {
projectId: string;
keywords: string[];
locationCode: number;
languageCode: string;
}) {
if (params.keywords.length === 0) return;
await db
.insert(savedKeywords)
.values(
params.keywords.map((keyword) => ({
id: crypto.randomUUID(),
projectId: params.projectId,
keyword,
locationCode: params.locationCode,
languageCode: params.languageCode,
})),
)
.onConflictDoNothing();
}
async function listSavedKeywordsByProject(projectId: string) {
return db
.select({ row: savedKeywords, metric: keywordMetrics })
.from(savedKeywords)
.leftJoin(
keywordMetrics,
and(
eq(keywordMetrics.keyword, savedKeywords.keyword),
eq(keywordMetrics.locationCode, savedKeywords.locationCode),
eq(keywordMetrics.languageCode, savedKeywords.languageCode),
),
)
.where(eq(savedKeywords.projectId, projectId))
.orderBy(desc(savedKeywords.createdAt));
}
async function removeSavedKeyword(savedKeywordId: string) {
await db.delete(savedKeywords).where(eq(savedKeywords.id, savedKeywordId));
}
async function getSavedKeywordById(savedKeywordId: string) {
return db.query.savedKeywords.findFirst({
where: eq(savedKeywords.id, savedKeywordId),
});
}
export const KeywordResearchRepository = {
upsertKeywordMetric,
listProjects,
getProject,
getProjectPsiApiKey,
setProjectPsiApiKey,
clearProjectPsiApiKey,
createProject,
deleteProject,
countSavedKeywords,
saveKeywordsToProject,
listSavedKeywordsByProject,
removeSavedKeyword,
getSavedKeywordById,
} as const;

View File

@ -0,0 +1,102 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "@/db";
import { projects, psiAuditResults } from "@/db/schema";
async function ensureProjectAccess(projectId: string, userId: string) {
const project = await db.query.projects.findFirst({
where: and(eq(projects.id, projectId), eq(projects.userId, userId)),
});
if (!project) {
throw new Error("Project not found");
}
}
async function createAuditResult(data: {
id: string;
projectId: string;
requestedUrl: string;
finalUrl: string;
strategy: "mobile" | "desktop";
status: "completed" | "failed";
performanceScore?: number | null;
accessibilityScore?: number | null;
bestPracticesScore?: number | null;
seoScore?: number | null;
firstContentfulPaint?: string | null;
largestContentfulPaint?: string | null;
totalBlockingTime?: string | null;
cumulativeLayoutShift?: string | null;
speedIndex?: string | null;
timeToInteractive?: string | null;
lighthouseVersion?: string | null;
errorMessage?: string | null;
r2Key?: string | null;
payloadSizeBytes?: number | null;
}) {
await db.insert(psiAuditResults).values({
id: data.id,
projectId: data.projectId,
requestedUrl: data.requestedUrl,
finalUrl: data.finalUrl,
strategy: data.strategy,
status: data.status,
performanceScore: data.performanceScore ?? null,
accessibilityScore: data.accessibilityScore ?? null,
bestPracticesScore: data.bestPracticesScore ?? null,
seoScore: data.seoScore ?? null,
firstContentfulPaint: data.firstContentfulPaint ?? null,
largestContentfulPaint: data.largestContentfulPaint ?? null,
totalBlockingTime: data.totalBlockingTime ?? null,
cumulativeLayoutShift: data.cumulativeLayoutShift ?? null,
speedIndex: data.speedIndex ?? null,
timeToInteractive: data.timeToInteractive ?? null,
lighthouseVersion: data.lighthouseVersion ?? null,
errorMessage: data.errorMessage ?? null,
r2Key: data.r2Key ?? null,
payloadSizeBytes: data.payloadSizeBytes ?? null,
createdAt: new Date().toISOString(),
});
}
async function listAuditResults(input: {
projectId: string;
userId: string;
strategy?: "mobile" | "desktop";
limit: number;
}) {
await ensureProjectAccess(input.projectId, input.userId);
return db.query.psiAuditResults.findMany({
where:
input.strategy != null
? and(
eq(psiAuditResults.projectId, input.projectId),
eq(psiAuditResults.strategy, input.strategy),
)
: eq(psiAuditResults.projectId, input.projectId),
orderBy: desc(psiAuditResults.createdAt),
limit: input.limit,
});
}
async function getAuditResult(input: {
projectId: string;
userId: string;
auditId: string;
}) {
await ensureProjectAccess(input.projectId, input.userId);
return db.query.psiAuditResults.findFirst({
where: and(
eq(psiAuditResults.id, input.auditId),
eq(psiAuditResults.projectId, input.projectId),
),
});
}
export const PsiAuditRepository = {
createAuditResult,
listAuditResults,
getAuditResult,
} as const;

View File

@ -0,0 +1,184 @@
/**
* Business logic layer for site audits.
* Orchestrates between the workflow trigger, repository, and data formatting.
*/
import { env } from "cloudflare:workers";
import { AuditRepository } from "@/server/repositories/AuditRepository";
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
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";
async function startAudit(input: {
userId: string;
projectId: string;
startUrl: string;
maxPages?: number;
psiStrategy?: PsiStrategy;
psiApiKey?: string;
}) {
const hasProjectAccess = await AuditRepository.isProjectOwnedByUser(
input.projectId,
input.userId,
);
if (!hasProjectAccess) {
throw new AppError("FORBIDDEN");
}
const auditId = crypto.randomUUID();
const shouldRunPsi = (input.psiStrategy ?? "auto") !== "none";
let resolvedPsiApiKey = input.psiApiKey?.trim();
if (shouldRunPsi && !resolvedPsiApiKey) {
resolvedPsiApiKey =
(await KeywordResearchRepository.getProjectPsiApiKey(
input.projectId,
input.userId,
)) ?? undefined;
}
if (shouldRunPsi && !resolvedPsiApiKey) {
throw new Error("PSI API key is not set for this project.");
}
const config: AuditConfig = {
maxPages: Math.min(Math.max(input.maxPages ?? 50, 10), 10_000),
psiStrategy: input.psiStrategy ?? "auto",
// PSI key is used for Google quota/abuse control (non-billing).
psiApiKey: resolvedPsiApiKey,
};
const startUrl = await normalizeAndValidateStartUrl(input.startUrl);
// Trigger the Cloudflare Workflow
const instance = await env.SITE_AUDIT_WORKFLOW.create({
id: auditId,
params: {
auditId,
projectId: input.projectId,
startUrl,
config,
},
});
// Create the audit row in D1
await AuditRepository.createAudit({
id: auditId,
projectId: input.projectId,
userId: input.userId,
startUrl,
workflowInstanceId: instance.id,
config,
});
return { auditId };
}
async function getStatus(auditId: string, userId: string) {
const audit = await AuditRepository.getAuditForUser(auditId, userId);
if (!audit) throw new AppError("NOT_FOUND");
return {
id: audit.id,
startUrl: audit.startUrl,
status: audit.status,
pagesCrawled: audit.pagesCrawled,
pagesTotal: audit.pagesTotal,
psiTotal: audit.psiTotal,
psiCompleted: audit.psiCompleted,
psiFailed: audit.psiFailed,
currentPhase: audit.currentPhase,
startedAt: audit.startedAt,
completedAt: audit.completedAt,
};
}
async function getResults(auditId: string, userId: string) {
const { audit, pages, psi } = await AuditRepository.getAuditResultsForUser(
auditId,
userId,
);
if (!audit) throw new AppError("NOT_FOUND");
const parsedConfig = JSON.parse(audit.config) as AuditConfig;
const { psiApiKey: _psiApiKey, ...safeConfig } = parsedConfig;
return {
audit: {
id: audit.id,
startUrl: audit.startUrl,
status: audit.status,
pagesCrawled: audit.pagesCrawled,
pagesTotal: audit.pagesTotal,
startedAt: audit.startedAt,
completedAt: audit.completedAt,
config: safeConfig,
},
pages,
psi,
};
}
async function getHistory(projectId: string, userId: string) {
const hasProjectAccess = await AuditRepository.isProjectOwnedByUser(
projectId,
userId,
);
if (!hasProjectAccess) {
throw new AppError("FORBIDDEN");
}
const auditList = await AuditRepository.getAuditsByProjectForUser(
projectId,
userId,
);
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;
}
};
return auditList.map((a) => ({
id: a.id,
startUrl: a.startUrl,
status: a.status,
pagesCrawled: a.pagesCrawled,
pagesTotal: a.pagesTotal,
ranPsi: didRunPsi(a.config),
startedAt: a.startedAt,
completedAt: a.completedAt,
}));
}
async function getCrawlProgress(auditId: string, userId: string) {
const audit = await AuditRepository.getAuditForUser(auditId, userId);
if (!audit) {
throw new AppError("NOT_FOUND");
}
return AuditProgressKV.getCrawledUrls(auditId);
}
async function remove(auditId: string, userId: string) {
const audit = await AuditRepository.getAuditForUser(auditId, userId);
if (!audit) {
throw new AppError("NOT_FOUND");
}
await AuditRepository.deleteAuditForUser(auditId, userId);
}
export const AuditService = {
startAudit,
getStatus,
getCrawlProgress,
getResults,
getHistory,
remove,
} as const;

View File

@ -0,0 +1,219 @@
import {
normalizeDomainInput,
toRelativePath,
fetchDomainRankOverviewRaw,
fetchRankedKeywordsRaw,
type DomainRankedKeywordItem,
} from "@/server/lib/dataforseo";
import { sortBy } from "remeda";
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
import { logServerError } from "@/server/lib/logger";
/** Domain overview data is refreshed every 12 hours. */
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;
type DomainOverviewResult = {
domain: string;
organicTraffic: number | null;
organicKeywords: number | null;
backlinks: number | null;
referringDomains: number | null;
hasData: boolean;
keywords: Array<{
keyword: string;
position: number | null;
searchVolume: number | null;
traffic: number | null;
cpc: number | null;
url: string | null;
relativeUrl: string | null;
keywordDifficulty: number | null;
}>;
pages: Array<{
page: string;
relativePath: string | null;
organicTraffic: number | null;
keywords: number | null;
backlinks: number | null;
}>;
fetchedAt: string;
};
async function getOverview(input: {
domain: string;
includeSubdomains: boolean;
locationCode: number;
languageCode: string;
}): Promise<DomainOverviewResult> {
const domain = normalizeDomainInput(input.domain, input.includeSubdomains);
// --- KV cache check ---
const cacheKey = buildCacheKey("domain:overview", {
domain,
includeSubdomains: input.includeSubdomains,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
const cached = await getCached<DomainOverviewResult>(cacheKey);
if (cached && cached.hasData) {
return cached;
}
// --- Fetch fresh from DataForSEO ---
const nowIso = new Date().toISOString();
const [metricsResponse, rankedKeywordsResponse] = await Promise.all([
fetchDomainRankOverviewRaw(domain, input.locationCode, input.languageCode),
fetchRankedKeywordsRaw(
domain,
input.locationCode,
input.languageCode,
200,
["keyword_data.keyword_info.search_volume,desc"],
),
]);
const metrics = metricsResponse[0];
const rankedItems = rankedKeywordsResponse;
const keywords = rankedItems
.map((item) => mapKeywordItem(item))
.filter(
(item): item is NonNullable<ReturnType<typeof mapKeywordItem>> =>
item != null,
);
const pages = derivePages(keywords);
const organicTraffic =
metrics?.metrics?.organic?.etv != null
? Math.round(metrics.metrics.organic.etv)
: null;
const organicKeywords =
metrics?.metrics?.organic?.count != null
? Math.round(metrics.metrics.organic.count)
: null;
const result: DomainOverviewResult = {
domain,
organicTraffic,
organicKeywords,
backlinks: null,
referringDomains: null,
hasData: keywords.length > 0,
keywords,
pages,
fetchedAt: nowIso,
};
// Persist to KV (fire-and-forget; don't block response)
if (result.hasData) {
void setCached(cacheKey, result, DOMAIN_OVERVIEW_TTL_SECONDS).catch(
(error) => {
logServerError("domain.overview.cache-write", error, {
domain,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
},
);
}
return result;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mapKeywordItem(item: DomainRankedKeywordItem) {
const keywordData = item.keyword_data;
const keywordInfo = keywordData?.keyword_info;
const keywordProperties = keywordData?.keyword_properties;
const rankedSerpElement = item.ranked_serp_element;
const serpItem = rankedSerpElement?.serp_item;
const keyword = keywordData?.keyword ?? item.keyword;
if (!keyword) return null;
const url = serpItem?.url ?? rankedSerpElement?.url ?? null;
const relativeUrl =
serpItem?.relative_url ??
rankedSerpElement?.relative_url ??
(url ? toRelativePath(url) : null);
const position =
serpItem?.rank_absolute ?? rankedSerpElement?.rank_absolute ?? null;
const traffic = serpItem?.etv ?? rankedSerpElement?.etv ?? null;
const keywordDifficulty =
keywordProperties?.keyword_difficulty ??
keywordInfo?.keyword_difficulty ??
null;
return {
keyword,
position: position != null ? Math.round(position) : null,
searchVolume:
keywordInfo?.search_volume != null
? Math.round(keywordInfo.search_volume)
: null,
traffic: traffic ?? null,
cpc: keywordInfo?.cpc ?? null,
url: url ?? null,
relativeUrl,
keywordDifficulty:
keywordDifficulty != null ? Math.round(keywordDifficulty) : null,
};
}
function derivePages(
keywords: Array<{
url: string | null;
relativeUrl: string | null;
traffic: number | null;
}>,
) {
const grouped = new Map<
string,
{
page: string;
relativePath: string | null;
traffic: number;
keywords: number;
}
>();
for (const keyword of keywords) {
if (!keyword.url) continue;
const existing = grouped.get(keyword.url) ?? {
page: keyword.url,
relativePath: keyword.relativeUrl,
traffic: 0,
keywords: 0,
};
existing.traffic += keyword.traffic ?? 0;
existing.keywords += 1;
grouped.set(keyword.url, existing);
}
return sortBy(Array.from(grouped.values()), [(page) => page.traffic, "desc"])
.slice(0, 100)
.map((page) => ({
page: page.page,
relativePath: page.relativePath,
organicTraffic: page.traffic,
keywords: page.keywords,
backlinks: null as number | null,
}));
}
export const DomainService = {
getOverview,
} as const;

View File

@ -0,0 +1,577 @@
import type {
KeywordIntent,
KeywordResearchRow,
MonthlySearch,
SavedKeywordRow,
SerpResultItem,
} from "@/types/keywords";
import type {
CreateProjectInput,
DeleteProjectInput,
GetSavedKeywordsInput,
RemoveSavedKeywordInput,
ResearchKeywordsInput,
SaveKeywordsInput,
} from "@/types/schemas/keywords";
import {
fetchRelatedKeywordsRaw,
fetchKeywordSuggestionsRaw,
fetchKeywordIdeasRaw,
type LabsKeywordDataItem,
fetchHistoricalSerpsRaw,
} from "@/server/lib/dataforseo";
import {
buildCacheKey,
getCached,
setCached,
CACHE_TTL,
} from "@/server/lib/kv-cache";
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository";
import { AppError } from "@/server/lib/errors";
import { logServerError } from "@/server/lib/logger";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function normalizeKeyword(input: string): string {
return input.trim().toLowerCase();
}
function normalizeIntent(raw: unknown): KeywordIntent {
if (typeof raw !== "string") return "unknown";
const value = raw.toLowerCase();
if (value.includes("inform")) return "informational";
if (value.includes("commerc")) return "commercial";
if (value.includes("transact")) return "transactional";
if (value.includes("navig")) return "navigational";
return "unknown";
}
// ---------------------------------------------------------------------------
// DataForSEO fetch helpers
// ---------------------------------------------------------------------------
type EnrichedKeyword = {
keyword: string;
searchVolume: number | null;
trend: MonthlySearch[];
cpc: number | null;
competition: number | null;
keywordDifficulty: number | null;
intent: KeywordIntent;
};
type KeywordSource = "related" | "suggestions" | "ideas";
function parseMonthlySearches(
payload: string | null,
context: { keyword: string; projectId: string },
): MonthlySearch[] {
if (!payload) return [];
try {
return JSON.parse(payload) as MonthlySearch[];
} catch (error) {
logServerError("keywords.saved.parse-monthly-searches", error, context);
return [];
}
}
async function fetchRelatedKeywordsWithData(
seedKeyword: string,
locationCode: number,
languageCode: string,
limit: number,
): Promise<EnrichedKeyword[]> {
// Fetch from API - data is embedded in the response
const items = await fetchRelatedKeywordsRaw(
seedKeyword,
locationCode,
languageCode,
limit,
3, // depth=3 for ~584 keywords
);
// Map embedded data directly from the response
const rows: EnrichedKeyword[] = [];
const seen = new Set<string>();
for (const item of items) {
const kw = item.keyword_data?.keyword;
if (!kw) continue;
const normalizedKw = normalizeKeyword(kw);
if (seen.has(normalizedKw)) continue;
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;
rows.push({
keyword: normalizedKw,
searchVolume: keywordInfo?.search_volume ?? null,
trend: (keywordInfo?.monthly_searches ?? []).map((m) => ({
year: m.year,
month: m.month,
searchVolume: m.search_volume ?? 0,
})),
cpc: item.keyword_data?.keyword_info?.cpc ?? null,
competition: item.keyword_data?.keyword_info?.competition ?? null,
keywordDifficulty:
item.keyword_data?.keyword_properties?.keyword_difficulty ?? null,
intent: normalizeIntent(
item.keyword_data?.search_intent_info?.main_intent,
),
});
}
return rows;
}
async function fetchKeywordDataRows(
items: LabsKeywordDataItem[],
): Promise<EnrichedKeyword[]> {
const rows: EnrichedKeyword[] = [];
const seen = new Set<string>();
for (const item of items) {
const kw = item.keyword;
if (!kw) continue;
const normalizedKw = normalizeKeyword(kw);
if (seen.has(normalizedKw)) continue;
seen.add(normalizedKw);
const keywordInfo = item.keyword_info_normalized_with_clickstream
?.search_volume
? item.keyword_info_normalized_with_clickstream
: item.keyword_info;
rows.push({
keyword: normalizedKw,
searchVolume: keywordInfo?.search_volume ?? null,
trend: (keywordInfo?.monthly_searches ?? []).map((m) => ({
year: m.year,
month: m.month,
searchVolume: m.search_volume ?? 0,
})),
cpc: item.keyword_info?.cpc ?? null,
competition: item.keyword_info?.competition ?? null,
keywordDifficulty: item.keyword_properties?.keyword_difficulty ?? null,
intent: normalizeIntent(item.search_intent_info?.main_intent),
});
}
return rows;
}
async function fetchKeywordRowsWithFallback(
seedKeyword: string,
locationCode: number,
languageCode: string,
limit: number,
): Promise<{
rows: EnrichedKeyword[];
source: KeywordSource;
usedFallback: boolean;
}> {
const relatedRows = await fetchRelatedKeywordsWithData(
seedKeyword,
locationCode,
languageCode,
limit,
);
if (relatedRows.length > 0) {
return {
rows: relatedRows,
source: "related",
usedFallback: false,
};
}
const suggestionRows = await fetchKeywordDataRows(
await fetchKeywordSuggestionsRaw(
seedKeyword,
locationCode,
languageCode,
limit,
),
);
if (suggestionRows.length > 0) {
return {
rows: suggestionRows,
source: "suggestions",
usedFallback: true,
};
}
const ideaRows = await fetchKeywordDataRows(
await fetchKeywordIdeasRaw(seedKeyword, locationCode, languageCode, limit),
);
return {
rows: ideaRows,
source: "ideas",
usedFallback: true,
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
async function research(
_userId: string,
input: ResearchKeywordsInput,
): Promise<{
rows: KeywordResearchRow[];
source: KeywordSource;
usedFallback: boolean;
}> {
const uniqueKeywords = [
...new Set(input.keywords.map(normalizeKeyword)),
].filter((kw) => kw.length > 0);
if (uniqueKeywords.length === 0) {
throw new AppError("VALIDATION_ERROR");
}
// Check KV cache
const cacheKey = buildCacheKey("kw:related", {
keywords: uniqueKeywords,
locationCode: input.locationCode,
languageCode: input.languageCode,
resultLimit: input.resultLimit,
depth: 3, // bump when depth changes to bust stale cache
});
type CachedResult = {
rows: EnrichedKeyword[];
source?: KeywordSource;
usedFallback?: boolean;
};
const cached = await getCached<CachedResult>(cacheKey);
// Only serve cached results that actually have metric data. Previous
// failed fetches may have cached rows with all-zero volume/cpc/competition.
const cacheHasMetrics = cached?.rows?.some(
(r) => (r.searchVolume ?? 0) > 0 || (r.cpc ?? 0) > 0,
);
if (cached && cacheHasMetrics) {
return {
rows: cached.rows,
source: cached.source ?? "related",
usedFallback: cached.usedFallback ?? false,
};
}
// Fetch keyword data from primary endpoint with fallback chain
const { rows, source, usedFallback } = await fetchKeywordRowsWithFallback(
uniqueKeywords[0],
input.locationCode,
input.languageCode,
input.resultLimit,
);
// Cache the result
await setCached(
cacheKey,
{ rows, source, usedFallback },
CACHE_TTL.researchResult,
);
// Persist metrics to DB (fire-and-forget, don't block the response)
void Promise.all(
rows.map((row) =>
KeywordResearchRepository.upsertKeywordMetric({
keyword: row.keyword,
locationCode: input.locationCode,
languageCode: input.languageCode,
searchVolume: row.searchVolume,
cpc: row.cpc,
competition: row.competition,
keywordDifficulty: row.keywordDifficulty,
intent: row.intent,
monthlySearchesJson: JSON.stringify(row.trend),
}),
),
).catch((error) => {
logServerError("keywords.research.persist-metrics", error, {
locationCode: input.locationCode,
languageCode: input.languageCode,
rowCount: rows.length,
});
});
return { rows, source, usedFallback };
}
async function listProjects(userId: string) {
const rows = await KeywordResearchRepository.listProjects(userId);
return rows.map((row) => ({
id: row.id,
name: row.name,
domain: row.domain,
createdAt: row.createdAt,
}));
}
async function createProject(userId: string, input: CreateProjectInput) {
const id = await KeywordResearchRepository.createProject(
userId,
input.name,
input.domain,
);
return { id };
}
async function deleteProject(userId: string, input: DeleteProjectInput) {
await KeywordResearchRepository.deleteProject(input.projectId, userId);
return { success: true };
}
async function saveKeywords(userId: string, input: SaveKeywordsInput) {
const project = await KeywordResearchRepository.getProject(
input.projectId,
userId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
const normalizedKeywords = [
...new Set(
input.keywords.map(normalizeKeyword).filter((kw) => kw.length > 0),
),
];
const metricByKeyword = new Map(
(input.metrics ?? [])
.map((metric) => {
const keyword = normalizeKeyword(metric.keyword);
if (!keyword || !normalizedKeywords.includes(keyword)) return null;
return [keyword, metric] as const;
})
.filter(
(
entry,
): entry is readonly [
string,
NonNullable<typeof input.metrics>[number],
] => entry != null,
),
);
if (metricByKeyword.size > 0) {
await Promise.all(
normalizedKeywords.map(async (keyword) => {
const metric = metricByKeyword.get(keyword);
if (!metric) return;
await KeywordResearchRepository.upsertKeywordMetric({
keyword,
locationCode: input.locationCode,
languageCode: input.languageCode,
searchVolume: metric.searchVolume ?? null,
cpc: metric.cpc ?? null,
competition: metric.competition ?? null,
keywordDifficulty: metric.keywordDifficulty ?? null,
intent: metric.intent ?? null,
monthlySearchesJson: JSON.stringify(metric.monthlySearches ?? []),
});
}),
);
}
await KeywordResearchRepository.saveKeywordsToProject({
projectId: input.projectId,
keywords: normalizedKeywords,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
return { success: true };
}
async function getSavedKeywords(
userId: string,
input: GetSavedKeywordsInput,
): Promise<{ rows: SavedKeywordRow[] }> {
const project = await KeywordResearchRepository.getProject(
input.projectId,
userId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
const rows = await KeywordResearchRepository.listSavedKeywordsByProject(
input.projectId,
);
return {
rows: rows.map(({ row, metric }) => ({
id: row.id,
projectId: row.projectId,
keyword: row.keyword,
locationCode: row.locationCode,
languageCode: row.languageCode,
createdAt: row.createdAt,
searchVolume: metric?.searchVolume ?? null,
cpc: metric?.cpc ?? null,
competition: metric?.competition ?? null,
keywordDifficulty: metric?.keywordDifficulty ?? null,
intent: metric?.intent ?? null,
monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null, {
keyword: row.keyword,
projectId: row.projectId,
}),
fetchedAt: metric?.fetchedAt ?? null,
})),
};
}
async function removeSavedKeyword(
userId: string,
input: RemoveSavedKeywordInput,
) {
// Verify the keyword belongs to a project owned by this user
const savedKw = await KeywordResearchRepository.getSavedKeywordById(
input.savedKeywordId,
);
if (!savedKw) {
throw new AppError("NOT_FOUND");
}
const project = await KeywordResearchRepository.getProject(
savedKw.projectId,
userId,
);
if (!project) {
throw new AppError("FORBIDDEN");
}
await KeywordResearchRepository.removeSavedKeyword(input.savedKeywordId);
return { success: true };
}
async function getOrCreateDefaultProject(userId: string) {
const existing = await KeywordResearchRepository.listProjects(userId);
if (existing.length > 0) {
const first = existing[0];
return {
id: first.id,
name: first.name,
domain: first.domain,
createdAt: first.createdAt,
};
}
const id = await KeywordResearchRepository.createProject(
userId,
"Default",
undefined,
);
return {
id,
name: "Default",
domain: null,
createdAt: new Date().toISOString(),
};
}
async function getProject(userId: string, projectId: string) {
const project = await KeywordResearchRepository.getProject(projectId, userId);
if (!project) return null;
return {
id: project.id,
name: project.name,
domain: project.domain,
createdAt: project.createdAt,
};
}
// ---------------------------------------------------------------------------
// SERP Analysis
// ---------------------------------------------------------------------------
const SERP_CACHE_TTL_SECONDS = 12 * 60 * 60; // 12 hours
async function getSerpAnalysis(input: {
keyword: string;
locationCode: number;
languageCode: string;
}): Promise<{ items: SerpResultItem[] }> {
const keyword = normalizeKeyword(input.keyword);
const cacheKey = buildCacheKey("serp:analysis", {
keyword,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
const cached = await getCached<{ items: SerpResultItem[] }>(cacheKey);
if (cached && cached.items.length > 0) {
return cached;
}
const snapshots = await fetchHistoricalSerpsRaw(
keyword,
input.locationCode,
input.languageCode,
);
// Take the most recent snapshot (first item)
const snapshot = snapshots[0];
const rawItems = snapshot?.items ?? [];
// Filter to organic results only and map to our shape
const items: SerpResultItem[] = rawItems
.filter((item) => item.type === "organic")
.map((item) => ({
rank: item.rank_absolute ?? item.rank_group ?? 0,
title: item.title ?? "",
url: item.url ?? "",
domain: item.domain ?? "",
description: item.description ?? "",
etv: item.etv ?? null,
estimatedPaidTrafficCost: item.estimated_paid_traffic_cost ?? null,
referringDomains: item.backlinks_info?.referring_domains ?? null,
backlinks: item.backlinks_info?.backlinks ?? null,
isNew: item.rank_changes?.is_new ?? false,
rankChange:
item.rank_changes?.previous_rank_absolute != null &&
item.rank_absolute != null
? item.rank_changes.previous_rank_absolute - item.rank_absolute
: null,
}));
const result = { items };
if (items.length > 0) {
void setCached(cacheKey, result, SERP_CACHE_TTL_SECONDS).catch((err) => {
console.error("Failed to cache SERP analysis in KV:", err);
});
}
return result;
}
export const KeywordResearchService = {
research,
getSerpAnalysis,
listProjects,
createProject,
deleteProject,
saveKeywords,
getSavedKeywords,
removeSavedKeyword,
getOrCreateDefaultProject,
getProject,
} as const;

View File

@ -0,0 +1,192 @@
import { sortBy } from "remeda";
const PSI_CATEGORIES = [
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
export type PsiIssueCategory = (typeof PSI_CATEGORIES)[number];
export type PsiIssue = {
category: PsiIssueCategory;
auditKey: string;
title: string;
description: string;
score: number | null;
scoreDisplayMode: string | null;
displayValue: string | null;
impactMs: number | null;
impactBytes: number | null;
severity: "critical" | "warning" | "info";
items: string[];
};
type LighthouseAudit = {
title?: string;
description?: string;
score?: number | null;
scoreDisplayMode?: string;
displayValue?: string;
details?: {
overallSavingsMs?: number;
overallSavingsBytes?: number;
items?: Array<Record<string, unknown>>;
};
};
type LighthouseCategory = {
auditRefs?: Array<{
id?: string;
}>;
};
function normalizeScore(score: number | null | undefined): number | null {
if (score == null || Number.isNaN(score)) return null;
return Math.round(score * 100);
}
function compactItem(item: Record<string, unknown>): string {
const preferredKeys = [
"url",
"source",
"nodeLabel",
"snippet",
"totalBytes",
"wastedBytes",
"wastedMs",
"label",
"value",
];
const output: Record<string, unknown> = {};
for (const key of preferredKeys) {
if (item[key] != null) {
output[key] = item[key];
}
}
if (Object.keys(output).length === 0) {
for (const [key, value] of Object.entries(item).slice(0, 6)) {
output[key] = value;
}
}
return JSON.stringify(output);
}
function getSeverity(input: {
score: number | null;
impactMs: number | null;
impactBytes: number | null;
}): "critical" | "warning" | "info" {
if ((input.impactMs ?? 0) >= 300 || (input.impactBytes ?? 0) >= 150_000) {
return "critical";
}
if (input.score != null && input.score < 50) {
return "critical";
}
if ((input.impactMs ?? 0) >= 100 || (input.impactBytes ?? 0) >= 50_000) {
return "warning";
}
if (input.score != null && input.score < 90) {
return "warning";
}
return "info";
}
function parseIssues(
payloadJson: string,
categoryFilter?: PsiIssueCategory,
): PsiIssue[] {
let payload: Record<string, unknown>;
try {
payload = JSON.parse(payloadJson) as Record<string, unknown>;
} 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 issues: PsiIssue[] = [];
for (const category of PSI_CATEGORIES) {
if (categoryFilter && category !== categoryFilter) continue;
const refs = categories[category]?.auditRefs ?? [];
for (const ref of refs) {
const auditKey = ref.id;
if (!auditKey) continue;
const audit = audits[auditKey];
if (!audit) continue;
const score = normalizeScore(audit.score);
const displayMode = audit.scoreDisplayMode ?? null;
const isPass =
(score != null && score >= 90) ||
displayMode === "notApplicable" ||
displayMode === "informative" ||
displayMode === "manual";
if (isPass) continue;
const impactMs =
typeof audit.details?.overallSavingsMs === "number"
? audit.details.overallSavingsMs
: null;
const impactBytes =
typeof audit.details?.overallSavingsBytes === "number"
? audit.details.overallSavingsBytes
: null;
const items = Array.isArray(audit.details?.items)
? audit.details!.items!.slice(0, 10).map(compactItem)
: [];
issues.push({
category,
auditKey,
title: audit.title ?? auditKey,
description: audit.description ?? "",
score,
scoreDisplayMode: displayMode,
displayValue: audit.displayValue ?? null,
impactMs,
impactBytes,
severity: getSeverity({ score, impactMs, impactBytes }),
items,
});
}
}
return sortBy(
issues,
[
(issue) => (issue.impactMs ?? 0) * 1000 + (issue.impactBytes ?? 0),
"desc",
],
[(issue) => issue.score ?? 100, "asc"],
);
}
export const PsiIssuesService = {
parseIssues,
} as const;

View File

@ -0,0 +1,170 @@
const PSI_ENDPOINT =
"https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
const PSI_CATEGORIES = [
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
type PsiCategory = (typeof PSI_CATEGORIES)[number];
type PsiStrategy = "mobile" | "desktop";
type PsiAuditMetric = {
score: number | null;
displayValue: string | null;
numericValue: number | null;
};
type PsiAuditResult = {
requestedUrl: string;
finalUrl: string;
strategy: PsiStrategy;
fetchedAt: string;
lighthouseVersion: string | null;
scores: Record<PsiCategory, number | null>;
metrics: {
firstContentfulPaint: PsiAuditMetric;
largestContentfulPaint: PsiAuditMetric;
totalBlockingTime: PsiAuditMetric;
cumulativeLayoutShift: PsiAuditMetric;
speedIndex: PsiAuditMetric;
timeToInteractive: PsiAuditMetric;
};
rawPayload: Record<string, unknown>;
};
type LighthouseAudit = {
score?: number | null;
displayValue?: string;
numericValue?: number;
};
function normalizeInputUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("URL is required");
}
const withProtocol = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
try {
const parsed = new URL(withProtocol);
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("Only http and https URLs are supported");
}
return parsed.toString();
} catch {
throw new Error("Please enter a valid URL");
}
}
function asScore(value: number | null | undefined): number | null {
if (value == null || Number.isNaN(value)) return null;
return Math.round(value * 100);
}
function asMetric(audit: LighthouseAudit | undefined): PsiAuditMetric {
return {
score: asScore(audit?.score),
displayValue: audit?.displayValue ?? null,
numericValue:
typeof audit?.numericValue === "number" ? audit.numericValue : null,
};
}
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;
}
async function runAudit(input: {
url: string;
strategy: PsiStrategy;
apiKey: string;
}): Promise<PsiAuditResult> {
const apiKey = input.apiKey.trim();
if (!apiKey) {
throw new Error("PSI API key is required");
}
const normalizedUrl = normalizeInputUrl(input.url);
const params = new URLSearchParams({
url: normalizedUrl,
strategy: input.strategy,
});
for (const category of PSI_CATEGORIES) {
params.append("category", category);
}
params.append("key", apiKey);
const response = await fetch(`${PSI_ENDPOINT}?${params.toString()}`);
const payload = (await response.json().catch(() => null)) as Record<
string,
unknown
> | 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) {
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
>;
return {
requestedUrl: normalizedUrl,
finalUrl:
(lighthouseResult.finalDisplayedUrl as string | undefined) ??
normalizedUrl,
strategy: input.strategy,
fetchedAt: new Date().toISOString(),
lighthouseVersion:
(lighthouseResult.lighthouseVersion as string | undefined) ?? null,
scores: {
performance: asScore(categories.performance?.score),
accessibility: asScore(categories.accessibility?.score),
"best-practices": asScore(categories["best-practices"]?.score),
seo: asScore(categories.seo?.score),
},
metrics: {
firstContentfulPaint: asMetric(audits["first-contentful-paint"]),
largestContentfulPaint: asMetric(audits["largest-contentful-paint"]),
totalBlockingTime: asMetric(audits["total-blocking-time"]),
cumulativeLayoutShift: asMetric(audits["cumulative-layout-shift"]),
speedIndex: asMetric(audits["speed-index"]),
timeToInteractive: asMetric(audits.interactive),
},
rawPayload: payload ?? {},
};
}
export const PsiService = {
runAudit,
} as const;

View File

@ -0,0 +1,25 @@
import type { KeywordIntent, MonthlySearch } from "@/types/keywords";
export type EnrichedKeyword = {
keyword: string;
searchVolume: number | null;
trend: MonthlySearch[];
cpc: number | null;
competition: number | null;
keywordDifficulty: number | null;
intent: KeywordIntent;
};
export function normalizeKeyword(input: string): string {
return input.trim().toLowerCase();
}
export function normalizeIntent(raw: unknown): KeywordIntent {
if (typeof raw !== "string") return "unknown";
const value = raw.toLowerCase();
if (value.includes("inform")) return "informational";
if (value.includes("commerc")) return "commercial";
if (value.includes("transact")) return "transactional";
if (value.includes("navig")) return "navigational";
return "unknown";
}

View File

@ -0,0 +1,65 @@
import type {
CreateProjectInput,
DeleteProjectInput,
} from "@/types/schemas/keywords";
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository";
export async function listProjects(userId: string) {
const rows = await KeywordResearchRepository.listProjects(userId);
return rows.map((row) => ({
id: row.id,
name: row.name,
domain: row.domain,
createdAt: row.createdAt,
}));
}
export async function createProject(userId: string, input: CreateProjectInput) {
const id = await KeywordResearchRepository.createProject(
userId,
input.name,
input.domain,
);
return { id };
}
export async function deleteProject(userId: string, input: DeleteProjectInput) {
await KeywordResearchRepository.deleteProject(input.projectId, userId);
return { success: true };
}
export async function getOrCreateDefaultProject(userId: string) {
const existing = await KeywordResearchRepository.listProjects(userId);
if (existing.length > 0) {
const first = existing[0];
return {
id: first.id,
name: first.name,
domain: first.domain,
createdAt: first.createdAt,
};
}
const id = await KeywordResearchRepository.createProject(
userId,
"Default",
undefined,
);
return {
id,
name: "Default",
domain: null,
createdAt: new Date().toISOString(),
};
}
export async function getProject(userId: string, projectId: string) {
const project = await KeywordResearchRepository.getProject(projectId, userId);
if (!project) return null;
return {
id: project.id,
name: project.name,
domain: project.domain,
createdAt: project.createdAt,
};
}

View File

@ -0,0 +1,61 @@
import { fetchRelatedKeywordsRaw } from "@/server/lib/dataforseo";
import type { ResearchKeywordsInput } from "@/types/schemas/keywords";
import {
normalizeIntent,
normalizeKeyword,
type EnrichedKeyword,
} from "./helpers";
export async function fetchResearchRows(
input: ResearchKeywordsInput,
uniqueKeywords: string[],
): Promise<EnrichedKeyword[]> {
const seedKeyword = uniqueKeywords[0];
if (!seedKeyword) {
return [];
}
const items = await fetchRelatedKeywordsRaw(
seedKeyword,
input.locationCode,
input.languageCode,
input.resultLimit,
3,
);
const rows: EnrichedKeyword[] = [];
const seen = new Set<string>();
for (const item of items) {
const keyword = item.keyword_data?.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;
rows.push({
keyword: normalizedKeyword,
searchVolume: keywordInfo?.search_volume ?? null,
trend: (keywordInfo?.monthly_searches ?? []).map((entry) => ({
year: entry.year,
month: entry.month,
searchVolume: entry.search_volume ?? 0,
})),
cpc: item.keyword_data?.keyword_info?.cpc ?? null,
competition: item.keyword_data?.keyword_info?.competition ?? null,
keywordDifficulty:
item.keyword_data?.keyword_properties?.keyword_difficulty ?? null,
intent: normalizeIntent(
item.keyword_data?.search_intent_info?.main_intent,
),
});
}
return rows;
}

View File

@ -0,0 +1,109 @@
import { AppError } from "@/server/lib/errors";
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository";
import type {
GetSavedKeywordsInput,
RemoveSavedKeywordInput,
SaveKeywordsInput,
} from "@/types/schemas/keywords";
import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords";
import { normalizeKeyword } from "./helpers";
import { logServerError } from "@/server/lib/logger";
function parseMonthlySearches(
payload: string | null,
context: { keyword: string; projectId: string },
): MonthlySearch[] {
if (!payload) return [];
try {
return JSON.parse(payload) as MonthlySearch[];
} catch (error) {
logServerError("keywords.saved.parse-monthly-searches", error, context);
return [];
}
}
export async function saveKeywords(userId: string, input: SaveKeywordsInput) {
const project = await KeywordResearchRepository.getProject(
input.projectId,
userId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
const normalizedKeywords = [
...new Set(
input.keywords.map(normalizeKeyword).filter((kw) => kw.length > 0),
),
];
await KeywordResearchRepository.saveKeywordsToProject({
projectId: input.projectId,
keywords: normalizedKeywords,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
return { success: true };
}
export async function getSavedKeywords(
userId: string,
input: GetSavedKeywordsInput,
): Promise<{ rows: SavedKeywordRow[] }> {
const project = await KeywordResearchRepository.getProject(
input.projectId,
userId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
const rows = await KeywordResearchRepository.listSavedKeywordsByProject(
input.projectId,
);
return {
rows: rows.map(({ row, metric }) => ({
id: row.id,
projectId: row.projectId,
keyword: row.keyword,
locationCode: row.locationCode,
languageCode: row.languageCode,
createdAt: row.createdAt,
searchVolume: metric?.searchVolume ?? null,
cpc: metric?.cpc ?? null,
competition: metric?.competition ?? null,
keywordDifficulty: metric?.keywordDifficulty ?? null,
intent: metric?.intent ?? null,
monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null, {
keyword: row.keyword,
projectId: row.projectId,
}),
fetchedAt: metric?.fetchedAt ?? null,
})),
};
}
export async function removeSavedKeyword(
userId: string,
input: RemoveSavedKeywordInput,
) {
const savedKw = await KeywordResearchRepository.getSavedKeywordById(
input.savedKeywordId,
);
if (!savedKw) {
throw new AppError("NOT_FOUND");
}
const project = await KeywordResearchRepository.getProject(
savedKw.projectId,
userId,
);
if (!project) {
throw new AppError("FORBIDDEN");
}
await KeywordResearchRepository.removeSavedKeyword(input.savedKeywordId);
return { success: true };
}

View File

@ -0,0 +1,69 @@
import { fetchHistoricalSerpsRaw } from "@/server/lib/dataforseo";
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
import { logServerError } from "@/server/lib/logger";
import type { SerpResultItem } from "@/types/keywords";
import { normalizeKeyword } from "./helpers";
const SERP_CACHE_TTL_SECONDS = 12 * 60 * 60;
export async function getSerpAnalysis(input: {
keyword: string;
locationCode: number;
languageCode: string;
}): Promise<{ items: SerpResultItem[] }> {
const keyword = normalizeKeyword(input.keyword);
const cacheKey = buildCacheKey("serp:analysis", {
keyword,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
const cached = await getCached<{ items: SerpResultItem[] }>(cacheKey);
if (cached && cached.items.length > 0) {
return cached;
}
const snapshots = await fetchHistoricalSerpsRaw(
keyword,
input.locationCode,
input.languageCode,
);
const snapshot = snapshots[0];
const rawItems = snapshot?.items ?? [];
const items: SerpResultItem[] = rawItems
.filter((item) => item.type === "organic")
.map((item) => ({
rank: item.rank_absolute ?? item.rank_group ?? 0,
title: item.title ?? "",
url: item.url ?? "",
domain: item.domain ?? "",
description: item.description ?? "",
etv: item.etv ?? null,
estimatedPaidTrafficCost: item.estimated_paid_traffic_cost ?? null,
referringDomains: item.backlinks_info?.referring_domains ?? null,
backlinks: item.backlinks_info?.backlinks ?? null,
isNew: item.rank_changes?.is_new ?? false,
rankChange:
item.rank_changes?.previous_rank_absolute != null &&
item.rank_absolute != null
? item.rank_changes.previous_rank_absolute - item.rank_absolute
: null,
}));
const result = { items };
if (items.length > 0) {
void setCached(cacheKey, result, SERP_CACHE_TTL_SECONDS).catch((error) => {
logServerError("keywords.serp.cache-write", error, {
keyword,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
});
}
return result;
}

View File

@ -0,0 +1,540 @@
/**
* Cloudflare Workflow for site audit crawling.
*
* Each step is durable if a step fails, it retries without redoing
* completed steps.
*
* Flow:
* Step 1: Discovery (robots.txt + sitemaps)
* Step 2-N: Crawl page batches (parallel fetch+analyze per step)
* Step N+1: Select PSI sample
* Step N+2-M: PSI batches (parallel URLs, mobile+desktop per URL)
* Step M+1: Finalize (batch write to D1)
*/
import {
WorkflowEntrypoint,
type WorkflowEvent,
type WorkflowStep,
} from "cloudflare:workers";
import {
discoverUrls,
fetchRobotsTxt,
type RobotsResult,
} from "@/server/lib/audit/discovery";
import { analyzeHtml } from "@/server/lib/audit/page-analyzer";
import { fetchPsiResult, selectPsiSample } from "@/server/lib/audit/psi";
import {
normalizeUrl,
isSameOrigin,
getOrigin,
} from "@/server/lib/audit/url-utils";
import { putTextToR2 } from "@/server/lib/r2";
import { AuditRepository } from "@/server/repositories/AuditRepository";
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
import type { AuditConfig, PsiResult } from "@/server/lib/audit/types";
interface AuditParams {
auditId: string;
projectId: string;
startUrl: string;
config: AuditConfig;
}
const CRAWL_CONCURRENCY = 25;
const PSI_URL_CONCURRENCY = 6;
/** Serializable page data passed between workflow steps. */
interface StepPageResult {
id: string;
url: string;
statusCode: number;
redirectUrl: string | null;
// Metadata
title: string;
metaDescription: string;
canonicalUrl: string | null;
robotsMeta: string | null;
// Open Graph
ogTitle: string | null;
ogDescription: string | null;
ogImage: string | null;
// Headings
h1Count: number;
h2Count: number;
h3Count: number;
h4Count: number;
h5Count: number;
h6Count: number;
headingOrder: number[];
// Content
wordCount: number;
// Images
imagesTotal: number;
imagesMissingAlt: number;
images: Array<{ src: string | null; alt: string | null }>;
// Links
internalLinks: string[];
externalLinks: string[];
// Structured data
hasStructuredData: boolean;
// Hreflang
hreflangTags: string[];
// Indexability
isIndexable: boolean;
// Performance
responseTimeMs: number;
}
type PsiUploadContext = {
projectId: string;
auditId: string;
};
function shouldQueueCrawlLink(
link: string,
origin: string,
robots: RobotsResult,
visited: Set<string>,
queued: Set<string>,
): boolean {
return (
isSameOrigin(link, origin) &&
robots.isAllowed(link) &&
!visited.has(link) &&
!queued.has(link)
);
}
function countPsiBatchResults(results: PsiResult[]): {
completed: number;
failed: number;
} {
let completed = 0;
let failed = 0;
for (const result of results) {
if (result.errorMessage) {
failed += 1;
continue;
}
completed += 1;
}
return { completed, failed };
}
export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
async run(event: WorkflowEvent<AuditParams>, step: WorkflowStep) {
const { auditId, projectId, startUrl, config } = event.payload;
const origin = getOrigin(startUrl);
const maxPages = config.maxPages;
try {
// ─── Step 1: Discovery ───────────────────────────────────────
const discovery = await step.do("discover-urls", async () => {
const result = await discoverUrls(origin, maxPages);
// Update audit with discovery info
await AuditRepository.updateAuditProgress(auditId, {
pagesTotal: Math.min(result.urls.length + 1, maxPages),
currentPhase: "crawling",
});
return {
sitemapUrls: result.urls,
// We can't serialize the robots function, so we store the raw result
// and re-fetch robots in crawl steps if needed
};
});
const robots = await fetchRobotsTxt(origin);
// ─── Step 2-N: Crawl pages ──────────────────────────────────
const visited = new Set<string>();
const queue: string[] = [];
const queued = new Set<string>();
const allPages: StepPageResult[] = [];
// Seed the queue
const normalizedStart = normalizeUrl(startUrl) ?? startUrl;
if (
robots.isAllowed(normalizedStart) &&
isSameOrigin(normalizedStart, origin)
) {
queue.push(normalizedStart);
queued.add(normalizedStart);
}
// Add sitemap URLs to queue
for (const sitemapUrl of discovery.sitemapUrls) {
const normalized = normalizeUrl(sitemapUrl);
if (
normalized &&
isSameOrigin(normalized, origin) &&
robots.isAllowed(normalized)
) {
if (!visited.has(normalized) && !queued.has(normalized)) {
queue.push(normalized);
queued.add(normalized);
}
}
}
let crawlBatchIndex = 0;
while (queue.length > 0 && allPages.length < maxPages) {
const remaining = maxPages - allPages.length;
const batchSize = Math.min(CRAWL_CONCURRENCY, remaining);
const urlsToCrawl: string[] = [];
while (queue.length > 0 && urlsToCrawl.length < batchSize) {
const url = queue.shift()!;
queued.delete(url);
if (visited.has(url)) continue;
if (!robots.isAllowed(url)) continue;
visited.add(url);
urlsToCrawl.push(url);
}
if (urlsToCrawl.length === 0) continue;
crawlBatchIndex++;
const crawledBatch = await step.do(
`crawl-batch-${crawlBatchIndex}`,
async () => {
const settled = await Promise.allSettled(
urlsToCrawl.map((url) => crawlPage(url, origin)),
);
return settled.flatMap((result) => {
if (result.status === "fulfilled" && result.value) {
return [result.value];
}
return [];
});
},
);
allPages.push(...crawledBatch);
// Add discovered internal links to queue
for (const pageResult of crawledBatch) {
for (const link of pageResult.internalLinks.filter((candidate) =>
shouldQueueCrawlLink(candidate, origin, robots, visited, queued),
)) {
queue.push(link);
queued.add(link);
}
}
// Push crawled URLs to KV for live progress (batched)
await step.do(`kv-progress-batch-${crawlBatchIndex}`, async () => {
await AuditProgressKV.pushCrawledUrls(
auditId,
crawledBatch.map((pageResult) => ({
url: pageResult.url,
statusCode: pageResult.statusCode,
title: pageResult.title,
crawledAt: Date.now(),
})),
);
});
// Update D1 progress each batch
await step.do(`progress-batch-${crawlBatchIndex}`, async () => {
await AuditRepository.updateAuditProgress(auditId, {
pagesCrawled: allPages.length,
pagesTotal: Math.min(visited.size + queue.length, maxPages),
});
});
}
// ─── PSI Phase ──────────────────────────────────────────────
const psiResults: PsiResult[] = [];
if (config.psiStrategy !== "none" && config.psiApiKey) {
const psiSample = await step.do("select-psi-sample", async () => {
const pagesForSample = allPages.map((p) => ({
id: p.id,
url: p.url,
statusCode: p.statusCode,
}));
const sample = selectPsiSample(
pagesForSample,
startUrl,
config.psiStrategy,
);
await AuditRepository.updateAuditProgress(auditId, {
currentPhase: "psi",
psiTotal: sample.length * 2,
psiCompleted: 0,
psiFailed: 0,
});
return sample;
});
let psiCompleted = 0;
let psiFailed = 0;
const updatePsiProgress = async (stepName: string) => {
await step.do(stepName, async () => {
await AuditRepository.updateAuditProgress(auditId, {
psiCompleted,
psiFailed,
});
});
};
const psiWork = psiSample.flatMap((psiUrl) => {
const page = allPages.find((p) => p.url === psiUrl);
if (!page) return [];
return [{ url: psiUrl, pageId: page.id }];
});
let psiBatchIndex = 0;
for (let i = 0; i < psiWork.length; i += PSI_URL_CONCURRENCY) {
const batch = psiWork.slice(i, i + PSI_URL_CONCURRENCY);
psiBatchIndex += 1;
const psiBatchResults = await step.do(
`psi-batch-${psiBatchIndex}`,
async () => {
const perUrlResults = await Promise.all(
batch.map(async ({ url, pageId }) => {
const [mobileResult, desktopResult] = await Promise.all([
fetchPsiAndUploadToR2(
url,
pageId,
"mobile",
config.psiApiKey!,
{ projectId, auditId },
),
fetchPsiAndUploadToR2(
url,
pageId,
"desktop",
config.psiApiKey!,
{ projectId, auditId },
),
]);
return [mobileResult, desktopResult];
}),
);
return perUrlResults.flat();
},
);
psiResults.push(...psiBatchResults);
const counts = countPsiBatchResults(psiBatchResults);
psiFailed += counts.failed;
psiCompleted += counts.completed;
await updatePsiProgress(`psi-progress-batch-${psiBatchIndex}`);
}
}
// ─── Finalize ────────────────────────────────────────────────
await step.do("finalize", async () => {
await AuditRepository.updateAuditProgress(auditId, {
currentPhase: "finalizing",
});
// Batch write all results to D1
await AuditRepository.batchWriteResults(auditId, allPages, psiResults);
// Mark audit as completed
await AuditRepository.completeAudit(auditId, {
pagesCrawled: allPages.length,
pagesTotal: allPages.length,
});
// Clean up KV progress data (no longer needed once results are in D1)
await AuditProgressKV.clear(auditId);
});
} catch (error) {
console.error(`Audit ${auditId} failed:`, error);
await step.do("mark-failed", async () => {
await AuditRepository.failAudit(auditId);
});
throw error;
}
}
}
async function fetchPsiAndUploadToR2(
url: string,
pageId: string,
strategy: "mobile" | "desktop",
apiKey: string,
context: PsiUploadContext,
): Promise<PsiResult> {
const result = await fetchPsiResult(url, pageId, strategy, apiKey);
if (result.rawPayloadJson) {
const key = `site-audit/${context.projectId}/${context.auditId}/${pageId}-${strategy}.json`;
const uploaded = await putTextToR2(key, result.rawPayloadJson);
result.r2Key = uploaded.key;
result.payloadSizeBytes = uploaded.sizeBytes;
result.rawPayloadJson = null;
}
return result;
}
/**
* Fetch and analyze a single page. Returns null if the page can't be fetched.
*/
async function crawlPage(
url: string,
crawlOrigin: string,
): Promise<StepPageResult | null> {
const startTime = Date.now();
try {
const response = await fetch(url, {
headers: {
"User-Agent": "SuperSEO-Audit/1.0",
Accept: "text/html,application/xhtml+xml",
},
redirect: "follow",
signal: AbortSignal.timeout(15_000),
});
const responseTimeMs = Date.now() - startTime;
const statusCode = response.status;
const finalUrl = normalizeUrl(response.url) ?? response.url;
if (!isSameOrigin(finalUrl, crawlOrigin)) {
return null;
}
// Detect redirects
const redirectUrl =
response.redirected && response.url !== url ? response.url : null;
// Only parse HTML responses
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) {
return {
id: crypto.randomUUID(),
url: finalUrl,
statusCode,
redirectUrl,
title: "",
metaDescription: "",
canonicalUrl: null,
robotsMeta: null,
ogTitle: null,
ogDescription: null,
ogImage: null,
h1Count: 0,
h2Count: 0,
h3Count: 0,
h4Count: 0,
h5Count: 0,
h6Count: 0,
headingOrder: [],
wordCount: 0,
imagesTotal: 0,
imagesMissingAlt: 0,
images: [],
internalLinks: [],
externalLinks: [],
hasStructuredData: false,
hreflangTags: [],
isIndexable: false,
responseTimeMs,
};
}
const html = await response.text();
const analysis = analyzeHtml(
html,
finalUrl,
statusCode,
responseTimeMs,
redirectUrl,
);
// Determine indexability
const isIndexable = !(
analysis.robotsMeta?.toLowerCase().includes("noindex") ?? false
);
// Count headings by level
const h2Count = analysis.headingOrder.filter((h) => h === 2).length;
const h3Count = analysis.headingOrder.filter((h) => h === 3).length;
const h4Count = analysis.headingOrder.filter((h) => h === 4).length;
const h5Count = analysis.headingOrder.filter((h) => h === 5).length;
const h6Count = analysis.headingOrder.filter((h) => h === 6).length;
return {
id: crypto.randomUUID(),
url: finalUrl,
statusCode,
redirectUrl,
title: analysis.title,
metaDescription: analysis.metaDescription,
canonicalUrl: analysis.canonical,
robotsMeta: analysis.robotsMeta,
ogTitle: analysis.ogTitle,
ogDescription: analysis.ogDescription,
ogImage: analysis.ogImage,
h1Count: analysis.h1s.length,
h2Count,
h3Count,
h4Count,
h5Count,
h6Count,
headingOrder: analysis.headingOrder,
wordCount: analysis.wordCount,
imagesTotal: analysis.images.length,
imagesMissingAlt: analysis.images.filter(
(img) => !img.alt || img.alt === "",
).length,
images: analysis.images,
internalLinks: analysis.internalLinks,
externalLinks: analysis.externalLinks,
hasStructuredData: analysis.hasStructuredData,
hreflangTags: analysis.hreflangTags,
isIndexable,
responseTimeMs,
};
} catch (error) {
const responseTimeMs = Date.now() - startTime;
console.warn(`Failed to crawl ${url}:`, error);
return {
id: crypto.randomUUID(),
url,
statusCode: 0,
redirectUrl: null,
title: "",
metaDescription: "",
canonicalUrl: null,
robotsMeta: null,
ogTitle: null,
ogDescription: null,
ogImage: null,
h1Count: 0,
h2Count: 0,
h3Count: 0,
h4Count: 0,
h5Count: 0,
h6Count: 0,
headingOrder: [],
wordCount: 0,
imagesTotal: 0,
imagesMissingAlt: 0,
images: [],
internalLinks: [],
externalLinks: [],
hasStructuredData: false,
hreflangTags: [],
isIndexable: false,
responseTimeMs,
};
}
}

View File

@ -0,0 +1,112 @@
import { createServerFn } from "@tanstack/react-start";
import { ensureUserMiddleware } from "@/middleware/ensureUser";
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
import {
startAuditSchema,
getAuditStatusSchema,
getAuditResultsSchema,
getAuditHistorySchema,
deleteAuditSchema,
getCrawlProgressSchema,
} from "@/types/schemas/audit";
import { AuditService } from "@/server/services/AuditService";
import { logServerError } from "@/server/lib/logger";
import { toClientError } from "@/server/lib/errors";
export const startAudit = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => startAuditSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await AuditService.startAudit({
userId: context.userId,
projectId: data.projectId,
startUrl: data.startUrl,
maxPages: data.maxPages,
psiStrategy: data.psiStrategy,
psiApiKey: data.psiApiKey,
});
} catch (error) {
logServerError("audit.start", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});
export const getAuditStatus = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => getAuditStatusSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await AuditService.getStatus(data.auditId, context.userId);
} catch (error) {
logServerError("audit.status", error, {
userId: context.userId,
auditId: data.auditId,
});
throw toClientError(error);
}
});
export const getAuditResults = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => getAuditResultsSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await AuditService.getResults(data.auditId, context.userId);
} catch (error) {
logServerError("audit.results", error, {
userId: context.userId,
auditId: data.auditId,
});
throw toClientError(error);
}
});
export const getAuditHistory = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await AuditService.getHistory(data.projectId, context.userId);
} catch (error) {
logServerError("audit.history", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});
export const getCrawlProgress = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => getCrawlProgressSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await AuditService.getCrawlProgress(data.auditId, context.userId);
} catch (error) {
logServerError("audit.crawl-progress", error, {
userId: context.userId,
auditId: data.auditId,
});
throw toClientError(error);
}
});
export const deleteAudit = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => deleteAuditSchema.parse(data))
.handler(async ({ data, context }) => {
try {
await AuditService.remove(data.auditId, context.userId);
return { success: true };
} catch (error) {
logServerError("audit.delete", error, {
userId: context.userId,
auditId: data.auditId,
});
throw toClientError(error);
}
});

View File

@ -0,0 +1,22 @@
import { createServerFn } from "@tanstack/react-start";
import { ensureUserMiddleware } from "@/middleware/ensureUser";
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
import { domainOverviewSchema } from "@/types/schemas/domain";
import { DomainService } from "@/server/services/DomainService";
import { logServerError } from "@/server/lib/logger";
import { toClientError } from "@/server/lib/errors";
export const getDomainOverview = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => domainOverviewSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await DomainService.getOverview(data);
} catch (error) {
logServerError("domain.overview", error, {
userId: context.userId,
domain: data.domain,
});
throw toClientError(error);
}
});

View File

@ -0,0 +1,168 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { ensureUserMiddleware } from "@/middleware/ensureUser";
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
import {
researchKeywordsSchema,
createProjectSchema,
deleteProjectSchema,
saveKeywordsSchema,
getSavedKeywordsSchema,
removeSavedKeywordSchema,
serpAnalysisSchema,
} from "@/types/schemas/keywords";
import { KeywordResearchService } from "@/server/services/KeywordResearchService";
import { logServerError } from "@/server/lib/logger";
import { toClientError } from "@/server/lib/errors";
export const researchKeywords = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => researchKeywordsSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await KeywordResearchService.research(context.userId, data);
} catch (error) {
logServerError("keywords.research", error, { userId: context.userId });
throw toClientError(error);
}
});
export const listProjects = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.handler(async ({ context }) => {
try {
return await KeywordResearchService.listProjects(context.userId);
} catch (error) {
logServerError("projects.list", error, { userId: context.userId });
throw toClientError(error);
}
});
export const createProject = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => createProjectSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await KeywordResearchService.createProject(context.userId, data);
} catch (error) {
logServerError("projects.create", error, { userId: context.userId });
throw toClientError(error);
}
});
export const deleteProject = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => deleteProjectSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await KeywordResearchService.deleteProject(context.userId, data);
} catch (error) {
logServerError("projects.delete", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});
export const saveKeywords = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => saveKeywordsSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await KeywordResearchService.saveKeywords(context.userId, data);
} catch (error) {
logServerError("keywords.save", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});
export const getSavedKeywords = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => getSavedKeywordsSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await KeywordResearchService.getSavedKeywords(
context.userId,
data,
);
} catch (error) {
logServerError("keywords.saved.list", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});
export const removeSavedKeyword = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => removeSavedKeywordSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await KeywordResearchService.removeSavedKeyword(
context.userId,
data,
);
} catch (error) {
logServerError("keywords.saved.remove", error, {
userId: context.userId,
savedKeywordId: data.savedKeywordId,
});
throw toClientError(error);
}
});
export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.handler(async ({ context }) => {
try {
return await KeywordResearchService.getOrCreateDefaultProject(
context.userId,
);
} catch (error) {
logServerError("projects.get-or-create-default", error, {
userId: context.userId,
});
throw toClientError(error);
}
});
export const getSerpAnalysis = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => serpAnalysisSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await KeywordResearchService.getSerpAnalysis(data);
} catch (error) {
logServerError("keywords.serp-analysis", error, {
userId: context.userId,
keyword: data.keyword,
});
throw toClientError(error);
}
});
const getProjectSchema = z.object({
projectId: z.string().min(1),
});
export const getProject = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => getProjectSchema.parse(data))
.handler(async ({ data, context }) => {
try {
return await KeywordResearchService.getProject(
context.userId,
data.projectId,
);
} catch (error) {
logServerError("projects.get", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});

405
src/serverFunctions/psi.ts Normal file
View File

@ -0,0 +1,405 @@
import { createServerFn } from "@tanstack/react-start";
import { ensureUserMiddleware } from "@/middleware/ensureUser";
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
import {
psiAuditSchema,
psiAuditListSchema,
psiAuditDetailsSchema,
psiIssueFilterSchema,
psiExportSchema,
psiUnifiedIssueSchema,
psiUnifiedExportSchema,
psiProjectKeySchema,
psiProjectSchema,
} from "@/types/schemas/psi";
import { PsiService } from "@/server/services/PsiService";
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository";
import { PsiAuditRepository } from "@/server/repositories/PsiAuditRepository";
import { AuditRepository } from "@/server/repositories/AuditRepository";
import { getJsonFromR2, putJsonToR2 } from "@/server/lib/r2";
import { PsiIssuesService } from "@/server/services/PsiIssuesService";
async function resolvePsiSource(input: {
projectId: string;
userId: string;
source: "single" | "site";
resultId: string;
}) {
if (input.source === "single") {
const row = await PsiAuditRepository.getAuditResult({
auditId: input.resultId,
projectId: input.projectId,
userId: input.userId,
});
if (!row) {
throw new Error("Audit not found");
}
return {
id: row.id,
strategy: row.strategy,
finalUrl: row.finalUrl,
createdAt: row.createdAt,
r2Key: row.r2Key,
};
}
const site = await AuditRepository.getPsiResultById({
psiResultId: input.resultId,
projectId: input.projectId,
userId: input.userId,
});
if (!site) {
throw new Error("Audit not found");
}
return {
id: site.psi.id,
strategy: site.psi.strategy,
finalUrl: site.page?.url ?? "",
createdAt: site.audit.startedAt,
r2Key: site.psi.r2Key,
};
}
export const runPsiAudit = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => psiAuditSchema.parse(data))
.handler(async ({ data, context }) => {
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
data.projectId,
context.userId,
);
if (!apiKey) {
throw new Error(
"PSI API key is not set for this project. Save a key first.",
);
}
const auditId = crypto.randomUUID();
try {
const result = await PsiService.runAudit({
url: data.url,
strategy: data.strategy,
apiKey,
});
const now = new Date();
const datePrefix = now.toISOString().slice(0, 10);
const key = `psi/${data.projectId}/${datePrefix}/${auditId}.json`;
const uploaded = await putJsonToR2(key, result.rawPayload);
await PsiAuditRepository.createAuditResult({
id: auditId,
projectId: data.projectId,
requestedUrl: result.requestedUrl,
finalUrl: result.finalUrl,
strategy: result.strategy,
status: "completed",
performanceScore: result.scores.performance,
accessibilityScore: result.scores.accessibility,
bestPracticesScore: result.scores["best-practices"],
seoScore: result.scores.seo,
firstContentfulPaint: result.metrics.firstContentfulPaint.displayValue,
largestContentfulPaint:
result.metrics.largestContentfulPaint.displayValue,
totalBlockingTime: result.metrics.totalBlockingTime.displayValue,
cumulativeLayoutShift:
result.metrics.cumulativeLayoutShift.displayValue,
speedIndex: result.metrics.speedIndex.displayValue,
timeToInteractive: result.metrics.timeToInteractive.displayValue,
lighthouseVersion: result.lighthouseVersion,
r2Key: uploaded.key,
payloadSizeBytes: uploaded.sizeBytes,
});
return {
auditId,
requestedUrl: result.requestedUrl,
finalUrl: result.finalUrl,
strategy: result.strategy,
fetchedAt: result.fetchedAt,
lighthouseVersion: result.lighthouseVersion,
scores: result.scores,
metrics: result.metrics,
};
} catch (error) {
const requestedUrl = data.url.trim();
const message =
error instanceof Error ? error.message : "PSI request failed";
await PsiAuditRepository.createAuditResult({
id: auditId,
projectId: data.projectId,
requestedUrl,
finalUrl: requestedUrl,
strategy: data.strategy,
status: "failed",
errorMessage: message,
});
throw error;
}
});
export const getProjectPsiApiKey = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
.handler(async ({ data, context }) => {
// This PSI key is intentionally treated as low-sensitivity operational config
// (Google abuse-control), not a direct billing secret.
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
data.projectId,
context.userId,
);
return { apiKey };
});
export const saveProjectPsiApiKey = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => psiProjectKeySchema.parse(data))
.handler(async ({ data, context }) => {
// Same tradeoff: persisted for convenience across PSI + Site Audit flows.
await KeywordResearchRepository.setProjectPsiApiKey(
data.projectId,
context.userId,
data.apiKey.trim(),
);
return { success: true };
});
export const clearProjectPsiApiKey = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
.handler(async ({ data, context }) => {
await KeywordResearchRepository.clearProjectPsiApiKey(
data.projectId,
context.userId,
);
return { success: true };
});
export const listProjectPsiAudits = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => psiAuditListSchema.parse(data))
.handler(async ({ data, context }) => {
const rows = await PsiAuditRepository.listAuditResults({
projectId: data.projectId,
userId: context.userId,
strategy: data.strategy,
limit: data.limit,
});
return {
rows: rows.map((row) => ({
id: row.id,
requestedUrl: row.requestedUrl,
finalUrl: row.finalUrl,
strategy: row.strategy,
status: row.status,
performanceScore: row.performanceScore,
accessibilityScore: row.accessibilityScore,
bestPracticesScore: row.bestPracticesScore,
seoScore: row.seoScore,
firstContentfulPaint: row.firstContentfulPaint,
largestContentfulPaint: row.largestContentfulPaint,
totalBlockingTime: row.totalBlockingTime,
cumulativeLayoutShift: row.cumulativeLayoutShift,
speedIndex: row.speedIndex,
timeToInteractive: row.timeToInteractive,
lighthouseVersion: row.lighthouseVersion,
errorMessage: row.errorMessage,
payloadSizeBytes: row.payloadSizeBytes,
createdAt: row.createdAt,
})),
};
});
export const getProjectPsiAuditRaw = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => psiAuditDetailsSchema.parse(data))
.handler(async ({ data, context }) => {
const row = await PsiAuditRepository.getAuditResult({
auditId: data.auditId,
projectId: data.projectId,
userId: context.userId,
});
if (!row) {
throw new Error("Audit not found");
}
if (!row.r2Key) {
throw new Error("Audit payload not available");
}
const payloadJson = await getJsonFromR2(row.r2Key);
return {
id: row.id,
strategy: row.strategy,
finalUrl: row.finalUrl,
createdAt: row.createdAt,
payloadJson,
};
});
export const getProjectPsiAuditIssues = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => psiIssueFilterSchema.parse(data))
.handler(async ({ data, context }) => {
const row = await PsiAuditRepository.getAuditResult({
auditId: data.auditId,
projectId: data.projectId,
userId: context.userId,
});
if (!row) {
throw new Error("Audit not found");
}
if (!row.r2Key) {
throw new Error("Audit payload not available");
}
const payloadJson = await getJsonFromR2(row.r2Key);
const issues = PsiIssuesService.parseIssues(payloadJson, data.category);
return {
id: row.id,
finalUrl: row.finalUrl,
strategy: row.strategy,
createdAt: row.createdAt,
issues,
};
});
export const exportProjectPsiAudit = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => psiExportSchema.parse(data))
.handler(async ({ data, context }) => {
const row = await PsiAuditRepository.getAuditResult({
auditId: data.auditId,
projectId: data.projectId,
userId: context.userId,
});
if (!row) {
throw new Error("Audit not found");
}
if (!row.r2Key) {
throw new Error("Audit payload not available");
}
const payloadJson = await getJsonFromR2(row.r2Key);
const safeDate = row.createdAt.replace(/[:.]/g, "-");
const baseName = `psi-${row.strategy}-${safeDate}`;
if (data.mode === "full") {
return {
filename: `${baseName}-full.json`,
content: payloadJson,
};
}
const category = data.mode === "category" ? data.category : undefined;
const issues = PsiIssuesService.parseIssues(payloadJson, category);
return {
filename:
data.mode === "category" && category
? `${baseName}-${category}-issues.json`
: `${baseName}-issues.json`,
content: JSON.stringify(
{
auditId: row.id,
finalUrl: row.finalUrl,
strategy: row.strategy,
createdAt: row.createdAt,
category: category ?? "all",
issues,
},
null,
2,
),
};
});
export const getPsiIssuesBySource = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => psiUnifiedIssueSchema.parse(data))
.handler(async ({ data, context }) => {
const target = await resolvePsiSource({
projectId: data.projectId,
userId: context.userId,
source: data.source,
resultId: data.resultId,
});
if (!target.r2Key) {
throw new Error("Audit payload not available");
}
const payloadJson = await getJsonFromR2(target.r2Key);
const issues = PsiIssuesService.parseIssues(payloadJson, data.category);
return {
id: target.id,
finalUrl: target.finalUrl,
strategy: target.strategy,
createdAt: target.createdAt,
issues,
};
});
export const exportPsiBySource = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
.inputValidator((data: unknown) => psiUnifiedExportSchema.parse(data))
.handler(async ({ data, context }) => {
const target = await resolvePsiSource({
projectId: data.projectId,
userId: context.userId,
source: data.source,
resultId: data.resultId,
});
if (!target.r2Key) {
throw new Error("Audit payload not available");
}
const payloadJson = await getJsonFromR2(target.r2Key);
const safeDate = target.createdAt.replace(/[:.]/g, "-");
const baseName = `psi-${target.strategy}-${safeDate}`;
if (data.mode === "full") {
return {
filename: `${baseName}-full.json`,
content: payloadJson,
};
}
const category = data.mode === "category" ? data.category : undefined;
const issues = PsiIssuesService.parseIssues(payloadJson, category);
return {
filename:
data.mode === "category" && category
? `${baseName}-${category}-issues.json`
: `${baseName}-issues.json`,
content: JSON.stringify(
{
resultId: target.id,
finalUrl: target.finalUrl,
strategy: target.strategy,
createdAt: target.createdAt,
category: category ?? "all",
issues,
},
null,
2,
),
};
});

16
src/shared/error-codes.ts Normal file
View File

@ -0,0 +1,16 @@
export const ERROR_CODES = [
"UNAUTHENTICATED",
"FORBIDDEN",
"NOT_FOUND",
"VALIDATION_ERROR",
"CRAWL_TARGET_BLOCKED",
"RATE_LIMITED",
"CONFLICT",
"INTERNAL_ERROR",
] as const;
export type ErrorCode = (typeof ERROR_CODES)[number];
export function isErrorCode(value: string): value is ErrorCode {
return (ERROR_CODES as readonly string[]).includes(value);
}

52
src/types/keywords.ts Normal file
View File

@ -0,0 +1,52 @@
export type KeywordIntent =
| "informational"
| "commercial"
| "transactional"
| "navigational"
| "unknown";
export type MonthlySearch = {
year: number;
month: number;
searchVolume: number;
};
export type KeywordResearchRow = {
keyword: string;
searchVolume: number | null;
trend: MonthlySearch[];
keywordDifficulty: number | null;
cpc: number | null;
competition: number | null;
intent: KeywordIntent;
};
export type SavedKeywordRow = {
id: string;
projectId: string;
keyword: string;
locationCode: number;
languageCode: string;
createdAt: string;
searchVolume: number | null;
cpc: number | null;
competition: number | null;
keywordDifficulty: number | null;
intent: string | null;
monthlySearches: MonthlySearch[];
fetchedAt: string | null;
};
export type SerpResultItem = {
rank: number;
title: string;
url: string;
domain: string;
description: string;
etv: number | null;
estimatedPaidTrafficCost: number | null;
referringDomains: number | null;
backlinks: number | null;
isNew: boolean;
rankChange: number | null;
};

View File

@ -0,0 +1,57 @@
import { z } from "zod";
// ─── Server function input schemas ──────────────────────────────────────────
export const startAuditSchema = z.object({
projectId: z.string().min(1),
startUrl: z.string().min(1, "URL is required").max(2048),
maxPages: z.number().int().min(10).max(10_000).optional().default(50),
psiStrategy: z
.enum(["auto", "all", "manual", "none"])
.optional()
.default("auto"),
psiApiKey: z.string().optional(),
});
export type StartAuditInput = z.infer<typeof startAuditSchema>;
export const getAuditStatusSchema = z.object({
auditId: z.string().min(1),
});
export type GetAuditStatusInput = z.infer<typeof getAuditStatusSchema>;
export const getAuditResultsSchema = z.object({
auditId: z.string().min(1),
});
export type GetAuditResultsInput = z.infer<typeof getAuditResultsSchema>;
export const getAuditHistorySchema = z.object({
projectId: z.string().min(1),
});
export type GetAuditHistoryInput = z.infer<typeof getAuditHistorySchema>;
export const deleteAuditSchema = z.object({
auditId: z.string().min(1),
});
export type DeleteAuditInput = z.infer<typeof deleteAuditSchema>;
export const getCrawlProgressSchema = z.object({
auditId: z.string().min(1),
});
export type GetCrawlProgressInput = z.infer<typeof getCrawlProgressSchema>;
// ─── URL search params schema for /p/$projectId/audit ────────────────────────
const auditTabs = ["pages", "performance"] as const;
export const auditSearchSchema = z.object({
auditId: z.string().optional().catch(undefined),
tab: z.enum(auditTabs).catch("pages").default("pages"),
});
export type AuditSearchParams = z.infer<typeof auditSearchSchema>;

View File

@ -0,0 +1,25 @@
import { z } from "zod";
export const domainOverviewSchema = z.object({
domain: z.string().min(1, "Domain is required").max(255),
includeSubdomains: z.boolean().default(true),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
});
/* ------------------------------------------------------------------ */
/* URL search params schema for /p/$projectId/domain */
/* ------------------------------------------------------------------ */
const domainSortModes = ["rank", "traffic", "volume"] as const;
const domainSortOrders = ["asc", "desc"] as const;
const domainTabs = ["keywords", "pages"] as const;
export const domainSearchSchema = z.object({
domain: z.string().optional(),
subdomains: z.coerce.boolean().optional(),
sort: z.enum(domainSortModes).optional(),
order: z.enum(domainSortOrders).optional(),
tab: z.enum(domainTabs).optional(),
search: z.string().optional(),
});

View File

@ -0,0 +1,115 @@
import { z } from "zod";
export const researchKeywordsSchema = z.object({
keywords: z.array(z.string().min(1)).min(1).max(200),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
resultLimit: z
.union([z.literal(150), z.literal(300), z.literal(500)])
.default(150),
mode: z.literal("related").optional().default("related"),
});
export const createProjectSchema = z.object({
name: z.string().min(1, "Project name is required").max(120),
domain: z.string().max(255).optional(),
});
export const deleteProjectSchema = z.object({
projectId: z.string().min(1),
});
export const saveKeywordsSchema = z.object({
projectId: z.string().min(1),
keywords: z.array(z.string().min(1)).min(1).max(200),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
metrics: z
.array(
z.object({
keyword: z.string().min(1),
searchVolume: z.number().int().nonnegative().nullable().optional(),
cpc: z.number().nonnegative().nullable().optional(),
competition: z.number().min(0).max(1).nullable().optional(),
keywordDifficulty: z
.number()
.int()
.min(0)
.max(100)
.nullable()
.optional(),
intent: z
.enum([
"informational",
"commercial",
"transactional",
"navigational",
"unknown",
])
.nullable()
.optional(),
monthlySearches: z
.array(
z.object({
year: z.number().int().positive(),
month: z.number().int().min(1).max(12),
searchVolume: z.number().int().nonnegative(),
}),
)
.optional(),
}),
)
.max(200)
.optional(),
});
export const removeSavedKeywordSchema = z.object({
savedKeywordId: z.string().min(1),
});
export const getSavedKeywordsSchema = z.object({
projectId: z.string().min(1),
});
export type ResearchKeywordsInput = z.infer<typeof researchKeywordsSchema>;
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>;
export type SaveKeywordsInput = z.infer<typeof saveKeywordsSchema>;
export type RemoveSavedKeywordInput = z.infer<typeof removeSavedKeywordSchema>;
export const serpAnalysisSchema = z.object({
keyword: z.string().min(1),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
});
export type GetSavedKeywordsInput = z.infer<typeof getSavedKeywordsSchema>;
/* ------------------------------------------------------------------ */
/* URL search params schema for /p/$projectId/keywords */
/* ------------------------------------------------------------------ */
const keywordSortFields = [
"keyword",
"searchVolume",
"cpc",
"competition",
"keywordDifficulty",
] as const;
const sortDirs = ["asc", "desc"] as const;
export const keywordsSearchSchema = z.object({
q: z.string().optional(),
loc: z.coerce.number().int().positive().optional(),
kLimit: z.union([z.literal(150), z.literal(300), z.literal(500)]).optional(),
sort: z.enum(keywordSortFields).optional(),
order: z.enum(sortDirs).optional(),
minVol: z.string().optional(),
maxVol: z.string().optional(),
minCpc: z.string().optional(),
maxCpc: z.string().optional(),
minKd: z.string().optional(),
maxKd: z.string().optional(),
include: z.string().optional(),
exclude: z.string().optional(),
});

81
src/types/schemas/psi.ts Normal file
View File

@ -0,0 +1,81 @@
import { z } from "zod";
const psiStrategies = ["mobile", "desktop"] as const;
const psiCategories = [
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
const psiSources = ["single", "site"] as const;
export const psiAuditSchema = z.object({
projectId: z.string().min(1, "Project is required"),
url: z.string().min(1, "URL is required").max(2048),
strategy: z.enum(psiStrategies).default("mobile"),
});
export type PsiAuditInput = z.infer<typeof psiAuditSchema>;
export const psiSearchSchema = z.object({
url: z.string().catch("").default(""),
strategy: z.enum(psiStrategies).catch("mobile").default("mobile"),
});
export type PsiSearchParams = z.infer<typeof psiSearchSchema>;
export const psiProjectKeySchema = z.object({
projectId: z.string().min(1, "Project is required"),
apiKey: z.string().min(1, "API key is required").max(512),
});
export const psiProjectSchema = z.object({
projectId: z.string().min(1, "Project is required"),
});
export const psiAuditListSchema = z.object({
projectId: z.string().min(1, "Project is required"),
strategy: z.enum(psiStrategies).optional(),
limit: z.number().int().min(1).max(200).default(50),
});
export const psiAuditDetailsSchema = z.object({
projectId: z.string().min(1, "Project is required"),
auditId: z.string().min(1, "Audit is required"),
});
export const psiIssueFilterSchema = z.object({
projectId: z.string().min(1, "Project is required"),
auditId: z.string().min(1, "Audit is required"),
category: z.enum(psiCategories).optional(),
});
export const psiExportSchema = z.object({
projectId: z.string().min(1, "Project is required"),
auditId: z.string().min(1, "Audit is required"),
mode: z.enum(["full", "issues", "category"]),
category: z.enum(psiCategories).optional(),
});
export const psiUnifiedIssueSchema = z.object({
projectId: z.string().min(1, "Project is required"),
source: z.enum(psiSources),
resultId: z.string().min(1, "Result id is required"),
category: z.enum(psiCategories).optional(),
});
export const psiUnifiedExportSchema = z.object({
projectId: z.string().min(1, "Project is required"),
source: z.enum(psiSources),
resultId: z.string().min(1, "Result id is required"),
mode: z.enum(["full", "issues", "category"]),
category: z.enum(psiCategories).optional(),
});
export const psiIssuesSearchSchema = z.object({
source: z.enum(psiSources).catch("single").default("single"),
category: z
.enum(["all", ...psiCategories])
.catch("all")
.default("all"),
});

16
src/types/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
/// <reference types="vite/client" />
interface ViteTypeOptions {
// By adding this line, you can make the type of ImportMetaEnv strict
// to disallow unknown keys.
strictImportMetaEnv: unknown;
}
interface ImportMetaEnv {
readonly VITE_GATEWAY_URL: string;
readonly VITE_APP_ID: string;
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"isolatedModules": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"target": "ES2022",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"noEmit": true
}
}

32
vite.config.ts Normal file
View File

@ -0,0 +1,32 @@
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { defineConfig, loadEnv } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";
import viteReact from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { devtools } from "@tanstack/devtools-vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const port = env.PORT ? Number(env.PORT) : 3001;
return {
envPrefix: ["VITE_", "BYPASS_GATEWAY_LOCAL_ONLY"],
server: {
port,
},
plugins: [
devtools({
consolePiping: {
enabled: true,
levels: ["log", "warn", "error", "info", "debug"],
},
}),
cloudflare({ viteEnvironment: { name: "ssr" } }),
tsConfigPaths(),
tanstackStart(),
viteReact(),
tailwindcss(),
],
};
});

Some files were not shown because too many files have changed in this diff Show More