Fetcha v0.2.0 — private access (single unlimited plan, invitation-only signup, /admin/access), managed browser (Patchright, auto-escalation), hardened HTTP fingerprints (ordered headers, HTTP/2, TLS profiles, cookie jar, backoff), expanded block detection, markdown/page/links, crawl & map API, dashboard Crawls, SDK 0.2.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
117 changed files +11,929 −1,177
modified
.env.example
+11 −0
@@ -32,3 +32,14 @@ SOAX_PASSWORD= | ||
| 32 | 32 | |
| 33 | 33 | # ---- Admin ---- |
| 34 | 34 | ADMIN_EMAILS=you@example.com |
| 35 | + | |
| 36 | +# --- v0.2: managed browser, HTTP engine, crawler --------------------------- | |
| 37 | +# Managed Chromium (Playwright). Install the browser once: `pnpm --filter @fetcha/browser install-browser`. | |
| 38 | +FETCHA_BROWSER_ENABLED=1 | |
| 39 | +FETCHA_BROWSER_CONCURRENCY=6 | |
| 40 | +# "chromium" = full Chromium build in new headless mode (best anti-bot realism); "chrome" uses an installed Google Chrome. | |
| 41 | +FETCHA_BROWSER_CHANNEL=chromium | |
| 42 | +# Prefer HTTP/2 towards origins (set 0 to force HTTP/1.1). | |
| 43 | +FETCHA_HTTP2=1 | |
| 44 | +# Crawl jobs executed in parallel by this API process. | |
| 45 | +FETCHA_CRAWL_PARALLEL_JOBS=4 | |
modified
CLAUDE.md
+15 −5
@@ -2,15 +2,20 @@ | ||
| 2 | 2 | |
| 3 | 3 | Fetcha (www.fetcha.co) is "Intelligent Web Access Infrastructure": one API (`POST /v1/fetch`) that routes web requests |
| 4 | 4 | across upstream proxy networks (Oxylabs, Decodo, SOAX — never exposed to customers) with smart routing, retries, |
| 5 | −sticky sessions, usage metering and a full dashboard. | |
| 5 | +sticky sessions, a managed headless browser (`browser: true` + automatic escalation), crawl/map jobs, usage metering | |
| 6 | +and a full dashboard. **Private platform (v0.2, 2026-09-08)**: a single `unlimited` plan, invitation-only signup | |
| 7 | +(`signup_allowlist`, managed from `/admin/access`), `ADMIN_EMAILS` become admins. No billing. | |
| 6 | 8 | |
| 7 | 9 | ## Layout |
| 8 | 10 | - `apps/web` — Next.js 16 (marketing site, docs, auth, customer dashboard, admin). Port 8220. Proxies `/v1/*` to the API. |
| 9 | 11 | - `apps/api` — Fastify API service (public `/v1/*`, `/health`, `/ready`, internal `/internal/*` for the dashboard). Port 8221. |
| 10 | 12 | - `packages/core` — zod schemas, error codes, ids/api-key hashing, SSRF policy, geo model, plan limits. |
| 11 | 13 | - `packages/db` — Drizzle schema + migrations (`drizzle/`), `pnpm db:generate|migrate|seed`. |
| 12 | −- `packages/providers` — `ProxyProvider` interface + adapters (oxylabs, decodo, soax, direct). Provider code lives ONLY here. | |
| 13 | −- `packages/routing` — circuit breaker, routing score, `FetchExecutor` (attempt loop, block detection, escalation). | |
| 14 | +- `packages/providers` — `ProxyProvider` interface + adapters (oxylabs, decodo, soax, direct), `http.ts` (undici, HTTP/2, browser-ordered headers, Chrome/Firefox/Safari TLS cipher lists, h2→h1 fallback), `fingerprint.ts` (header profiles), `cookies.ts` (jar across hops / sessions). Provider code lives ONLY here. | |
| 15 | +- `packages/routing` — circuit breaker, routing score, `FetchExecutor` (attempt loop, fingerprint rotation, jittered backoff, block detection, HTTP → browser escalation). | |
| 16 | +- `packages/browser` — `BrowserPool` (Patchright = patched Playwright, full Chromium new-headless, per-context upstream proxy, stealth init script, challenge wait + Turnstile click). Install the browser once: `pnpm --filter @fetcha/browser install-browser`. | |
| 17 | +- `packages/core/src/markdown.ts` — HTML → Markdown / main text, page metadata + links, URL normalisation, glob/regex matchers; `robots.ts` — robots.txt + sitemap parsing. | |
| 18 | +- `apps/api/src/services/crawl.ts` — durable crawl jobs (`crawl_jobs`/`crawl_pages`, in-process worker, resume after restart) and the sync `/v1/map`. | |
| 14 | 19 | - `packages/email` — Resend abstraction + React Email templates. `packages/sdk` (JS), `sdk-python/` (Python). |
| 15 | 20 | - `docs/AGENT-BRIEF.md` — product/engineering rules used when generating UI. |
| 16 | 21 | |
@@ -19,7 +24,9 @@ sticky sessions, usage metering and a full dashboard. | ||
| 19 | 24 | - Every external URL goes through `assertUrlAllowed()` (SSRF) — including redirects. |
| 20 | 25 | - Business logic in server actions / `apps/api` / packages, not in React components. |
| 21 | 26 | - API keys: shown once, SHA-256 stored. Request IDs `req_…` on every response (`X-Fetcha-Request-ID`). |
| 22 | −- Not implemented yet (say so, don't fake): browser mode, `/v1/extract`, Stripe checkout, teams, webhook delivery, OAuth, 2FA. | |
| 27 | +- Not implemented yet (say so, don't fake): scripted browser actions (`POST /v1/browser`), `/v1/extract`, teams, webhook delivery (except the crawl `webhook_url` callback), OAuth, 2FA. Billing/Stripe is intentionally absent (private platform). | |
| 28 | +- Plans: always `normalizePlan(org.plan)`; never reintroduce tiers. Signup must stay allowlist-gated (Better Auth `user.create.before` hook in `apps/web/src/lib/auth.ts`). | |
| 29 | +- Anti-bot honesty: interactive Cloudflare Turnstile challenges are NOT reliably solved (tested 2026-09-08 with Patchright + residential + headful); non-interactive JS challenges, DataDome/PX/Akamai soft blocks usually pass via residential + browser escalation. | |
| 23 | 30 | |
| 24 | 31 | ## Dev |
| 25 | 32 | `cp .env.example .env` (fill provider creds + RESEND_API_KEY), `createdb fetcha`, `pnpm db:migrate && pnpm db:seed`, |
@@ -27,4 +34,7 @@ sticky sessions, usage metering and a full dashboard. | ||
| 27 | 34 | |
| 28 | 35 | ## Deploy (MacLustr) |
| 29 | 36 | Deployed by `mld` (gateway M1M32) on M3U96a: web 8220 + api 8221, Postgres 17 `fetcha` + Redis local, ngrok `www.fetcha.co`. |
| 30 | −Manifest: `deploy/fetcha.mld.json` → `M1M32:~/dispatch/apps/fetcha.json`. See `deploy/README.md`. | |
| 37 | +Manifest: `deploy/fetcha.mld.json` → `M1M32:~/dispatch/apps/fetcha.json`. See `deploy/README.md`. Post-sync hooks: `pnpm install`, | |
| 38 | +`db:migrate` + `db:seed` (with `ADMIN_EMAILS`), `patchright install chromium`, `next build`. Env knobs: `FETCHA_BROWSER_ENABLED`, | |
| 39 | +`FETCHA_BROWSER_CONCURRENCY`, `FETCHA_BROWSER_CHANNEL`, `FETCHA_BROWSER_HEADLESS`, `FETCHA_HTTP2`, `FETCHA_CRAWL_PARALLEL_JOBS`. | |
| 40 | +The `browser_enabled` feature flag (admin → Flags) is the global kill switch for browser mode. | |
modified
README.md
+4 −2
@@ -35,7 +35,9 @@ pnpm dev:web # http://localhost:8220 | ||
| 35 | 35 | ``` |
| 36 | 36 | Tests: `pnpm test` · Typecheck: `pnpm typecheck` · Build: `pnpm build`. |
| 37 | 37 | |
| 38 | −## Status (v0.1.0 — public preview) | |
| 38 | +## Status (v0.2.0 — private platform) | |
| 39 | 39 | Live: Fetch API, auto routing (residential network class), sticky sessions, Playground, request logs, usage metering, |
| 40 | −API keys, projects, admin console, docs, SDK source. Coming: managed browser, structured extraction, Stripe checkout, | |
| 40 | +API keys, projects, admin console, docs, SDK source, managed browser rendering (`browser: true`, automatic escalation), | |
| 41 | +`format: "markdown"` + page metadata/links, crawl & map jobs (`/v1/crawl`, `/v1/map`). Private platform: invitation-only | |
| 42 | +signup, one unlimited plan, no billing. Coming: scripted browser actions, structured extraction, | |
| 41 | 43 | teams, webhooks. See `/changelog`. |
modified
apps/api/package.json
+1 −0
@@ -11,6 +11,7 @@ | ||
| 11 | 11 | }, |
| 12 | 12 | "dependencies": { |
| 13 | 13 | "@fastify/cors": "^11.0.0", |
| 14 | + "@fetcha/browser": "workspace:*", | |
| 14 | 15 | "@fetcha/core": "workspace:*", |
| 15 | 16 | "@fetcha/db": "workspace:*", |
| 16 | 17 | "@fetcha/email": "workspace:*", |
modified
apps/api/src/auth.ts
+63 −3
@@ -1,4 +1,4 @@ | ||
| 1 | −import { FetchaError, hashApiKey, parseApiKeyMode, safeEqual, type ApiKeyScope, type Plan } from "@fetcha/core"; | |
| 1 | +import { FetchaError, hashApiKey, normalizePlan, parseApiKeyMode, safeEqual, type ApiKeyScope, type Plan } from "@fetcha/core"; | |
| 2 | 2 | import { apiKeys, db, eq, organizations, projects, sql, users } from "@fetcha/db"; |
| 3 | 3 | import { config } from "./config"; |
| 4 | 4 | import { getKV } from "./redis"; |
@@ -74,7 +74,7 @@ async function loadPrincipalByKeyHash(hash: string): Promise<ApiPrincipal | null | ||
| 74 | 74 | scopes: (row.scopes ?? []) as ApiKeyScope[], |
| 75 | 75 | organizationId: row.organizationId, |
| 76 | 76 | organizationName: row.organizationName, |
| 77 | − plan: row.plan as Plan, | |
| 77 | + plan: normalizePlan(row.plan), | |
| 78 | 78 | providerVisibility: row.providerVisibility, |
| 79 | 79 | suspended: row.suspended, |
| 80 | 80 | orgSoftLimitUsd: row.orgSoft, |
@@ -172,7 +172,67 @@ export async function principalForProject(projectId: string, userId: string): Pr | ||
| 172 | 172 | scopes: ["fetch:execute", "sessions:write", "usage:read"], |
| 173 | 173 | organizationId: row.organizationId, |
| 174 | 174 | organizationName: row.organizationName, |
| 175 | − plan: row.plan as Plan, | |
| 175 | + plan: normalizePlan(row.plan), | |
| 176 | + providerVisibility: row.providerVisibility, | |
| 177 | + suspended: row.suspended, | |
| 178 | + orgSoftLimitUsd: row.orgSoft, | |
| 179 | + orgHardLimitUsd: row.orgHard, | |
| 180 | + projectId: row.projectId, | |
| 181 | + projectName: row.projectName, | |
| 182 | + projectLogLevel: row.logLevel as ApiPrincipal["projectLogLevel"], | |
| 183 | + projectSoftLimitUsd: row.projSoft, | |
| 184 | + projectHardLimitUsd: row.projHard, | |
| 185 | + projectMonthlyRequestLimit: row.projMonthly, | |
| 186 | + ownerEmail: row.ownerEmail, | |
| 187 | + ownerVerified: row.ownerVerified, | |
| 188 | + internal: true, | |
| 189 | + }; | |
| 190 | +} | |
| 191 | + | |
| 192 | +/** Principal for a background crawl job: the project's owner context, with the originating key when still valid. */ | |
| 193 | +export async function principalForCrawl(projectId: string, apiKeyId: string | null): Promise<ApiPrincipal> { | |
| 194 | + const [row] = await db | |
| 195 | + .select({ | |
| 196 | + organizationId: organizations.id, | |
| 197 | + organizationName: organizations.name, | |
| 198 | + plan: organizations.plan, | |
| 199 | + providerVisibility: organizations.providerVisibility, | |
| 200 | + suspended: organizations.suspended, | |
| 201 | + orgSoft: organizations.softLimitUsd, | |
| 202 | + orgHard: organizations.hardLimitUsd, | |
| 203 | + projectId: projects.id, | |
| 204 | + projectName: projects.name, | |
| 205 | + logLevel: projects.logLevel, | |
| 206 | + projSoft: projects.softLimitUsd, | |
| 207 | + projHard: projects.hardLimitUsd, | |
| 208 | + projMonthly: projects.monthlyRequestLimit, | |
| 209 | + archivedAt: projects.archivedAt, | |
| 210 | + ownerEmail: users.email, | |
| 211 | + ownerVerified: users.emailVerified, | |
| 212 | + ownerBanned: users.banned, | |
| 213 | + }) | |
| 214 | + .from(projects) | |
| 215 | + .innerJoin(organizations, eq(projects.organizationId, organizations.id)) | |
| 216 | + .innerJoin(users, eq(organizations.ownerUserId, users.id)) | |
| 217 | + .where(eq(projects.id, projectId)) | |
| 218 | + .limit(1); | |
| 219 | + if (!row) throw new FetchaError("NOT_FOUND", "Project not found."); | |
| 220 | + if (row.archivedAt) throw new FetchaError("FORBIDDEN", "This project is archived."); | |
| 221 | + if (row.suspended || row.ownerBanned) throw new FetchaError("FORBIDDEN", "This organization is suspended."); | |
| 222 | + let keyName: string | null = null; | |
| 223 | + if (apiKeyId) { | |
| 224 | + const [k] = await db.select({ name: apiKeys.name, revokedAt: apiKeys.revokedAt }).from(apiKeys).where(eq(apiKeys.id, apiKeyId)).limit(1); | |
| 225 | + if (k?.revokedAt) throw new FetchaError("INVALID_API_KEY", "The API key that started this crawl was revoked."); | |
| 226 | + keyName = k?.name ?? null; | |
| 227 | + } | |
| 228 | + return { | |
| 229 | + keyId: apiKeyId, | |
| 230 | + keyName, | |
| 231 | + mode: "live", | |
| 232 | + scopes: ["fetch:execute", "crawl:execute", "sessions:write", "usage:read"], | |
| 233 | + organizationId: row.organizationId, | |
| 234 | + organizationName: row.organizationName, | |
| 235 | + plan: normalizePlan(row.plan), | |
| 176 | 236 | providerVisibility: row.providerVisibility, |
| 177 | 237 | suspended: row.suspended, |
| 178 | 238 | orgSoftLimitUsd: row.orgSoft, |
modified
apps/api/src/limits.ts
+18 −0
@@ -23,6 +23,24 @@ export async function acquireConcurrency(p: ApiPrincipal): Promise<() => Promise | ||
| 23 | 23 | }; |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | +/** Reserve a managed-browser slot for the organization (separate, smaller pool than plain fetches). */ | |
| 27 | +export async function acquireBrowserSlot(p: ApiPrincipal): Promise<() => Promise<void>> { | |
| 28 | + const kv = getKV(); | |
| 29 | + const limit = PLAN_LIMITS[p.plan].browser_concurrency; | |
| 30 | + const key = `fch:bconc:${p.organizationId}`; | |
| 31 | + const n = await kv.incrWithTtl(key, CONCURRENCY_TTL_SEC); | |
| 32 | + if (n > limit) { | |
| 33 | + await kv.decr(key); | |
| 34 | + throw new FetchaError("CONCURRENCY_LIMIT", `At most ${limit} browser renders can run at once for your organization.`, { details: { limit, scope: "browser" } }); | |
| 35 | + } | |
| 36 | + let released = false; | |
| 37 | + return async () => { | |
| 38 | + if (released) return; | |
| 39 | + released = true; | |
| 40 | + await kv.decr(key).catch(() => {}); | |
| 41 | + }; | |
| 42 | +} | |
| 43 | + | |
| 26 | 44 | /** Sliding-window rate limits: per key (burst), per organization (sustained), per IP (anonymous abuse). */ |
| 27 | 45 | export async function checkRateLimits(p: ApiPrincipal, ip: string | null): Promise<void> { |
| 28 | 46 | const kv = getKV(); |
added
apps/api/src/routes/crawl.ts
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +import type { FastifyInstance } from "fastify"; | |
| 2 | +import { z } from "zod"; | |
| 3 | +import { FetchaError } from "@fetcha/core"; | |
| 4 | +import { authenticateApiKey, principalForProject, requireScope } from "../auth"; | |
| 5 | +import { cancelCrawl, createCrawl, getCrawl, listCrawlPages, listCrawls, mapSite } from "../services/crawl"; | |
| 6 | +import { getEngine } from "../services/engine"; | |
| 7 | + | |
| 8 | +const pagesQuery = z.object({ | |
| 9 | + cursor: z.string().optional(), | |
| 10 | + limit: z.coerce.number().int().min(1).max(500).optional(), | |
| 11 | + status: z.enum(["pending", "success", "blocked", "failed", "skipped"]).optional(), | |
| 12 | + include_content: z | |
| 13 | + .union([z.literal("true"), z.literal("false"), z.literal("1"), z.literal("0")]) | |
| 14 | + .optional() | |
| 15 | + .transform((v) => (v === undefined ? undefined : v === "true" || v === "1")), | |
| 16 | +}); | |
| 17 | + | |
| 18 | +const clientIp = (req: { headers: Record<string, unknown>; ip: string }) => ((req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? req.ip) || null; | |
| 19 | +const sourceOf = (req: { headers: Record<string, unknown> }) => (/fetcha-sdk/i.test(String(req.headers["user-agent"] ?? "")) ? "sdk" : "api") as "sdk" | "api"; | |
| 20 | + | |
| 21 | +/** Public crawl & map endpoints. */ | |
| 22 | +export async function registerCrawlRoutes(app: FastifyInstance) { | |
| 23 | + app.post("/v1/crawl", async (req, reply) => { | |
| 24 | + const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined); | |
| 25 | + requireScope(principal, "fetch:execute"); | |
| 26 | + const job = await createCrawl(principal, req.body, sourceOf(req)); | |
| 27 | + return reply.status(202).send(job); | |
| 28 | + }); | |
| 29 | + app.get<{ Querystring: { limit?: string } }>("/v1/crawl", async (req) => { | |
| 30 | + const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined); | |
| 31 | + return listCrawls(principal, Number(req.query.limit ?? 50) || 50); | |
| 32 | + }); | |
| 33 | + app.get<{ Params: { id: string } }>("/v1/crawl/:id", async (req) => { | |
| 34 | + const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined); | |
| 35 | + return getCrawl(principal, req.params.id); | |
| 36 | + }); | |
| 37 | + app.get<{ Params: { id: string }; Querystring: Record<string, string | undefined> }>("/v1/crawl/:id/pages", async (req) => { | |
| 38 | + const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined); | |
| 39 | + const q = pagesQuery.safeParse(req.query); | |
| 40 | + if (!q.success) throw new FetchaError("INVALID_REQUEST", "Invalid query parameters.", { details: { issues: q.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) } }); | |
| 41 | + return listCrawlPages(principal, req.params.id, q.data); | |
| 42 | + }); | |
| 43 | + app.delete<{ Params: { id: string } }>("/v1/crawl/:id", async (req) => { | |
| 44 | + const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined); | |
| 45 | + requireScope(principal, "fetch:execute"); | |
| 46 | + return cancelCrawl(principal, req.params.id); | |
| 47 | + }); | |
| 48 | + app.post("/v1/map", async (req) => { | |
| 49 | + const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined); | |
| 50 | + requireScope(principal, "fetch:execute"); | |
| 51 | + return mapSite(principal, req.body, sourceOf(req), clientIp(req)); | |
| 52 | + }); | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** Internal (dashboard) crawl routes; the service-token check is applied by the internal onRequest hook. */ | |
| 56 | +export async function registerInternalCrawlRoutes(app: FastifyInstance) { | |
| 57 | + const bodySchema = z.object({ project_id: z.string(), user_id: z.string(), options: z.unknown().optional() }); | |
| 58 | + type Q = { project_id: string; user_id: string; limit?: string; cursor?: string; status?: string; include_content?: string }; | |
| 59 | + | |
| 60 | + app.post("/internal/crawls", async (req, reply) => { | |
| 61 | + const parsed = bodySchema.safeParse(req.body); | |
| 62 | + if (!parsed.success) throw new FetchaError("INVALID_REQUEST", "project_id, user_id and options are required."); | |
| 63 | + const principal = await principalForProject(parsed.data.project_id, parsed.data.user_id); | |
| 64 | + const job = await createCrawl(principal, parsed.data.options ?? {}, "playground"); | |
| 65 | + return reply.status(202).send(job); | |
| 66 | + }); | |
| 67 | + app.get<{ Querystring: Q }>("/internal/crawls", async (req) => { | |
| 68 | + const principal = await principalForProject(req.query.project_id, req.query.user_id); | |
| 69 | + return listCrawls(principal, Number(req.query.limit ?? 50) || 50); | |
| 70 | + }); | |
| 71 | + app.get<{ Params: { id: string }; Querystring: Q }>("/internal/crawls/:id", async (req) => { | |
| 72 | + const principal = await principalForProject(req.query.project_id, req.query.user_id); | |
| 73 | + return getCrawl(principal, req.params.id); | |
| 74 | + }); | |
| 75 | + app.get<{ Params: { id: string }; Querystring: Q }>("/internal/crawls/:id/pages", async (req) => { | |
| 76 | + const principal = await principalForProject(req.query.project_id, req.query.user_id); | |
| 77 | + const q = pagesQuery.safeParse(req.query); | |
| 78 | + if (!q.success) throw new FetchaError("INVALID_REQUEST", "Invalid query parameters."); | |
| 79 | + return listCrawlPages(principal, req.params.id, q.data); | |
| 80 | + }); | |
| 81 | + app.delete<{ Params: { id: string }; Querystring: Q }>("/internal/crawls/:id", async (req) => { | |
| 82 | + const principal = await principalForProject(req.query.project_id, req.query.user_id); | |
| 83 | + return cancelCrawl(principal, req.params.id); | |
| 84 | + }); | |
| 85 | + app.post("/internal/map", async (req) => { | |
| 86 | + const parsed = bodySchema.safeParse(req.body); | |
| 87 | + if (!parsed.success) throw new FetchaError("INVALID_REQUEST", "project_id, user_id and options are required."); | |
| 88 | + const principal = await principalForProject(parsed.data.project_id, parsed.data.user_id); | |
| 89 | + return mapSite(principal, parsed.data.options ?? {}, "playground", (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? req.ip); | |
| 90 | + }); | |
| 91 | + app.get("/internal/browser", async () => { | |
| 92 | + const engine = await getEngine(); | |
| 93 | + const s = engine.browser.status(); | |
| 94 | + return { ...s, enabled: engine.browserEnabled, flag_enabled: engine.browserEnabled || !engine.browser.enabled ? engine.browserEnabled : false }; | |
| 95 | + }); | |
| 96 | +} | |
modified
apps/api/src/routes/fetch.ts
+34 −11
@@ -1,11 +1,11 @@ | ||
| 1 | 1 | import { FetchaError, PLAN_LIMITS, extractDomain, fetchRequestSchema, newId, type FetchRequest } from "@fetcha/core"; |
| 2 | 2 | import { db, eq, proxySessions, sql } from "@fetcha/db"; |
| 3 | 3 | import type { ProviderId } from "@fetcha/providers"; |
| 4 | −import type { AttemptRecord } from "@fetcha/routing"; | |
| 4 | +import type { AttemptRecord, SerializedCookie } from "@fetcha/routing"; | |
| 5 | 5 | import type { ConcreteNetwork } from "@fetcha/core"; |
| 6 | 6 | import type { ApiPrincipal } from "../auth"; |
| 7 | 7 | import { config } from "../config"; |
| 8 | −import { acquireConcurrency, checkMonthlyLimits, checkRateLimits, invalidateUsageCache } from "../limits"; | |
| 8 | +import { acquireBrowserSlot, acquireConcurrency, checkMonthlyLimits, checkRateLimits, invalidateUsageCache } from "../limits"; | |
| 9 | 9 | import { getEngine } from "../services/engine"; |
| 10 | 10 | import { completeRequest, createRequestRow, failRequest, recordAttempt, type RequestContext } from "../services/persist"; |
| 11 | 11 | import { checkSpendAlerts, notifyHardLimit, recordAbuse } from "../services/alerts"; |
@@ -13,7 +13,7 @@ import { checkSpendAlerts, notifyHardLimit, recordAbuse } from "../services/aler | ||
| 13 | 13 | export interface FetchHandlerInput { |
| 14 | 14 | principal: ApiPrincipal; |
| 15 | 15 | body: unknown; |
| 16 | − source: "api" | "playground" | "sdk"; | |
| 16 | + source: "api" | "playground" | "sdk" | "crawl"; | |
| 17 | 17 | clientIp: string | null; |
| 18 | 18 | userAgent: string | null; |
| 19 | 19 | } |
@@ -22,10 +22,12 @@ export interface FetchHandlerOutput { | ||
| 22 | 22 | requestId: string; |
| 23 | 23 | status: number; |
| 24 | 24 | body: unknown; |
| 25 | + /** Internal: execution summary for callers that need more than the public document (crawler). */ | |
| 26 | + summary?: { mode: "http" | "browser"; bytes: number; costUsd: number; attempts: number; domain: string }; | |
| 25 | 27 | } |
| 26 | 28 | |
| 27 | 29 | /** |
| 28 | − * Shared /v1/fetch pipeline used by the public API and the dashboard playground. | |
| 30 | + * Shared /v1/fetch pipeline used by the public API, the dashboard playground and the crawler. | |
| 29 | 31 | * Order matters: validate → limits → SSRF (inside executor) → route → persist. |
| 30 | 32 | */ |
| 31 | 33 | export async function handleFetch(input: FetchHandlerInput): Promise<FetchHandlerOutput> { |
@@ -46,12 +48,12 @@ export async function handleFetch(input: FetchHandlerInput): Promise<FetchHandle | ||
| 46 | 48 | if (req.network !== "auto" && !limits.networks.includes(req.network)) { |
| 47 | 49 | throw new FetchaError("NETWORK_UNAVAILABLE", `The "${req.network}" network is not included in the ${limits.label} plan.`, { requestId }); |
| 48 | 50 | } |
| 49 | − if (req.browser) throw new FetchaError("BROWSER_UNAVAILABLE", undefined, { requestId }); | |
| 51 | + const engine = await getEngine(); | |
| 52 | + if (req.browser && (!limits.browser || !engine.browserEnabled)) throw new FetchaError("BROWSER_UNAVAILABLE", undefined, { requestId }); | |
| 50 | 53 | |
| 51 | 54 | await checkRateLimits(principal, input.clientIp); |
| 52 | − let monthly: Awaited<ReturnType<typeof checkMonthlyLimits>>; | |
| 53 | 55 | try { |
| 54 | − monthly = await checkMonthlyLimits(principal); | |
| 56 | + await checkMonthlyLimits(principal); | |
| 55 | 57 | } catch (e) { |
| 56 | 58 | if (e instanceof FetchaError && e.code === "USAGE_LIMIT_REACHED" && (principal.projectHardLimitUsd !== null || principal.orgHardLimitUsd !== null)) { |
| 57 | 59 | const { monthlyUsage } = await import("../limits"); |
@@ -60,6 +62,15 @@ export async function handleFetch(input: FetchHandlerInput): Promise<FetchHandle | ||
| 60 | 62 | throw e; |
| 61 | 63 | } |
| 62 | 64 | const release = await acquireConcurrency(principal); |
| 65 | + let releaseBrowser: (() => Promise<void>) | null = null; | |
| 66 | + if (req.browser) { | |
| 67 | + try { | |
| 68 | + releaseBrowser = await acquireBrowserSlot(principal); | |
| 69 | + } catch (e) { | |
| 70 | + await release(); | |
| 71 | + throw e; | |
| 72 | + } | |
| 73 | + } | |
| 63 | 74 | |
| 64 | 75 | const domain = extractDomain(req.url); |
| 65 | 76 | const ctx: RequestContext = { |
@@ -77,19 +88,22 @@ export async function handleFetch(input: FetchHandlerInput): Promise<FetchHandle | ||
| 77 | 88 | const attempts: AttemptRecord[] = []; |
| 78 | 89 | try { |
| 79 | 90 | await createRequestRow(ctx, req, domain); |
| 80 | − const engine = await getEngine(); | |
| 81 | 91 | |
| 82 | − // Resolve a Fetcha session (sess_…) into a provider-pinned sticky key. | |
| 92 | + // Resolve a Fetcha session (sess_…) into a provider-pinned sticky key (+ its cookie jar). | |
| 83 | 93 | let sessionKey: string | null = null; |
| 84 | 94 | let sessionProvider: ProviderId | null = null; |
| 85 | 95 | let sessionNetwork: ConcreteNetwork | null = null; |
| 96 | + let sessionCookies: SerializedCookie[] | null = null; | |
| 97 | + let sessionId: string | null = null; | |
| 86 | 98 | if (req.session) { |
| 87 | 99 | const [s] = await db.select().from(proxySessions).where(eq(proxySessions.id, req.session)).limit(1); |
| 88 | 100 | if (!s || s.projectId !== principal.projectId) throw new FetchaError("SESSION_NOT_FOUND", undefined, { requestId }); |
| 89 | 101 | if (s.status !== "active" || s.expiresAt.getTime() < Date.now()) throw new FetchaError("SESSION_EXPIRED", undefined, { requestId }); |
| 90 | 102 | sessionKey = s.stickyKey; |
| 103 | + sessionId = s.id; | |
| 91 | 104 | sessionProvider = s.provider as ProviderId; |
| 92 | 105 | sessionNetwork = s.network as ConcreteNetwork; |
| 106 | + sessionCookies = (s.cookies ?? []) as unknown as SerializedCookie[]; | |
| 93 | 107 | if (!req.country && s.country) req.country = s.country; |
| 94 | 108 | if (req.network === "auto") req.network = s.network as FetchRequest["network"]; |
| 95 | 109 | db.update(proxySessions) |
@@ -106,9 +120,12 @@ export async function handleFetch(input: FetchHandlerInput): Promise<FetchHandle | ||
| 106 | 120 | sessionKey, |
| 107 | 121 | sessionProvider, |
| 108 | 122 | sessionNetwork, |
| 123 | + sessionCookies, | |
| 109 | 124 | knowledge, |
| 110 | 125 | providerVisibility: principal.providerVisibility, |
| 111 | 126 | maxResponseBytes: Math.min(req.max_response_bytes ?? config.maxResponseBytesDefault, config.maxResponseBytesDefault), |
| 127 | + browserPool: engine.browserEnabled && limits.browser ? engine.browser : null, | |
| 128 | + browserAllowed: limits.browser, | |
| 112 | 129 | onAttempt: async (a) => { |
| 113 | 130 | attempts.push(a); |
| 114 | 131 | await recordAttempt(requestId, a).catch(() => {}); |
@@ -116,13 +133,18 @@ export async function handleFetch(input: FetchHandlerInput): Promise<FetchHandle | ||
| 116 | 133 | }); |
| 117 | 134 | const latencyMs = Math.round(performance.now() - started); |
| 118 | 135 | await completeRequest(ctx, req, result, latencyMs); |
| 136 | + if (sessionId && result.cookies.length) { | |
| 137 | + db.update(proxySessions) | |
| 138 | + .set({ cookies: result.cookies.slice(-200) as unknown as Array<{ name: string; value: string; domain?: string; path?: string }> }) | |
| 139 | + .where(eq(proxySessions.id, sessionId)) | |
| 140 | + .catch(() => {}); | |
| 141 | + } | |
| 119 | 142 | await invalidateUsageCache(principal.organizationId, principal.projectId); |
| 120 | 143 | if (principal.projectSoftLimitUsd !== null || principal.orgSoftLimitUsd !== null) { |
| 121 | 144 | const { monthlyUsage } = await import("../limits"); |
| 122 | 145 | monthlyUsage(principal).then((u) => checkSpendAlerts(principal, u)).catch(() => {}); |
| 123 | 146 | } |
| 124 | − void monthly; | |
| 125 | − return { requestId, status: 200, body: result.body }; | |
| 147 | + return { requestId, status: 200, body: result.body, summary: { mode: result.mode, bytes: result.bytesIn + result.bytesOut, costUsd: result.costUsd, attempts: result.attempts.length, domain } }; | |
| 126 | 148 | } catch (e) { |
| 127 | 149 | const latencyMs = Math.round(performance.now() - started); |
| 128 | 150 | const err = e instanceof FetchaError ? e : new FetchaError("INTERNAL_ERROR", undefined, { requestId, cause: e }); |
@@ -131,6 +153,7 @@ export async function handleFetch(input: FetchHandlerInput): Promise<FetchHandle | ||
| 131 | 153 | if (!(e instanceof FetchaError)) console.error(`[fetch] ${requestId} internal error`, e); |
| 132 | 154 | throw Object.assign(err, { requestId }); |
| 133 | 155 | } finally { |
| 156 | + await releaseBrowser?.(); | |
| 134 | 157 | await release(); |
| 135 | 158 | } |
| 136 | 159 | } |
modified
apps/api/src/routes/misc.ts
+3 −1
@@ -40,7 +40,9 @@ export async function usageResponse(p: ApiPrincipal) { | ||
| 40 | 40 | latency_p50_ms: Math.round(agg?.p50 ?? 0), |
| 41 | 41 | latency_p95_ms: Math.round(agg?.p95 ?? 0), |
| 42 | 42 | }, |
| 43 | − remaining_requests: Math.max(0, limits.monthly_requests - usage.requests), | |
| 43 | + remaining_requests: limits.monthly_requests >= Number.MAX_SAFE_INTEGER ? null : Math.max(0, limits.monthly_requests - usage.requests), | |
| 44 | + browser: { enabled: true, concurrency: limits.browser_concurrency }, | |
| 45 | + crawl: { max_pages: limits.crawl_max_pages, concurrent_jobs: limits.crawl_concurrent_jobs }, | |
| 44 | 46 | }; |
| 45 | 47 | } |
| 46 | 48 | |
modified
apps/api/src/server.ts
+13 −4
@@ -3,11 +3,14 @@ import cors from "@fastify/cors"; | ||
| 3 | 3 | import { FetchaError, isFetchaError, newId } from "@fetcha/core"; |
| 4 | 4 | import { closeDb, db, sql } from "@fetcha/db"; |
| 5 | 5 | import { closeAllDispatchers } from "@fetcha/providers"; |
| 6 | +import { closeBrowserPool } from "@fetcha/browser"; | |
| 6 | 7 | import { authenticateApiKey, requireScope } from "./auth"; |
| 7 | 8 | import { assertProductionConfig, config } from "./config"; |
| 8 | 9 | import { closeKV, getKV } from "./redis"; |
| 9 | 10 | import { handleFetch } from "./routes/fetch"; |
| 10 | 11 | import { registerInternalRoutes } from "./routes/internal"; |
| 12 | +import { registerCrawlRoutes, registerInternalCrawlRoutes } from "./routes/crawl"; | |
| 13 | +import { startCrawlWorker } from "./services/crawl"; | |
| 11 | 14 | import { meResponse, usageResponse } from "./routes/misc"; |
| 12 | 15 | import { closeSession, createSession, getSession, listSessions } from "./routes/sessions"; |
| 13 | 16 | import { getEngine, startHealthLoop } from "./services/engine"; |
@@ -74,7 +77,7 @@ export async function buildServer() { | ||
| 74 | 77 | const engine = await getEngine().catch(() => null); |
| 75 | 78 | checks.providers = Boolean(engine && engine.registry.available().length > 0); |
| 76 | 79 | const ok = checks.database && checks.cache && checks.providers; |
| 77 | − return reply.status(ok ? 200 : 503).send({ status: ok ? "ready" : "degraded", checks, available_networks: engine?.registry.availableNetworks() ?? [] }); | |
| 80 | + return reply.status(ok ? 200 : 503).send({ status: ok ? "ready" : "degraded", checks, available_networks: engine?.registry.availableNetworks() ?? [], browser: engine ? { enabled: engine.browserEnabled, launched: engine.browser.status().launched } : null }); | |
| 78 | 81 | }); |
| 79 | 82 | |
| 80 | 83 | // ---- Public API v1 ---------------------------------------------------------- |
@@ -118,15 +121,18 @@ export async function buildServer() { | ||
| 118 | 121 | return usageResponse(principal); |
| 119 | 122 | }); |
| 120 | 123 | |
| 124 | + await registerCrawlRoutes(app); | |
| 125 | + | |
| 121 | 126 | // Explicitly unavailable surfaces (never fake functionality). |
| 122 | 127 | app.post("/v1/browser", async () => { |
| 123 | − throw new FetchaError("BROWSER_UNAVAILABLE", "Browser actions are not yet available. Follow the changelog at https://www.fetcha.co/changelog."); | |
| 128 | + throw new FetchaError("BROWSER_UNAVAILABLE", "Scripted browser actions are not available yet. Rendered fetches are: send `browser: true` to POST /v1/fetch."); | |
| 124 | 129 | }); |
| 125 | 130 | app.post("/v1/extract", async () => { |
| 126 | − throw new FetchaError("INVALID_REQUEST", "Structured extraction is not yet available on this plan. Follow the changelog at https://www.fetcha.co/changelog."); | |
| 131 | + throw new FetchaError("INVALID_REQUEST", "Structured extraction is not yet available. Use `format: \"markdown\"` on POST /v1/fetch in the meantime."); | |
| 127 | 132 | }); |
| 128 | 133 | |
| 129 | 134 | await registerInternalRoutes(app); |
| 135 | + await registerInternalCrawlRoutes(app); | |
| 130 | 136 | return app; |
| 131 | 137 | } |
| 132 | 138 | |
@@ -136,12 +142,15 @@ async function main() { | ||
| 136 | 142 | const engine = await getEngine(); |
| 137 | 143 | app.log.info({ providers: engine.registry.available().map((p) => p.id), networks: engine.registry.availableNetworks() }, "providers loaded"); |
| 138 | 144 | const stopHealth = startHealthLoop(engine, app.log); |
| 145 | + const stopCrawl = startCrawlWorker(app.log); | |
| 146 | + app.log.info({ browser: engine.browser.status() }, "managed browser pool ready (lazy launch)"); | |
| 139 | 147 | |
| 140 | 148 | const shutdown = async (signal: string) => { |
| 141 | 149 | app.log.info(`${signal} received, shutting down`); |
| 142 | 150 | stopHealth(); |
| 151 | + await Promise.race([stopCrawl(), new Promise((r) => setTimeout(r, 8000))]); | |
| 143 | 152 | await app.close(); |
| 144 | − await Promise.all([closeAllDispatchers(), closeKV(), closeDb()]); | |
| 153 | + await Promise.all([closeAllDispatchers(), closeBrowserPool(), closeKV(), closeDb()]); | |
| 145 | 154 | process.exit(0); |
| 146 | 155 | }; |
| 147 | 156 | process.on("SIGINT", () => void shutdown("SIGINT")); |
added
apps/api/src/services/crawl.ts
+592 −0
@@ -0,0 +1,592 @@ | ||
| 1 | +import { | |
| 2 | + FetchaError, | |
| 3 | + PLAN_LIMITS, | |
| 4 | + assertUrlAllowed, | |
| 5 | + crawlCreateSchema, | |
| 6 | + extractDomain, | |
| 7 | + extractPageMetadata, | |
| 8 | + looksLikePage, | |
| 9 | + mapCreateSchema, | |
| 10 | + newId, | |
| 11 | + normalizeCrawlUrl, | |
| 12 | + parseRobots, | |
| 13 | + parseSitemap, | |
| 14 | + registrableHost, | |
| 15 | + robotsAllows, | |
| 16 | + urlPatternMatcher, | |
| 17 | + type CrawlCreateInput, | |
| 18 | + type FetchResponseBody, | |
| 19 | + type MapCreateInput, | |
| 20 | + type RobotsRules, | |
| 21 | +} from "@fetcha/core"; | |
| 22 | +import { and, crawlJobs, crawlPages, db, desc, eq, inArray, lt, sql, type CrawlJob, type CrawlPage } from "@fetcha/db"; | |
| 23 | +import { hostname } from "node:os"; | |
| 24 | +import type { ApiPrincipal } from "../auth"; | |
| 25 | +import { handleFetch, type FetchHandlerOutput } from "../routes/fetch"; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * Crawl service: durable jobs (crawl_jobs / crawl_pages) executed in-process by a small worker | |
| 29 | + * loop. Every page goes through `handleFetch`, so quotas, routing intelligence, block detection and | |
| 30 | + * browser escalation apply exactly as for a single fetch. | |
| 31 | + */ | |
| 32 | + | |
| 33 | +const WORKER_ID = `${hostname()}:${process.pid}`; | |
| 34 | +const PAGE_CONTENT_CAP = 600_000; // chars stored per page | |
| 35 | +const HEARTBEAT_MS = 10_000; | |
| 36 | +const STALE_MS = 3 * 60_000; | |
| 37 | + | |
| 38 | +export function serializeJob(j: CrawlJob) { | |
| 39 | + return { | |
| 40 | + id: j.id, | |
| 41 | + status: j.status, | |
| 42 | + label: j.label, | |
| 43 | + seed_url: j.seedUrl, | |
| 44 | + domain: j.domain, | |
| 45 | + source: j.source, | |
| 46 | + options: j.options, | |
| 47 | + stats: { discovered: j.pagesDiscovered, fetched: j.pagesFetched, ok: j.pagesOk, blocked: j.pagesBlocked, failed: j.pagesFailed, bytes: j.bytes }, | |
| 48 | + error: j.errorCode ? { code: j.errorCode, message: j.errorMessage } : null, | |
| 49 | + webhook_status: j.webhookStatus, | |
| 50 | + created_at: j.createdAt.toISOString(), | |
| 51 | + started_at: j.startedAt?.toISOString() ?? null, | |
| 52 | + completed_at: j.completedAt?.toISOString() ?? null, | |
| 53 | + }; | |
| 54 | +} | |
| 55 | + | |
| 56 | +export function serializePage(p: CrawlPage, includeContent = true) { | |
| 57 | + return { | |
| 58 | + id: p.id, | |
| 59 | + url: p.url, | |
| 60 | + final_url: p.finalUrl, | |
| 61 | + depth: p.depth, | |
| 62 | + parent_url: p.parentUrl, | |
| 63 | + status: p.status, | |
| 64 | + http_status: p.httpStatus, | |
| 65 | + error_code: p.errorCode, | |
| 66 | + request_id: p.requestId, | |
| 67 | + title: p.title, | |
| 68 | + description: p.description, | |
| 69 | + content_type: p.contentType, | |
| 70 | + content: includeContent ? p.content : undefined, | |
| 71 | + links_count: p.linksCount, | |
| 72 | + bytes: p.bytes, | |
| 73 | + duration_ms: p.durationMs, | |
| 74 | + mode: p.mode, | |
| 75 | + fetched_at: p.fetchedAt?.toISOString() ?? null, | |
| 76 | + }; | |
| 77 | +} | |
| 78 | + | |
| 79 | +// --------------------------------------------------------------------------- | |
| 80 | +// Public operations | |
| 81 | +// --------------------------------------------------------------------------- | |
| 82 | +export async function createCrawl(principal: ApiPrincipal, body: unknown, source: "api" | "playground" | "sdk") { | |
| 83 | + const parsed = crawlCreateSchema.safeParse(body ?? {}); | |
| 84 | + if (!parsed.success) { | |
| 85 | + throw new FetchaError("INVALID_REQUEST", "Invalid crawl options.", { details: { issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) } }); | |
| 86 | + } | |
| 87 | + const opts = parsed.data; | |
| 88 | + const limits = PLAN_LIMITS[principal.plan]; | |
| 89 | + if (opts.max_pages > limits.crawl_max_pages) opts.max_pages = limits.crawl_max_pages; | |
| 90 | + if (opts.timeout > limits.max_timeout_ms) opts.timeout = limits.max_timeout_ms; | |
| 91 | + if (opts.network !== "auto" && !limits.networks.includes(opts.network)) throw new FetchaError("NETWORK_UNAVAILABLE"); | |
| 92 | + const allowed = await assertUrlAllowed(opts.url); | |
| 93 | + const seed = normalizeCrawlUrl(allowed.url.toString()); | |
| 94 | + if (!seed) throw new FetchaError("INVALID_REQUEST", "The seed URL is invalid."); | |
| 95 | + if (opts.webhook_url) await assertUrlAllowed(opts.webhook_url); | |
| 96 | + | |
| 97 | + const [active] = await db | |
| 98 | + .select({ n: sql<number>`count(*)::int` }) | |
| 99 | + .from(crawlJobs) | |
| 100 | + .where(and(eq(crawlJobs.organizationId, principal.organizationId), inArray(crawlJobs.status, ["queued", "running"]))); | |
| 101 | + if ((active?.n ?? 0) >= limits.crawl_concurrent_jobs) { | |
| 102 | + throw new FetchaError("CRAWL_LIMIT_REACHED", `At most ${limits.crawl_concurrent_jobs} crawl jobs can be queued or running at once.`, { details: { limit: limits.crawl_concurrent_jobs } }); | |
| 103 | + } | |
| 104 | + | |
| 105 | + const id = newId("crawl"); | |
| 106 | + await db.insert(crawlJobs).values({ | |
| 107 | + id, | |
| 108 | + organizationId: principal.organizationId, | |
| 109 | + projectId: principal.projectId, | |
| 110 | + apiKeyId: principal.keyId, | |
| 111 | + source, | |
| 112 | + label: opts.label ?? null, | |
| 113 | + seedUrl: seed, | |
| 114 | + domain: extractDomain(seed), | |
| 115 | + options: { ...opts, url: seed } as Record<string, unknown>, | |
| 116 | + status: "queued", | |
| 117 | + pagesDiscovered: 1, | |
| 118 | + }); | |
| 119 | + await db.insert(crawlPages).values({ id: newId("cpg"), jobId: id, url: seed, depth: 0, status: "pending" }).onConflictDoNothing(); | |
| 120 | + const [job] = await db.select().from(crawlJobs).where(eq(crawlJobs.id, id)).limit(1); | |
| 121 | + kick(); | |
| 122 | + return serializeJob(job!); | |
| 123 | +} | |
| 124 | + | |
| 125 | +export async function getCrawl(principal: ApiPrincipal, id: string) { | |
| 126 | + const [job] = await db.select().from(crawlJobs).where(and(eq(crawlJobs.id, id), eq(crawlJobs.projectId, principal.projectId))).limit(1); | |
| 127 | + if (!job) throw new FetchaError("CRAWL_NOT_FOUND"); | |
| 128 | + return serializeJob(job); | |
| 129 | +} | |
| 130 | + | |
| 131 | +export async function listCrawls(principal: ApiPrincipal, limit = 50) { | |
| 132 | + const rows = await db.select().from(crawlJobs).where(eq(crawlJobs.projectId, principal.projectId)).orderBy(desc(crawlJobs.createdAt)).limit(Math.min(Math.max(1, limit), 200)); | |
| 133 | + return { data: rows.map(serializeJob) }; | |
| 134 | +} | |
| 135 | + | |
| 136 | +export async function listCrawlPages(principal: ApiPrincipal, id: string, q: { cursor?: string; limit?: number; status?: string; include_content?: boolean }) { | |
| 137 | + const [job] = await db.select({ id: crawlJobs.id }).from(crawlJobs).where(and(eq(crawlJobs.id, id), eq(crawlJobs.projectId, principal.projectId))).limit(1); | |
| 138 | + if (!job) throw new FetchaError("CRAWL_NOT_FOUND"); | |
| 139 | + const limit = Math.min(Math.max(1, q.limit ?? 100), 500); | |
| 140 | + const conds = [eq(crawlPages.jobId, id)]; | |
| 141 | + if (q.status) conds.push(eq(crawlPages.status, q.status)); | |
| 142 | + else conds.push(sql`${crawlPages.status} <> 'pending'`); | |
| 143 | + if (q.cursor) { | |
| 144 | + const [ts, cid] = decodeCursor(q.cursor); | |
| 145 | + conds.push(sql`(${crawlPages.createdAt}, ${crawlPages.id}) > (${new Date(ts)}, ${cid})`); | |
| 146 | + } | |
| 147 | + const rows = await db | |
| 148 | + .select() | |
| 149 | + .from(crawlPages) | |
| 150 | + .where(and(...conds)) | |
| 151 | + .orderBy(crawlPages.createdAt, crawlPages.id) | |
| 152 | + .limit(limit + 1); | |
| 153 | + const page = rows.slice(0, limit); | |
| 154 | + const last = page.at(-1); | |
| 155 | + return { data: page.map((p) => serializePage(p, q.include_content !== false)), next_cursor: rows.length > limit && last ? encodeCursor(last.createdAt, last.id) : null }; | |
| 156 | +} | |
| 157 | + | |
| 158 | +export async function cancelCrawl(principal: ApiPrincipal, id: string) { | |
| 159 | + const res = await db | |
| 160 | + .update(crawlJobs) | |
| 161 | + .set({ status: "cancelled", completedAt: new Date() }) | |
| 162 | + .where(and(eq(crawlJobs.id, id), eq(crawlJobs.projectId, principal.projectId), inArray(crawlJobs.status, ["queued", "running"]))) | |
| 163 | + .returning({ id: crawlJobs.id }); | |
| 164 | + if (!res.length) { | |
| 165 | + const [job] = await db.select({ id: crawlJobs.id, status: crawlJobs.status }).from(crawlJobs).where(and(eq(crawlJobs.id, id), eq(crawlJobs.projectId, principal.projectId))).limit(1); | |
| 166 | + if (!job) throw new FetchaError("CRAWL_NOT_FOUND"); | |
| 167 | + return { id, status: job.status }; | |
| 168 | + } | |
| 169 | + cancelled.add(id); | |
| 170 | + return { id, status: "cancelled" }; | |
| 171 | +} | |
| 172 | + | |
| 173 | +function encodeCursor(d: Date, id: string): string { | |
| 174 | + return Buffer.from(`${d.toISOString()}|${id}`).toString("base64url"); | |
| 175 | +} | |
| 176 | +function decodeCursor(c: string): [string, string] { | |
| 177 | + try { | |
| 178 | + const [ts, id] = Buffer.from(c, "base64url").toString("utf8").split("|"); | |
| 179 | + if (!ts || !id || Number.isNaN(Date.parse(ts))) throw new Error("bad"); | |
| 180 | + return [ts, id]; | |
| 181 | + } catch { | |
| 182 | + throw new FetchaError("INVALID_REQUEST", "Invalid cursor."); | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +// --------------------------------------------------------------------------- | |
| 187 | +// Map (sync) | |
| 188 | +// --------------------------------------------------------------------------- | |
| 189 | +export async function mapSite(principal: ApiPrincipal, body: unknown, source: "api" | "playground" | "sdk", clientIp: string | null) { | |
| 190 | + const parsed = mapCreateSchema.safeParse(body ?? {}); | |
| 191 | + if (!parsed.success) { | |
| 192 | + throw new FetchaError("INVALID_REQUEST", "Invalid map options.", { details: { issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) } }); | |
| 193 | + } | |
| 194 | + const opts = parsed.data; | |
| 195 | + const allowed = await assertUrlAllowed(opts.url); | |
| 196 | + const seed = normalizeCrawlUrl(allowed.url.toString())!; | |
| 197 | + const seedUrl = new URL(seed); | |
| 198 | + const host = registrableHost(seedUrl.hostname); | |
| 199 | + const inScope = scopeFilter(seedUrl, opts.same_domain, opts.allow_subdomains); | |
| 200 | + const search = opts.search ? urlPatternMatcher([opts.search.includes("*") || /^\/.+\/[a-z]*$/.test(opts.search) ? opts.search : `*${opts.search}*`]) : null; | |
| 201 | + const deadline = Date.now() + Math.min(55_000, opts.timeout * 2); | |
| 202 | + const found = new Map<string, "sitemap" | "links">(); | |
| 203 | + const add = (u: string, src: "sitemap" | "links") => { | |
| 204 | + const n = normalizeCrawlUrl(u); | |
| 205 | + if (!n || found.has(n) || !inScope(n) || !looksLikePage(n)) return; | |
| 206 | + if (search && !search(n)) return; | |
| 207 | + found.set(n, src); | |
| 208 | + }; | |
| 209 | + const fetchText = async (url: string, format: "raw" | "html" = "raw", extra: Record<string, unknown> = {}): Promise<FetchResponseBody | null> => { | |
| 210 | + if (Date.now() > deadline - 2000) return null; | |
| 211 | + try { | |
| 212 | + const out = await handleFetch({ principal, body: { url, format, timeout: Math.min(opts.timeout, Math.max(3000, deadline - Date.now() - 1000)), country: opts.country, network: opts.network, browser_fallback: false, retries: 1, ...extra }, source, clientIp, userAgent: `fetcha-map/${source}` }); | |
| 213 | + return out.body as FetchResponseBody; | |
| 214 | + } catch { | |
| 215 | + return null; | |
| 216 | + } | |
| 217 | + }; | |
| 218 | + let sitemapCount = 0; | |
| 219 | + let linksCount = 0; | |
| 220 | + if (opts.use_sitemap) { | |
| 221 | + const sitemapUrls = new Set<string>([`${seedUrl.origin}/sitemap.xml`, `${seedUrl.origin}/sitemap_index.xml`]); | |
| 222 | + const robots = await fetchText(`${seedUrl.origin}/robots.txt`); | |
| 223 | + if (robots?.success && robots.content) for (const s of parseRobots(robots.content).sitemaps) sitemapUrls.add(s); | |
| 224 | + const queue = [...sitemapUrls]; | |
| 225 | + const seen = new Set<string>(); | |
| 226 | + while (queue.length && seen.size < 25 && found.size < opts.limit && Date.now() < deadline - 3000) { | |
| 227 | + const u = queue.shift()!; | |
| 228 | + if (seen.has(u)) continue; | |
| 229 | + seen.add(u); | |
| 230 | + const r = await fetchText(u); | |
| 231 | + if (!r?.success || !r.content || !/<(urlset|sitemapindex)|<loc>/i.test(r.content.slice(0, 5000)) && !/^https?:\/\//m.test(r.content.slice(0, 500))) continue; | |
| 232 | + const { urls, sitemaps } = parseSitemap(r.content); | |
| 233 | + for (const s of sitemaps) if (host === safeHost(s) || opts.allow_subdomains) queue.push(s); | |
| 234 | + for (const x of urls) { | |
| 235 | + if (found.size >= opts.limit) break; | |
| 236 | + const before = found.size; | |
| 237 | + add(x, "sitemap"); | |
| 238 | + if (found.size > before) sitemapCount++; | |
| 239 | + } | |
| 240 | + } | |
| 241 | + } | |
| 242 | + if (opts.use_links && found.size < opts.limit && Date.now() < deadline - 3000) { | |
| 243 | + const r = await fetchText(seed, "html", { links: true }); | |
| 244 | + if (r?.success && r.links) { | |
| 245 | + for (const l of r.links) { | |
| 246 | + if (found.size >= opts.limit) break; | |
| 247 | + const before = found.size; | |
| 248 | + add(l.url, "links"); | |
| 249 | + if (found.size > before) linksCount++; | |
| 250 | + } | |
| 251 | + } else if (r?.content) { | |
| 252 | + const meta = extractPageMetadata(r.content, r.final_url); | |
| 253 | + for (const l of meta.links) { | |
| 254 | + if (found.size >= opts.limit) break; | |
| 255 | + const before = found.size; | |
| 256 | + add(l.url, "links"); | |
| 257 | + if (found.size > before) linksCount++; | |
| 258 | + } | |
| 259 | + } | |
| 260 | + } | |
| 261 | + const urls = [...found.keys()]; | |
| 262 | + return { url: seed, count: urls.length, urls, sources: { sitemap: sitemapCount, links: linksCount }, truncated: urls.length >= opts.limit }; | |
| 263 | +} | |
| 264 | + | |
| 265 | +function safeHost(u: string): string { | |
| 266 | + try { | |
| 267 | + return registrableHost(new URL(u).hostname); | |
| 268 | + } catch { | |
| 269 | + return ""; | |
| 270 | + } | |
| 271 | +} | |
| 272 | + | |
| 273 | +function scopeFilter(seed: URL, sameDomain: boolean, allowSubdomains: boolean): (url: string) => boolean { | |
| 274 | + const seedHost = seed.hostname.toLowerCase().replace(/^www\./, ""); | |
| 275 | + const reg = registrableHost(seed.hostname); | |
| 276 | + return (url: string) => { | |
| 277 | + if (!sameDomain) return true; | |
| 278 | + let h: string; | |
| 279 | + try { | |
| 280 | + h = new URL(url).hostname.toLowerCase().replace(/^www\./, ""); | |
| 281 | + } catch { | |
| 282 | + return false; | |
| 283 | + } | |
| 284 | + if (h === seedHost) return true; | |
| 285 | + if (allowSubdomains) return registrableHost(h) === reg; | |
| 286 | + return false; | |
| 287 | + }; | |
| 288 | +} | |
| 289 | + | |
| 290 | +// --------------------------------------------------------------------------- | |
| 291 | +// Worker | |
| 292 | +// --------------------------------------------------------------------------- | |
| 293 | +const cancelled = new Set<string>(); | |
| 294 | +let running = false; | |
| 295 | +let stopped = false; | |
| 296 | +let wake: (() => void) | null = null; | |
| 297 | +const activeJobs = new Map<string, Promise<void>>(); | |
| 298 | +const MAX_PARALLEL_JOBS = Number(process.env.FETCHA_CRAWL_PARALLEL_JOBS ?? 4); | |
| 299 | + | |
| 300 | +function kick() { | |
| 301 | + wake?.(); | |
| 302 | +} | |
| 303 | + | |
| 304 | +export function startCrawlWorker(log: { info: (m: string) => void; warn: (m: string) => void }): () => Promise<void> { | |
| 305 | + if (running) return async () => {}; | |
| 306 | + running = true; | |
| 307 | + stopped = false; | |
| 308 | + const loop = async () => { | |
| 309 | + // Re-queue jobs left "running" by a previous process (crash / redeploy). | |
| 310 | + await db | |
| 311 | + .update(crawlJobs) | |
| 312 | + .set({ status: "queued", workerId: null }) | |
| 313 | + .where(and(eq(crawlJobs.status, "running"), lt(crawlJobs.heartbeatAt, new Date(Date.now() - STALE_MS)))) | |
| 314 | + .catch(() => {}); | |
| 315 | + while (!stopped) { | |
| 316 | + try { | |
| 317 | + if (activeJobs.size < MAX_PARALLEL_JOBS) { | |
| 318 | + const claimed = await claimJob(); | |
| 319 | + if (claimed) { | |
| 320 | + const p = runJob(claimed, log) | |
| 321 | + .catch((e) => log.warn(`crawl ${claimed.id} crashed: ${(e as Error).message}`)) | |
| 322 | + .finally(() => activeJobs.delete(claimed.id)); | |
| 323 | + activeJobs.set(claimed.id, p); | |
| 324 | + continue; | |
| 325 | + } | |
| 326 | + } | |
| 327 | + } catch (e) { | |
| 328 | + log.warn(`crawl worker loop error: ${(e as Error).message}`); | |
| 329 | + } | |
| 330 | + await new Promise<void>((r) => { | |
| 331 | + wake = r; | |
| 332 | + setTimeout(r, 4000); | |
| 333 | + }); | |
| 334 | + wake = null; | |
| 335 | + } | |
| 336 | + }; | |
| 337 | + void loop(); | |
| 338 | + log.info(`crawl worker started (${WORKER_ID}, parallel jobs=${MAX_PARALLEL_JOBS})`); | |
| 339 | + return async () => { | |
| 340 | + stopped = true; | |
| 341 | + kick(); | |
| 342 | + await Promise.allSettled([...activeJobs.values()]); | |
| 343 | + running = false; | |
| 344 | + }; | |
| 345 | +} | |
| 346 | + | |
| 347 | +async function claimJob(): Promise<CrawlJob | null> { | |
| 348 | + const rows = await db.execute(sql` | |
| 349 | + update crawl_jobs set status = 'running', worker_id = ${WORKER_ID}, started_at = coalesce(started_at, now()), heartbeat_at = now() | |
| 350 | + where id = (select id from crawl_jobs where status = 'queued' order by created_at asc limit 1 for update skip locked) | |
| 351 | + returning * | |
| 352 | + `); | |
| 353 | + const r = (rows as unknown as { rows: Record<string, unknown>[] }).rows?.[0]; | |
| 354 | + if (!r) return null; | |
| 355 | + const [job] = await db.select().from(crawlJobs).where(eq(crawlJobs.id, r.id as string)).limit(1); | |
| 356 | + return job ?? null; | |
| 357 | +} | |
| 358 | + | |
| 359 | +interface Frontier { | |
| 360 | + url: string; | |
| 361 | + depth: number; | |
| 362 | + parent: string | null; | |
| 363 | +} | |
| 364 | + | |
| 365 | +async function runJob(job: CrawlJob, log: { info: (m: string) => void; warn: (m: string) => void }): Promise<void> { | |
| 366 | + const opts = crawlCreateSchema.parse(job.options) as CrawlCreateInput; | |
| 367 | + const { principalForCrawl } = await import("../auth"); | |
| 368 | + let principal: ApiPrincipal; | |
| 369 | + try { | |
| 370 | + principal = await principalForCrawl(job.projectId, job.apiKeyId); | |
| 371 | + } catch (e) { | |
| 372 | + await finishJob(job.id, "failed", { code: (e as FetchaError).code ?? "INTERNAL_ERROR", message: (e as Error).message }); | |
| 373 | + return; | |
| 374 | + } | |
| 375 | + const seedUrl = new URL(job.seedUrl); | |
| 376 | + const inScope = scopeFilter(seedUrl, opts.same_domain, opts.allow_subdomains); | |
| 377 | + const include = urlPatternMatcher(opts.include_patterns); | |
| 378 | + const exclude = urlPatternMatcher(opts.exclude_patterns); | |
| 379 | + const seen = new Set<string>(); | |
| 380 | + const frontier: Frontier[] = []; | |
| 381 | + let discovered = 0; | |
| 382 | + let fetched = 0; | |
| 383 | + let ok = 0; | |
| 384 | + let blocked = 0; | |
| 385 | + let failed = 0; | |
| 386 | + let bytes = 0; | |
| 387 | + let costUsd = 0; | |
| 388 | + let robots: RobotsRules | null = null; | |
| 389 | + let delayMs = opts.delay_ms; | |
| 390 | + | |
| 391 | + // Resume support: already-known pages of this job. | |
| 392 | + const existing = await db.select({ url: crawlPages.url, status: crawlPages.status, depth: crawlPages.depth, parentUrl: crawlPages.parentUrl }).from(crawlPages).where(eq(crawlPages.jobId, job.id)); | |
| 393 | + for (const p of existing) { | |
| 394 | + seen.add(p.url); | |
| 395 | + discovered++; | |
| 396 | + if (p.status === "pending") frontier.push({ url: p.url, depth: p.depth, parent: p.parentUrl }); | |
| 397 | + else { | |
| 398 | + fetched++; | |
| 399 | + if (p.status === "success") ok++; | |
| 400 | + else if (p.status === "blocked") blocked++; | |
| 401 | + else if (p.status === "failed") failed++; | |
| 402 | + } | |
| 403 | + } | |
| 404 | + bytes = job.bytes; | |
| 405 | + costUsd = job.costUsd; | |
| 406 | + | |
| 407 | + const fetchPage = async (url: string, extra: Record<string, unknown> = {}): Promise<FetchHandlerOutput | FetchaError> => { | |
| 408 | + try { | |
| 409 | + return await handleFetch({ | |
| 410 | + principal, | |
| 411 | + body: { | |
| 412 | + url, | |
| 413 | + format: opts.format === "html" ? "html" : opts.format, | |
| 414 | + timeout: opts.timeout, | |
| 415 | + country: opts.country, | |
| 416 | + network: opts.network, | |
| 417 | + browser: opts.browser, | |
| 418 | + browser_fallback: opts.browser_fallback, | |
| 419 | + headers: opts.headers, | |
| 420 | + links: true, | |
| 421 | + ...extra, | |
| 422 | + }, | |
| 423 | + source: "crawl", | |
| 424 | + clientIp: null, | |
| 425 | + userAgent: `fetcha-crawl/${job.id}`, | |
| 426 | + }); | |
| 427 | + } catch (e) { | |
| 428 | + return e instanceof FetchaError ? e : new FetchaError("INTERNAL_ERROR", (e as Error).message); | |
| 429 | + } | |
| 430 | + }; | |
| 431 | + | |
| 432 | + // robots.txt + optional sitemap seeding | |
| 433 | + if (opts.respect_robots) { | |
| 434 | + const r = await fetchPage(`${seedUrl.origin}/robots.txt`, { format: "raw", links: false, browser: false, browser_fallback: false, retries: 1, timeout: Math.min(opts.timeout, 15_000) }); | |
| 435 | + if (!(r instanceof FetchaError)) { | |
| 436 | + const b = r.body as FetchResponseBody; | |
| 437 | + if (b.success && b.content && (b.content_type ?? "").includes("text/plain")) { | |
| 438 | + robots = parseRobots(b.content, "fetchabot"); | |
| 439 | + if (robots.crawlDelayMs && robots.crawlDelayMs > delayMs) delayMs = Math.min(robots.crawlDelayMs, 10_000); | |
| 440 | + } | |
| 441 | + } | |
| 442 | + } | |
| 443 | + const enqueue = async (candidates: Array<{ url: string; depth: number; parent: string | null }>) => { | |
| 444 | + const rows: Array<{ id: string; jobId: string; url: string; depth: number; parentUrl: string | null; status: string }> = []; | |
| 445 | + for (const c of candidates) { | |
| 446 | + if (discovered >= opts.max_pages * 3 || frontier.length + fetched >= opts.max_pages) break; | |
| 447 | + const n = normalizeCrawlUrl(c.url); | |
| 448 | + if (!n || seen.has(n)) continue; | |
| 449 | + if (!inScope(n) || !looksLikePage(n)) continue; | |
| 450 | + if (exclude && exclude(n)) continue; | |
| 451 | + if (include && !include(n) && c.depth > 0) continue; | |
| 452 | + if (robots && !robotsAllows(robots, n)) continue; | |
| 453 | + seen.add(n); | |
| 454 | + discovered++; | |
| 455 | + frontier.push({ url: n, depth: c.depth, parent: c.parent }); | |
| 456 | + rows.push({ id: newId("cpg"), jobId: job.id, url: n, depth: c.depth, parentUrl: c.parent, status: "pending" }); | |
| 457 | + } | |
| 458 | + if (rows.length) await db.insert(crawlPages).values(rows).onConflictDoNothing().catch(() => {}); | |
| 459 | + }; | |
| 460 | + if (opts.use_sitemap && fetched === 0) { | |
| 461 | + const sitemapUrls = new Set<string>([`${seedUrl.origin}/sitemap.xml`]); | |
| 462 | + for (const s of robots?.sitemaps ?? []) sitemapUrls.add(s); | |
| 463 | + let count = 0; | |
| 464 | + for (const su of sitemapUrls) { | |
| 465 | + if (count++ >= 5 || frontier.length >= opts.max_pages) break; | |
| 466 | + const r = await fetchPage(su, { format: "raw", links: false, browser: false, browser_fallback: false, retries: 1 }); | |
| 467 | + if (r instanceof FetchaError) continue; | |
| 468 | + const b = r.body as FetchResponseBody; | |
| 469 | + if (!b.success || !b.content) continue; | |
| 470 | + const { urls, sitemaps } = parseSitemap(b.content); | |
| 471 | + for (const s of sitemaps.slice(0, 5)) sitemapUrls.add(s); | |
| 472 | + await enqueue(urls.slice(0, opts.max_pages).map((u) => ({ url: u, depth: 1, parent: su }))); | |
| 473 | + } | |
| 474 | + } | |
| 475 | + | |
| 476 | + let lastHeartbeat = 0; | |
| 477 | + let lastPersist = 0; | |
| 478 | + const persistStats = async (force = false) => { | |
| 479 | + const now = Date.now(); | |
| 480 | + if (!force && now - lastPersist < 2000) return; | |
| 481 | + lastPersist = now; | |
| 482 | + await db | |
| 483 | + .update(crawlJobs) | |
| 484 | + .set({ pagesDiscovered: discovered, pagesFetched: fetched, pagesOk: ok, pagesBlocked: blocked, pagesFailed: failed, bytes, costUsd, heartbeatAt: new Date() }) | |
| 485 | + .where(eq(crawlJobs.id, job.id)) | |
| 486 | + .catch(() => {}); | |
| 487 | + lastHeartbeat = now; | |
| 488 | + }; | |
| 489 | + const isCancelled = async (): Promise<boolean> => { | |
| 490 | + if (cancelled.has(job.id)) return true; | |
| 491 | + if (Date.now() - lastHeartbeat > HEARTBEAT_MS) { | |
| 492 | + const [row] = await db.select({ status: crawlJobs.status }).from(crawlJobs).where(eq(crawlJobs.id, job.id)).limit(1); | |
| 493 | + lastHeartbeat = Date.now(); | |
| 494 | + if (row?.status === "cancelled") { | |
| 495 | + cancelled.add(job.id); | |
| 496 | + return true; | |
| 497 | + } | |
| 498 | + } | |
| 499 | + return false; | |
| 500 | + }; | |
| 501 | + | |
| 502 | + const worker = async () => { | |
| 503 | + while (!stopped) { | |
| 504 | + if (fetched >= opts.max_pages) return; | |
| 505 | + if (await isCancelled()) return; | |
| 506 | + const item = frontier.shift(); | |
| 507 | + if (!item) return; | |
| 508 | + fetched++; | |
| 509 | + const t0 = Date.now(); | |
| 510 | + const r = await fetchPage(item.url); | |
| 511 | + const durationMs = Date.now() - t0; | |
| 512 | + const update: Partial<typeof crawlPages.$inferInsert> = { fetchedAt: new Date(), durationMs }; | |
| 513 | + if (r instanceof FetchaError) { | |
| 514 | + failed++; | |
| 515 | + Object.assign(update, { status: "failed", errorCode: r.code, requestId: r.requestId ?? null }); | |
| 516 | + } else { | |
| 517 | + const b = r.body as FetchResponseBody; | |
| 518 | + const content = opts.format === "markdown" ? b.markdown : opts.format === "text" ? b.text : b.content; | |
| 519 | + const isBlocked = !b.success && b.status >= 400 && r.summary && b.metadata.attempts > 0 && !(b.status === 404 || b.status === 410); | |
| 520 | + bytes += r.summary?.bytes ?? b.metadata.bytes; | |
| 521 | + costUsd += r.summary?.costUsd ?? 0; | |
| 522 | + if (b.success) ok++; | |
| 523 | + else if (isBlocked && b.status !== 500) blocked++; | |
| 524 | + else failed++; | |
| 525 | + Object.assign(update, { | |
| 526 | + status: b.success ? "success" : isBlocked && b.status !== 500 ? "blocked" : "failed", | |
| 527 | + httpStatus: b.status, | |
| 528 | + errorCode: b.success ? null : b.status === 403 || b.status === 429 || b.status === 503 ? "TARGET_BLOCKED" : `HTTP_${b.status}`, | |
| 529 | + requestId: b.request_id, | |
| 530 | + finalUrl: b.final_url, | |
| 531 | + title: b.page?.title ?? null, | |
| 532 | + description: b.page?.description ?? null, | |
| 533 | + contentType: b.content_type, | |
| 534 | + content: b.success && typeof content === "string" ? content.slice(0, PAGE_CONTENT_CAP) : null, | |
| 535 | + linksCount: b.page?.links_count ?? 0, | |
| 536 | + bytes: r.summary?.bytes ?? b.metadata.bytes, | |
| 537 | + mode: b.metadata.mode, | |
| 538 | + }); | |
| 539 | + if (b.success && item.depth < opts.max_depth && b.links?.length) { | |
| 540 | + await enqueue(b.links.filter((l) => l.internal || !opts.same_domain).map((l) => ({ url: l.url, depth: item.depth + 1, parent: item.url }))); | |
| 541 | + } | |
| 542 | + } | |
| 543 | + await db.update(crawlPages).set(update).where(and(eq(crawlPages.jobId, job.id), eq(crawlPages.url, item.url))).catch(() => {}); | |
| 544 | + await persistStats(); | |
| 545 | + if (delayMs > 0) await new Promise((res) => setTimeout(res, delayMs)); | |
| 546 | + } | |
| 547 | + }; | |
| 548 | + | |
| 549 | + try { | |
| 550 | + await Promise.all(Array.from({ length: Math.max(1, Math.min(opts.concurrency, 10)) }, () => worker())); | |
| 551 | + await persistStats(true); | |
| 552 | + if (cancelled.has(job.id)) { | |
| 553 | + await finishJob(job.id, "cancelled"); | |
| 554 | + cancelled.delete(job.id); | |
| 555 | + } else if (stopped && frontier.length) { | |
| 556 | + await db.update(crawlJobs).set({ status: "queued", workerId: null }).where(eq(crawlJobs.id, job.id)).catch(() => {}); | |
| 557 | + } else { | |
| 558 | + await finishJob(job.id, "completed"); | |
| 559 | + } | |
| 560 | + log.info(`crawl ${job.id} ${cancelled.has(job.id) ? "cancelled" : "done"}: ${fetched} fetched, ${ok} ok, ${blocked} blocked, ${failed} failed`); | |
| 561 | + if (opts.webhook_url && !stopped) await deliverWebhook(job.id, opts.webhook_url).catch(() => {}); | |
| 562 | + } catch (e) { | |
| 563 | + await persistStats(true); | |
| 564 | + await finishJob(job.id, "failed", { code: "INTERNAL_ERROR", message: (e as Error).message?.slice(0, 500) }); | |
| 565 | + throw e; | |
| 566 | + } | |
| 567 | +} | |
| 568 | + | |
| 569 | +async function finishJob(id: string, status: "completed" | "failed" | "cancelled", error?: { code: string; message?: string }) { | |
| 570 | + await db | |
| 571 | + .update(crawlJobs) | |
| 572 | + .set({ status, completedAt: new Date(), errorCode: error?.code ?? null, errorMessage: error?.message ?? null }) | |
| 573 | + .where(and(eq(crawlJobs.id, id), inArray(crawlJobs.status, ["running", "queued", "cancelled"]))) | |
| 574 | + .catch(() => {}); | |
| 575 | +} | |
| 576 | + | |
| 577 | +async function deliverWebhook(jobId: string, url: string) { | |
| 578 | + const [job] = await db.select().from(crawlJobs).where(eq(crawlJobs.id, jobId)).limit(1); | |
| 579 | + if (!job) return; | |
| 580 | + let status = "failed"; | |
| 581 | + try { | |
| 582 | + await assertUrlAllowed(url); | |
| 583 | + const ac = new AbortController(); | |
| 584 | + const t = setTimeout(() => ac.abort(), 10_000); | |
| 585 | + const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json", "user-agent": "fetcha-webhook/0.2" }, body: JSON.stringify({ type: "crawl.completed", job: serializeJob(job) }), signal: ac.signal }); | |
| 586 | + clearTimeout(t); | |
| 587 | + status = res.ok ? `delivered:${res.status}` : `failed:${res.status}`; | |
| 588 | + } catch (e) { | |
| 589 | + status = `failed:${(e as Error).message?.slice(0, 80)}`; | |
| 590 | + } | |
| 591 | + await db.update(crawlJobs).set({ webhookStatus: status }).where(eq(crawlJobs.id, jobId)).catch(() => {}); | |
| 592 | +} | |
modified
apps/api/src/services/engine.ts
+14 −2
@@ -1,6 +1,7 @@ | ||
| 1 | −import { db, providerConfigs, providerHealth, domainProfiles, eq } from "@fetcha/db"; | |
| 1 | +import { db, providerConfigs, providerHealth, domainProfiles, featureFlags, eq } from "@fetcha/db"; | |
| 2 | 2 | import { ProviderRegistry, type ProviderId } from "@fetcha/providers"; |
| 3 | 3 | import { CircuitBreaker, FetchExecutor, RoutingEngine, type DomainKnowledge } from "@fetcha/routing"; |
| 4 | +import { getBrowserPool, type BrowserPool } from "@fetcha/browser"; | |
| 4 | 5 | import { newId } from "@fetcha/core"; |
| 5 | 6 | import { config } from "../config"; |
| 6 | 7 | |
@@ -9,6 +10,9 @@ export interface Engine { | ||
| 9 | 10 | circuit: CircuitBreaker; |
| 10 | 11 | routing: RoutingEngine; |
| 11 | 12 | executor: FetchExecutor; |
| 13 | + browser: BrowserPool; | |
| 14 | + /** Global kill-switch from the `browser_enabled` feature flag (default on). */ | |
| 15 | + browserEnabled: boolean; | |
| 12 | 16 | reload(): Promise<void>; |
| 13 | 17 | probeAll(): Promise<void>; |
| 14 | 18 | knowledge(domain: string): Promise<DomainKnowledge | null>; |
@@ -22,12 +26,18 @@ export async function getEngine(): Promise<Engine> { | ||
| 22 | 26 | let registry = new ProviderRegistry(); |
| 23 | 27 | let routing = new RoutingEngine(registry, circuit); |
| 24 | 28 | let executor = new FetchExecutor(routing, circuit); |
| 29 | + const browser = getBrowserPool({ log: { info: (m) => console.log("[browser]", m), warn: (m) => console.warn("[browser]", m) } }); | |
| 30 | + let browserEnabled = true; | |
| 25 | 31 | |
| 26 | 32 | const engine: Engine = { |
| 27 | 33 | get registry() { |
| 28 | 34 | return registry; |
| 29 | 35 | }, |
| 30 | 36 | circuit, |
| 37 | + browser, | |
| 38 | + get browserEnabled() { | |
| 39 | + return browserEnabled && browser.enabled; | |
| 40 | + }, | |
| 31 | 41 | get routing() { |
| 32 | 42 | return routing; |
| 33 | 43 | }, |
@@ -46,6 +56,8 @@ export async function getEngine(): Promise<Engine> { | ||
| 46 | 56 | registry = new ProviderRegistry({ prices, disabled }); |
| 47 | 57 | routing = new RoutingEngine(registry, circuit); |
| 48 | 58 | executor = new FetchExecutor(routing, circuit); |
| 59 | + const [flag] = await db.select().from(featureFlags).where(eq(featureFlags.key, "browser_enabled")).limit(1).catch(() => []); | |
| 60 | + browserEnabled = flag ? flag.enabled : true; | |
| 49 | 61 | }, |
| 50 | 62 | async probeAll() { |
| 51 | 63 | const results = await Promise.all( |
@@ -62,7 +74,7 @@ export async function getEngine(): Promise<Engine> { | ||
| 62 | 74 | if (!domain) return null; |
| 63 | 75 | const [row] = await db.select().from(domainProfiles).where(eq(domainProfiles.domain, domain)).limit(1).catch(() => []); |
| 64 | 76 | if (!row) return null; |
| 65 | − return { domain, routeStats: row.routeStats ?? {}, policy: row.policy ?? null, browserRequiredRate: row.requests ? row.browserRequired / row.requests : 0 }; | |
| 77 | + return { domain, routeStats: row.routeStats ?? {}, policy: row.policy ?? null, browserRequiredRate: row.requests ? row.browserRequired / row.requests : 0, browserSamples: row.requests }; | |
| 66 | 78 | }, |
| 67 | 79 | }; |
| 68 | 80 | await engine.reload(); |
modified
apps/api/src/services/persist.ts
+11 −5
@@ -8,7 +8,7 @@ export interface RequestContext { | ||
| 8 | 8 | organizationId: string; |
| 9 | 9 | projectId: string; |
| 10 | 10 | apiKeyId: string | null; |
| 11 | − source: "api" | "playground" | "sdk"; | |
| 11 | + source: "api" | "playground" | "sdk" | "crawl"; | |
| 12 | 12 | plan: Plan; |
| 13 | 13 | clientIp: string | null; |
| 14 | 14 | userAgent: string | null; |
@@ -46,12 +46,13 @@ export async function recordAttempt(requestId: string, a: AttemptRecord): Promis | ||
| 46 | 46 | attemptNo: a.attemptNo, |
| 47 | 47 | provider: a.provider, |
| 48 | 48 | network: a.network, |
| 49 | + mode: a.mode, | |
| 49 | 50 | country: a.country, |
| 50 | 51 | sessionKey: a.sessionKey, |
| 51 | 52 | outcome: a.outcome, |
| 52 | 53 | httpStatus: a.httpStatus, |
| 53 | 54 | errorCode: a.errorCode, |
| 54 | − errorDetail: a.errorDetail, | |
| 55 | + errorDetail: a.errorDetail ?? (a.blockVendor ? `vendor=${a.blockVendor} profile=${a.profileId ?? "-"}` : a.profileId ? `profile=${a.profileId}` : null), | |
| 55 | 56 | blockReason: a.blockReason, |
| 56 | 57 | durationMs: a.durationMs, |
| 57 | 58 | bytesIn: a.bytesIn, |
@@ -83,6 +84,8 @@ export async function completeRequest(ctx: RequestContext, req: FetchRequest, re | ||
| 83 | 84 | errorMessage: result.body.success ? null : "The target blocked every route we tried.", |
| 84 | 85 | finalUrl: result.finalUrl, |
| 85 | 86 | network, |
| 87 | + mode: result.mode, | |
| 88 | + browser: result.mode === "browser", | |
| 86 | 89 | attempts: result.attempts.length, |
| 87 | 90 | latencyMs, |
| 88 | 91 | bytesIn: result.bytesIn, |
@@ -96,7 +99,7 @@ export async function completeRequest(ctx: RequestContext, req: FetchRequest, re | ||
| 96 | 99 | .where(sql`${fetchRequests.id} = ${ctx.requestId}`); |
| 97 | 100 | |
| 98 | 101 | await writeUsage(ctx, result, price.totalUsd, network); |
| 99 | − await Promise.all([updateDomainProfile(result.domain, result.attempts, result.body.success), updateRoutingMetrics(result.attempts)]); | |
| 102 | + await Promise.all([updateDomainProfile(result.domain, result.attempts, result.body.success, result.browserRequired), updateRoutingMetrics(result.attempts)]); | |
| 100 | 103 | return { priceUsd: price.totalUsd, costUsd: result.costUsd }; |
| 101 | 104 | } |
| 102 | 105 | |
@@ -118,7 +121,7 @@ export async function failRequest(ctx: RequestContext, code: string, message: st | ||
| 118 | 121 | costUsd: 0, |
| 119 | 122 | upstreamCostUsd: costUsd, |
| 120 | 123 | }); |
| 121 | − if (attempts.length) await Promise.all([updateDomainProfile(domain, attempts, false), updateRoutingMetrics(attempts)]); | |
| 124 | + if (attempts.length) await Promise.all([updateDomainProfile(domain, attempts, false, false), updateRoutingMetrics(attempts)]); | |
| 122 | 125 | } |
| 123 | 126 | |
| 124 | 127 | async function writeUsage(ctx: RequestContext, result: ExecutionResult, priceUsd: number, network: ConcreteNetwork | null) { |
@@ -134,7 +137,7 @@ async function writeUsage(ctx: RequestContext, result: ExecutionResult, priceUsd | ||
| 134 | 137 | ); |
| 135 | 138 | } |
| 136 | 139 | |
| 137 | −async function updateDomainProfile(domain: string, attempts: AttemptRecord[], success: boolean) { | |
| 140 | +async function updateDomainProfile(domain: string, attempts: AttemptRecord[], success: boolean, browserRequired: boolean) { | |
| 138 | 141 | if (!domain) return; |
| 139 | 142 | const [existing] = await db.select().from(domainProfiles).where(sql`${domainProfiles.domain} = ${domain}`).limit(1); |
| 140 | 143 | const stats = { ...(existing?.routeStats ?? {}) }; |
@@ -145,6 +148,7 @@ async function updateDomainProfile(domain: string, attempts: AttemptRecord[], su | ||
| 145 | 148 | if (a.outcome === "blocked") blocks++; |
| 146 | 149 | if (a.blockReason === "captcha" || a.blockReason === "cloudflare_challenge") captchas++; |
| 147 | 150 | } |
| 151 | + const browserInc = browserRequired ? 1 : 0; | |
| 148 | 152 | const pref = preferredRoute(stats); |
| 149 | 153 | const totalLatency = attempts.reduce((s, a) => s + a.durationMs, 0); |
| 150 | 154 | const n = (existing?.requests ?? 0) + 1; |
@@ -159,6 +163,7 @@ async function updateDomainProfile(domain: string, attempts: AttemptRecord[], su | ||
| 159 | 163 | successes: success ? 1 : 0, |
| 160 | 164 | blocks, |
| 161 | 165 | captchas, |
| 166 | + browserRequired: browserInc, | |
| 162 | 167 | avgLatencyMs: totalLatency, |
| 163 | 168 | routeStats: stats, |
| 164 | 169 | lastSeenAt: new Date(), |
@@ -172,6 +177,7 @@ async function updateDomainProfile(domain: string, attempts: AttemptRecord[], su | ||
| 172 | 177 | successes: sql`${domainProfiles.successes} + ${success ? 1 : 0}`, |
| 173 | 178 | blocks: sql`${domainProfiles.blocks} + ${blocks}`, |
| 174 | 179 | captchas: sql`${domainProfiles.captchas} + ${captchas}`, |
| 180 | + browserRequired: sql`${domainProfiles.browserRequired} + ${browserInc}`, | |
| 175 | 181 | avgLatencyMs: avgLatency, |
| 176 | 182 | routeStats: stats, |
| 177 | 183 | lastSeenAt: new Date(), |
modified
apps/api/src/services/pricing.ts
+5 −0
@@ -27,6 +27,11 @@ export interface PriceBreakdown { | ||
| 27 | 27 | |
| 28 | 28 | export function priceRequest(input: PriceInput): PriceBreakdown { |
| 29 | 29 | const limits = PLAN_LIMITS[input.plan]; |
| 30 | + // Private platform: the single plan has no unit prices → nothing is billed. Upstream cost is | |
| 31 | + // still tracked for the admin's unit economics. | |
| 32 | + if (limits.price_usd_month === 0 && limits.overage_per_1k_requests_usd === 0 && limits.residential_per_gb_usd === 0) { | |
| 33 | + return { requestUsd: 0, bandwidthUsd: 0, totalUsd: 0, marginUsd: round6(-input.upstreamCostUsd) }; | |
| 34 | + } | |
| 30 | 35 | // Free/enterprise have no per-request rate; failed requests are never charged a request fee. |
| 31 | 36 | const requestUsd = input.success ? limits.overage_per_1k_requests_usd / 1000 : 0; |
| 32 | 37 | let bandwidthUsd = 0; |
modified
apps/api/src/services/retention.ts
+10 −0
@@ -19,6 +19,16 @@ export async function pruneRetention(log: { info: (m: string) => void } = consol | ||
| 19 | 19 | `); |
| 20 | 20 | total += Number((res as { rowCount?: number }).rowCount ?? 0); |
| 21 | 21 | } |
| 22 | + for (const plan of PLANS) { | |
| 23 | + const days = PLAN_LIMITS[plan].retention_days; | |
| 24 | + await db.execute(sql` | |
| 25 | + delete from crawl_jobs j | |
| 26 | + using organizations o | |
| 27 | + where j.organization_id = o.id | |
| 28 | + and o.plan = ${plan} | |
| 29 | + and j.created_at < now() - make_interval(days => ${days}) | |
| 30 | + `); | |
| 31 | + } | |
| 22 | 32 | await db.execute(sql`update proxy_sessions set status = 'expired' where status = 'active' and expires_at < now()`); |
| 23 | 33 | await db.execute(sql`delete from provider_health where checked_at < now() - interval '30 days'`); |
| 24 | 34 | await db.execute(sql`delete from verifications where expires_at < now() - interval '1 day'`); |
added
apps/web/src/actions/access.ts
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +"use server"; | |
| 2 | + | |
| 3 | +import { revalidatePath } from "next/cache"; | |
| 4 | +import { z } from "zod"; | |
| 5 | +import { getDb, signupAllowlist, eq, inArray } from "@fetcha/db"; | |
| 6 | +import { getEmailService } from "@fetcha/email"; | |
| 7 | +import { requireAdmin, requestMeta } from "@/lib/session"; | |
| 8 | +import { writeAudit } from "@/lib/audit"; | |
| 9 | +import { SITE_URL } from "@/lib/utils"; | |
| 10 | +import type { AuthUser } from "@/lib/auth"; | |
| 11 | +import type { AdminActionResult } from "@/actions/admin"; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Signup allowlist management (invitation-only platform). Every mutation is audited as | |
| 15 | + * `admin.action` with metadata.type in { access.allow, access.invite, access.revoke }. | |
| 16 | + */ | |
| 17 | + | |
| 18 | +async function audit(admin: AuthUser, type: "access.allow" | "access.invite" | "access.revoke", target: string, metadata: Record<string, unknown> = {}) { | |
| 19 | + const meta = await requestMeta(); | |
| 20 | + await writeAudit({ userId: admin.id, action: "admin.action", target, metadata: { type, ...metadata }, ipAddress: meta.ip, userAgent: meta.userAgent }); | |
| 21 | +} | |
| 22 | + | |
| 23 | +function fail(e: unknown): { ok: false; error: string } { | |
| 24 | + if (e instanceof z.ZodError) return { ok: false, error: e.issues[0]?.message ?? "Invalid input" }; | |
| 25 | + return { ok: false, error: (e as Error)?.message ?? "Unexpected error" }; | |
| 26 | +} | |
| 27 | + | |
| 28 | +function revalidate() { | |
| 29 | + revalidatePath("/admin/access"); | |
| 30 | +} | |
| 31 | + | |
| 32 | +const emailSchema = z.string().trim().toLowerCase().email("Invalid email address").max(254); | |
| 33 | + | |
| 34 | +/** Split a comma / newline / whitespace separated list into unique, lower-cased, valid emails. */ | |
| 35 | +function parseEmailList(raw: string): { emails: string[]; invalid: string[] } { | |
| 36 | + const seen = new Set<string>(); | |
| 37 | + const emails: string[] = []; | |
| 38 | + const invalid: string[] = []; | |
| 39 | + for (const token of raw.split(/[\s,;]+/)) { | |
| 40 | + const t = token.trim(); | |
| 41 | + if (!t) continue; | |
| 42 | + const r = emailSchema.safeParse(t); | |
| 43 | + if (!r.success) { | |
| 44 | + invalid.push(t); | |
| 45 | + continue; | |
| 46 | + } | |
| 47 | + if (seen.has(r.data)) continue; | |
| 48 | + seen.add(r.data); | |
| 49 | + emails.push(r.data); | |
| 50 | + } | |
| 51 | + return { emails, invalid }; | |
| 52 | +} | |
| 53 | + | |
| 54 | +function signupUrlFor(email: string): string { | |
| 55 | + return `${SITE_URL}/signup?email=${encodeURIComponent(email)}`; | |
| 56 | +} | |
| 57 | + | |
| 58 | +const allowSchema = z.object({ | |
| 59 | + emails: z.string().trim().min(3, "Enter at least one email address").max(20_000), | |
| 60 | + note: z.string().trim().max(280, "Note is too long (280 characters max)").optional().or(z.literal("")), | |
| 61 | + sendInvite: z.boolean().default(false), | |
| 62 | +}); | |
| 63 | + | |
| 64 | +export type AllowEmailsInput = z.input<typeof allowSchema>; | |
| 65 | +export type AllowEmailsData = { added: number; existing: number; invited: number; invalid: string[]; failed: string[] }; | |
| 66 | + | |
| 67 | +export async function allowEmails(input: AllowEmailsInput): Promise<AdminActionResult<AllowEmailsData>> { | |
| 68 | + const admin = await requireAdmin(); | |
| 69 | + try { | |
| 70 | + const d = allowSchema.parse(input); | |
| 71 | + const { emails, invalid } = parseEmailList(d.emails); | |
| 72 | + if (emails.length === 0) return { ok: false, error: invalid.length ? `No valid email address found (rejected: ${invalid.slice(0, 3).join(", ")}${invalid.length > 3 ? "…" : ""}).` : "Enter at least one email address." }; | |
| 73 | + if (emails.length > 200) return { ok: false, error: "Add at most 200 addresses at a time." }; | |
| 74 | + | |
| 75 | + const db = getDb(); | |
| 76 | + const note = d.note?.trim() || null; | |
| 77 | + let invited = 0; | |
| 78 | + const failed: string[] = []; | |
| 79 | + | |
| 80 | + const already = new Set((await db.select({ email: signupAllowlist.email }).from(signupAllowlist).where(inArray(signupAllowlist.email, emails))).map((r) => r.email)); | |
| 81 | + const fresh = emails.filter((e) => !already.has(e)); | |
| 82 | + if (fresh.length) await db.insert(signupAllowlist).values(fresh.map((email) => ({ email, note, invitedByUserId: admin.id }))).onConflictDoNothing(); | |
| 83 | + // Existing entries keep their inviter; only refresh the note when one was provided. | |
| 84 | + if (note && already.size) await db.update(signupAllowlist).set({ note }).where(inArray(signupAllowlist.email, Array.from(already))); | |
| 85 | + const added = fresh.length; | |
| 86 | + const existing = already.size; | |
| 87 | + await audit(admin, "access.allow", emails.length === 1 ? emails[0]! : `${emails.length} emails`, { emails, note, added, existing }); | |
| 88 | + | |
| 89 | + if (d.sendInvite) { | |
| 90 | + const inviterName = admin.name?.trim() || admin.email; | |
| 91 | + for (const email of emails) { | |
| 92 | + try { | |
| 93 | + await getEmailService().sendInvite(email, { inviterName, signupUrl: signupUrlFor(email) }); | |
| 94 | + await db.update(signupAllowlist).set({ invitedAt: new Date() }).where(eq(signupAllowlist.email, email)); | |
| 95 | + invited++; | |
| 96 | + } catch (e) { | |
| 97 | + failed.push(email); | |
| 98 | + console.error("[access] invite failed", email, (e as Error).message); | |
| 99 | + } | |
| 100 | + } | |
| 101 | + await audit(admin, "access.invite", emails.length === 1 ? emails[0]! : `${emails.length} emails`, { emails, invited, failed }); | |
| 102 | + } | |
| 103 | + | |
| 104 | + revalidate(); | |
| 105 | + const warnings: string[] = []; | |
| 106 | + if (invalid.length) warnings.push(`${invalid.length} entr${invalid.length === 1 ? "y was" : "ies were"} not a valid email and ${invalid.length === 1 ? "was" : "were"} skipped: ${invalid.slice(0, 5).join(", ")}${invalid.length > 5 ? "…" : ""}`); | |
| 107 | + if (failed.length) warnings.push(`Invitation email could not be sent to ${failed.join(", ")}. Use “Resend invite” later.`); | |
| 108 | + return { ok: true, data: { added, existing, invited, invalid, failed }, warning: warnings.length ? warnings.join(" ") : undefined }; | |
| 109 | + } catch (e) { | |
| 110 | + return fail(e); | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +export async function resendInvite(email: string): Promise<AdminActionResult> { | |
| 115 | + const admin = await requireAdmin(); | |
| 116 | + try { | |
| 117 | + const target = emailSchema.parse(email); | |
| 118 | + const db = getDb(); | |
| 119 | + const [row] = await db.select().from(signupAllowlist).where(eq(signupAllowlist.email, target)).limit(1); | |
| 120 | + if (!row) return { ok: false, error: "This address is not on the access list." }; | |
| 121 | + if (row.userId) return { ok: false, error: "This address already has an account; there is nothing to invite." }; | |
| 122 | + const inviterName = admin.name?.trim() || admin.email; | |
| 123 | + await getEmailService().sendInvite(target, { inviterName, signupUrl: signupUrlFor(target) }); | |
| 124 | + await db.update(signupAllowlist).set({ invitedAt: new Date() }).where(eq(signupAllowlist.email, target)); | |
| 125 | + await audit(admin, "access.invite", target, { emails: [target], invited: 1, resend: true }); | |
| 126 | + revalidate(); | |
| 127 | + return { ok: true }; | |
| 128 | + } catch (e) { | |
| 129 | + return fail(e); | |
| 130 | + } | |
| 131 | +} | |
| 132 | + | |
| 133 | +export async function revokeAllow(email: string): Promise<AdminActionResult> { | |
| 134 | + const admin = await requireAdmin(); | |
| 135 | + try { | |
| 136 | + const target = emailSchema.parse(email); | |
| 137 | + const db = getDb(); | |
| 138 | + const [row] = await db.select({ email: signupAllowlist.email, userId: signupAllowlist.userId, note: signupAllowlist.note }).from(signupAllowlist).where(eq(signupAllowlist.email, target)).limit(1); | |
| 139 | + if (!row) return { ok: false, error: "This address is not on the access list." }; | |
| 140 | + if (row.userId) return { ok: false, error: "This address already has an account; ban the user instead." }; | |
| 141 | + await db.delete(signupAllowlist).where(eq(signupAllowlist.email, target)); | |
| 142 | + await audit(admin, "access.revoke", target, { note: row.note }); | |
| 143 | + revalidate(); | |
| 144 | + return { ok: true }; | |
| 145 | + } catch (e) { | |
| 146 | + return fail(e); | |
| 147 | + } | |
| 148 | +} | |
modified
apps/web/src/actions/admin.ts
+8 −13
@@ -118,20 +118,15 @@ export async function forceVerifyEmail(userId: string): Promise<AdminActionResul | ||
| 118 | 118 | // --------------------------------------------------------------------------- |
| 119 | 119 | // Organizations |
| 120 | 120 | // --------------------------------------------------------------------------- |
| 121 | +/** | |
| 122 | + * Single-plan platform: kept for API compatibility with older UI code, but plans can no longer be | |
| 123 | + * changed. Every organization is `unlimited` (see `normalizePlan` in @fetcha/core). | |
| 124 | + */ | |
| 121 | 125 | export async function updateOrganizationPlan(orgId: string, plan: string): Promise<AdminActionResult> { |
| 122 | − const admin = await requireAdmin(); | |
| 123 | − try { | |
| 124 | − const p = z.enum(PLANS).parse(plan); | |
| 125 | − const db = getDb(); | |
| 126 | − const [o] = await db.select({ id: organizations.id, plan: organizations.plan }).from(organizations).where(eq(organizations.id, orgId)).limit(1); | |
| 127 | − if (!o) return { ok: false, error: "Organization not found" }; | |
| 128 | − await db.update(organizations).set({ plan: p, updatedAt: new Date() }).where(eq(organizations.id, orgId)); | |
| 129 | − await audit(admin, "org.plan", orgId, { from: o.plan, to: p }, orgId); | |
| 130 | − revalidateAdmin(); | |
| 131 | − return { ok: true }; | |
| 132 | − } catch (e) { | |
| 133 | − return fail(e); | |
| 134 | − } | |
| 126 | + await requireAdmin(); | |
| 127 | + void orgId; | |
| 128 | + void plan; | |
| 129 | + return { ok: false, error: "Plans are not configurable on this platform" }; | |
| 135 | 130 | } |
| 136 | 131 | |
| 137 | 132 | export async function setOrganizationProviderVisibility(orgId: string, enabled: boolean): Promise<AdminActionResult> { |
added
apps/web/src/actions/crawls.ts
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +"use server"; | |
| 2 | + | |
| 3 | +import { revalidatePath } from "next/cache"; | |
| 4 | +import { crawlCreateSchema, mapCreateSchema } from "@fetcha/core"; | |
| 5 | +import { getWorkspace } from "@/lib/session"; | |
| 6 | +import { internalApi, InternalApiError, type CrawlJob, type MapResult } from "@/lib/api"; | |
| 7 | + | |
| 8 | +export interface CrawlActionError { | |
| 9 | + code: string; | |
| 10 | + message: string; | |
| 11 | + requestId: string | null; | |
| 12 | + details?: Record<string, unknown>; | |
| 13 | +} | |
| 14 | + | |
| 15 | +export type CrawlActionResult<T> = { ok: true; data: T } | { ok: false; error: CrawlActionError }; | |
| 16 | + | |
| 17 | +function invalid(issues: Array<{ path: string; message: string }>): CrawlActionError { | |
| 18 | + const first = issues[0]; | |
| 19 | + return { | |
| 20 | + code: "INVALID_REQUEST", | |
| 21 | + message: first ? `${first.path ? `${first.path}: ` : ""}${first.message}` : "The request is invalid.", | |
| 22 | + requestId: null, | |
| 23 | + details: { issues }, | |
| 24 | + }; | |
| 25 | +} | |
| 26 | + | |
| 27 | +function fromError(e: unknown, fallback: string): CrawlActionError { | |
| 28 | + if (e instanceof InternalApiError) return { code: e.code, message: e.message, requestId: e.requestId, details: e.details }; | |
| 29 | + console.error("[crawls] unexpected error", e); | |
| 30 | + return { code: "INTERNAL_ERROR", message: fallback, requestId: null }; | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** Strip empty strings / undefined so optional fields do not trip the strict schema. */ | |
| 34 | +function clean(input: unknown): Record<string, unknown> { | |
| 35 | + if (!input || typeof input !== "object") return {}; | |
| 36 | + return Object.fromEntries(Object.entries(input as Record<string, unknown>).filter(([, v]) => v !== undefined && v !== "" && v !== null)); | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** | |
| 40 | + * Start a crawl job for the current project. Validated with the shared `crawlCreateSchema`; the API | |
| 41 | + * enforces plan limits (pages per job, concurrent jobs). Never throws to the client. | |
| 42 | + */ | |
| 43 | +export async function createCrawl(input: unknown): Promise<CrawlActionResult<CrawlJob>> { | |
| 44 | + const ws = await getWorkspace(); | |
| 45 | + try { | |
| 46 | + const parsed = crawlCreateSchema.safeParse(clean(input)); | |
| 47 | + if (!parsed.success) return { ok: false, error: invalid(parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))) }; | |
| 48 | + const job = await internalApi.createCrawl(ws.project.id, ws.user.id, parsed.data); | |
| 49 | + if (!job || typeof job !== "object" || typeof job.id !== "string") { | |
| 50 | + return { ok: false, error: { code: "INTERNAL_ERROR", message: "The API returned an unexpected response.", requestId: null } }; | |
| 51 | + } | |
| 52 | + revalidatePath("/dashboard/crawls"); | |
| 53 | + return { ok: true, data: job }; | |
| 54 | + } catch (e) { | |
| 55 | + return { ok: false, error: fromError(e, "Could not start the crawl. Please try again.") }; | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** Cancel a queued or running crawl job. */ | |
| 60 | +export async function cancelCrawl(id: string): Promise<CrawlActionResult<{ id: string; status: "cancelled" }>> { | |
| 61 | + const ws = await getWorkspace(); | |
| 62 | + if (!/^crawl_[A-Za-z0-9]{4,64}$/.test(id)) return { ok: false, error: { code: "INVALID_REQUEST", message: "Invalid crawl id.", requestId: null } }; | |
| 63 | + try { | |
| 64 | + const res = await internalApi.cancelCrawl(ws.project.id, ws.user.id, id); | |
| 65 | + revalidatePath("/dashboard/crawls"); | |
| 66 | + revalidatePath(`/dashboard/crawls/${id}`); | |
| 67 | + return { ok: true, data: res }; | |
| 68 | + } catch (e) { | |
| 69 | + return { ok: false, error: fromError(e, "Could not cancel the crawl. Please try again.") }; | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +/** Discover the URLs of a site (sitemap + links), synchronously. */ | |
| 74 | +export async function runMap(input: unknown): Promise<CrawlActionResult<MapResult>> { | |
| 75 | + const ws = await getWorkspace(); | |
| 76 | + try { | |
| 77 | + const parsed = mapCreateSchema.safeParse(clean(input)); | |
| 78 | + if (!parsed.success) return { ok: false, error: invalid(parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))) }; | |
| 79 | + const result = await internalApi.mapSite(ws.project.id, ws.user.id, parsed.data); | |
| 80 | + if (!result || typeof result !== "object" || !Array.isArray(result.urls)) { | |
| 81 | + return { ok: false, error: { code: "INTERNAL_ERROR", message: "The API returned an unexpected response.", requestId: null } }; | |
| 82 | + } | |
| 83 | + return { ok: true, data: result }; | |
| 84 | + } catch (e) { | |
| 85 | + return { ok: false, error: fromError(e, "Could not map the site. Please try again.") }; | |
| 86 | + } | |
| 87 | +} | |
modified
apps/web/src/actions/playground.ts
+2 −7
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | "use server"; |
| 2 | 2 | |
| 3 | −import { PLAN_LIMITS, fetchRequestSchema, type FetchResponseBody, type Plan } from "@fetcha/core"; | |
| 3 | +import { PLAN_LIMITS, fetchRequestSchema, normalizePlan, type FetchResponseBody } from "@fetcha/core"; | |
| 4 | 4 | import { getWorkspace } from "@/lib/session"; |
| 5 | 5 | import { internalApi, InternalApiError } from "@/lib/api"; |
| 6 | 6 | |
@@ -39,8 +39,7 @@ export async function runPlayground(input: unknown): Promise<PlaygroundResult> { | ||
| 39 | 39 | } |
| 40 | 40 | |
| 41 | 41 | const request = parsed.data; |
| 42 | − const plan = (ws.organization.plan as Plan) in PLAN_LIMITS ? (ws.organization.plan as Plan) : "free"; | |
| 43 | − const limits = PLAN_LIMITS[plan]; | |
| 42 | + const limits = PLAN_LIMITS[normalizePlan(ws.organization.plan)]; | |
| 44 | 43 | if (request.timeout > limits.max_timeout_ms) request.timeout = limits.max_timeout_ms; |
| 45 | 44 | if (request.retries !== undefined && request.retries > limits.max_retries) request.retries = limits.max_retries; |
| 46 | 45 | if (request.network !== "auto" && !limits.networks.includes(request.network)) { |
@@ -49,10 +48,6 @@ export async function runPlayground(input: unknown): Promise<PlaygroundResult> { | ||
| 49 | 48 | error: { code: "NETWORK_UNAVAILABLE", message: `The "${request.network}" network is not included in the ${limits.label} plan.`, requestId: null }, |
| 50 | 49 | }; |
| 51 | 50 | } |
| 52 | − if (request.browser) { | |
| 53 | − return { ok: false, error: { code: "BROWSER_UNAVAILABLE", message: "Managed browser execution is not yet available.", requestId: null } }; | |
| 54 | − } | |
| 55 | − | |
| 56 | 51 | const data = (await internalApi.playgroundFetch(ws.project.id, ws.user.id, request)) as FetchResponseBody; |
| 57 | 52 | if (!data || typeof data !== "object" || typeof data.request_id !== "string") { |
| 58 | 53 | return { ok: false, error: { code: "INTERNAL_ERROR", message: "The API returned an unexpected response.", requestId: null } }; |
modified
apps/web/src/app/(auth)/login/login-form.tsx
+3 −3
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(auth)/signup/page.tsx
+4 −2
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(auth)/signup/signup-form.tsx
+33 −8
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/changelog/page.tsx
+23 −2
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/docs/authentication/page.tsx
+11 −11
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/docs/browser/page.tsx
+189 −45
Large diff (280 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/(marketing)/docs/crawl/page.tsx
+379 −0
Large diff (379 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/docs/errors/page.tsx
+6 −4
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/docs/fetch/page.tsx
+85 −21
Large diff (206 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/docs/networks/page.tsx
+30 −33
Large diff (123 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/docs/openapi.json/route.ts
+277 −20
Large diff (398 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/docs/page.tsx
+15 −11
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/docs/rate-limits/page.tsx
+83 −74
Large diff (274 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/docs/retries/page.tsx
+33 −26
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/page.tsx
+30 −25
Large diff (134 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/(marketing)/pricing/page.tsx
+127 −249
Large diff (431 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/admin/access/loading.tsx
+5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/admin/access/page.tsx
+159 −0
Large diff (159 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/admin/billing/page.tsx
+4 −4
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/admin/organizations/[id]/page.tsx
+6 −3
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/dashboard/billing/page.tsx
+86 −143
Large diff (282 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/dashboard/crawls/[id]/loading.tsx
+28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/dashboard/crawls/[id]/page.tsx
+344 −0
Large diff (344 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/dashboard/crawls/loading.tsx
+31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/dashboard/crawls/page.tsx
+179 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/dashboard/page.tsx
+5 −6
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/dashboard/playground/page.tsx
+4 −5
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/dashboard/projects/[id]/page.tsx
+3 −3
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/app/dashboard/usage/page.tsx
+18 −17
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/admin/access-controls.tsx
+149 −0
Large diff (149 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/admin/admin-shell.tsx
+2 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/admin/org-actions.tsx
+1 −36
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/admin/primitives.tsx
+3 −2
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
deleted
apps/web/src/components/dashboard/billing/upgrade-dialog.tsx
+0 −39
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/dashboard/crawls/cancel-crawl-button.tsx
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/dashboard/crawls/crawl-status-badge.tsx
+39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/dashboard/crawls/create-crawl-dialog.tsx
+201 −0
Large diff (201 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/dashboard/crawls/map-tool.tsx
+99 −0
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/dashboard/sidebar.tsx
+2 −1
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/docs/nav-config.ts
+2 −1
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/marketing/architecture-diagram.tsx
+2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/marketing/footer.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/marketing/nav.tsx
+5 −5
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/playground/request-builder.tsx
+102 −11
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/playground/results-panel.tsx
+124 −3
Large diff (208 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/playground/types.ts
+54 −1
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/access.ts
+20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/api.ts
+88 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/auth.ts
+22 −1
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/codegen.test.ts
+56 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/codegen.ts
+22 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/queries/account.ts
+13 −3
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/queries/admin.ts
+73 −4
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/session.ts
+2 −6
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
modified
deploy/README.md
+2 −1
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
docs/API-v0.2.md
+124 −0
Large diff (124 lines) is not loaded by default to keep the page fast.Load diff
added
packages/browser/package.json
+26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added
packages/browser/src/index.ts
+550 −0
Large diff (550 lines) is not loaded by default to keep the page fast.Load diff
added
packages/browser/src/stealth.ts
+126 −0
Large diff (126 lines) is not loaded by default to keep the page fast.Load diff
added
packages/browser/tsconfig.json
+8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/core/src/client.ts
+1 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/core/src/errors.ts
+10 −4
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/core/src/ids.ts
+3 −1
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/core/src/index.ts
+2 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added
packages/core/src/markdown.ts
+687 −0
Large diff (687 lines) is not loaded by default to keep the page fast.Load diff
added
packages/core/src/robots.ts
+101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/core/src/schema.ts
+150 −64
Large diff (273 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/core/src/text.ts
+180 −13
Large diff (228 lines) is not loaded by default to keep the page fast.Load diff
added
packages/core/test/markdown.test.ts
+80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/core/test/ssrf.test.ts
+7 −1
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
packages/db/drizzle/0001_furry_red_skull.sql
+76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added
packages/db/drizzle/meta/0001_snapshot.json
+3572 −0
Large diff (3,573 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/db/drizzle/meta/_journal.json
+7 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/db/src/schema.ts
+96 −1
Large diff (130 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/db/src/seed.ts
+47 −3
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/email/src/index.ts
+6 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/email/src/templates.tsx
+26 −1
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added
packages/providers/src/cookies.ts
+129 −0
Large diff (129 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/providers/src/decodo.ts
+6 −2
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/providers/src/direct.ts
+4 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
packages/providers/src/fingerprint.ts
+369 −0
Large diff (369 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/providers/src/http.ts
+171 −82
Large diff (367 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/providers/src/index.ts
+2 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/providers/src/oxylabs.ts
+6 −2
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/providers/src/soax.ts
+6 −2
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/providers/src/types.ts
+26 −1
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/routing/package.json
+1 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/routing/src/engine.ts
+3 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/routing/src/executor.ts
+230 −61
Large diff (514 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/routing/test/engine.test.ts
+7 −7
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/sdk/package.json
+1 −1
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/sdk/src/index.ts
+274 −9
Large diff (379 lines) is not loaded by default to keep the page fast.Load diff
modified
pnpm-lock.yaml
+54 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
modified
scripts/dev-seed.ts
+7 −4
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
modified
sdk-python/README.md
+28 −4
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
modified
sdk-python/fetcha/__init__.py
+266 −27
Large diff (390 lines) is not loaded by default to keep the page fast.Load diff
added
sdk-python/fetcha/__pycache__/__init__.cpython-314.pyc
+0 −0
Binary file not shown.
modified
sdk-python/pyproject.toml
+2 −2
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff