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(),
|
||||
],
|
||||
};
|
||||
});
|
||||