Initial public release
11
.env.example
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -0,0 +1,3 @@
|
|||||||
|
.env.local
|
||||||
|
.wrangler
|
||||||
|
node_modules/
|
||||||
7
EveryAppLearnings.md
Normal 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
@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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, DataForSEO’s 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
@ -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
@ -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
|
||||||
|
},
|
||||||
|
});
|
||||||
146
drizzle/0000_fantastic_vanisher.sql
Normal 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`);
|
||||||
11
drizzle/0001_round_unus.sql
Normal 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`);
|
||||||
1047
drizzle/meta/0000_snapshot.json
Normal file
1076
drizzle/meta/0001_snapshot.json
Normal file
20
drizzle/meta/_journal.json
Normal 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
@ -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
@ -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
@ -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
@ -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
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 832 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
19
public/site.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
48
src/client/components/DefaultCatchBoundary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/client/components/NotFound.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/client/components/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
666
src/client/features/keywords/components.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/client/features/keywords/utils.ts
Normal 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}"`;
|
||||||
|
}
|
||||||
121
src/client/hooks/useDomainSearchHistory.ts
Normal 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 };
|
||||||
|
}
|
||||||
92
src/client/hooks/useSearchHistory.ts
Normal 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 };
|
||||||
|
}
|
||||||
21
src/client/lib/error-messages.ts
Normal 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;
|
||||||
|
}
|
||||||
34
src/client/navigation/items.ts
Normal 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
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/client/styles/view-transitions.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/client/tanstack-db/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { queryClient } from "./queryClient";
|
||||||
10
src/client/tanstack-db/queryClient.ts
Normal 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
@ -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
@ -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
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/middleware/ensureUser.ts
Normal 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
@ -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
@ -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
@ -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
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/routes/p/$projectId/ai.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/routes/p/$projectId/audit.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
1446
src/routes/p/$projectId/audit/index.tsx
Normal file
554
src/routes/p/$projectId/audit/issues/$resultId.tsx
Normal 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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
← 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" />;
|
||||||
|
}
|
||||||
1201
src/routes/p/$projectId/domain.tsx
Normal file
10
src/routes/p/$projectId/index.tsx
Normal 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 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
1559
src/routes/p/$projectId/keywords.tsx
Normal file
554
src/routes/p/$projectId/psi/issues/$resultId.tsx
Normal 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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
← 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" />;
|
||||||
|
}
|
||||||
44
src/routes/p/$projectId/route.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
238
src/routes/p/$projectId/saved.tsx
Normal 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'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
@ -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,
|
||||||
|
};
|
||||||
243
src/server/lib/audit/discovery.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
131
src/server/lib/audit/page-analyzer.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/server/lib/audit/progress-kv.ts
Normal 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
@ -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);
|
||||||
|
}
|
||||||
69
src/server/lib/audit/types.ts
Normal 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;
|
||||||
|
}
|
||||||
220
src/server/lib/audit/url-policy.ts
Normal 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();
|
||||||
|
}
|
||||||
133
src/server/lib/audit/url-utils.ts
Normal 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;
|
||||||
|
}
|
||||||
404
src/server/lib/dataforseo.ts
Normal 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
@ -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));
|
||||||
|
}
|
||||||
64
src/server/lib/kv-cache.ts
Normal 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
@ -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
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
51
src/server/lib/serverFnErrorBoundary.ts
Normal 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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
308
src/server/repositories/AuditRepository.ts
Normal 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;
|
||||||
192
src/server/repositories/KeywordResearchRepository.ts
Normal 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;
|
||||||
102
src/server/repositories/PsiAuditRepository.ts
Normal 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;
|
||||||
184
src/server/services/AuditService.ts
Normal 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;
|
||||||
219
src/server/services/DomainService.ts
Normal 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;
|
||||||
577
src/server/services/KeywordResearchService.ts
Normal 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;
|
||||||
192
src/server/services/PsiIssuesService.ts
Normal 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;
|
||||||
170
src/server/services/PsiService.ts
Normal 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;
|
||||||
25
src/server/services/keyword-research/helpers.ts
Normal 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";
|
||||||
|
}
|
||||||
65
src/server/services/keyword-research/projects.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
61
src/server/services/keyword-research/research-data.ts
Normal 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;
|
||||||
|
}
|
||||||
109
src/server/services/keyword-research/saved-keywords.ts
Normal 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 };
|
||||||
|
}
|
||||||
69
src/server/services/keyword-research/serp.ts
Normal 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;
|
||||||
|
}
|
||||||
540
src/server/workflows/SiteAuditWorkflow.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/serverFunctions/audit.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
22
src/serverFunctions/domain.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
168
src/serverFunctions/keywords.ts
Normal 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
@ -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
@ -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
@ -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;
|
||||||
|
};
|
||||||
57
src/types/schemas/audit.ts
Normal 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>;
|
||||||
25
src/types/schemas/domain.ts
Normal 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(),
|
||||||
|
});
|
||||||
115
src/types/schemas/keywords.ts
Normal 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
@ -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
@ -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
@ -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
@ -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(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||