SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
35.9 KB · 700 lines typescript
Raw Blame History
1import { CRAWL_FORMATS, CRAWL_STATUSES, ERROR_CODES, ERROR_HTTP_STATUS, ERROR_MESSAGES, HTTP_METHODS, NETWORK_CLASSES, OUTPUT_FORMATS, DEVICES, PLAN_LIMITS } from "@fetcha/core";23/**4 * OpenAPI 3.1 description of the public Fetcha API, derived by hand from `fetchRequestSchema`,5 * `sessionCreateSchema`, `crawlCreateSchema`, `mapCreateSchema`, the error catalogue and the route6 * handlers. Served at /docs/openapi.json.7 */89const BASE_URL = "https://www.fetcha.co";1011const errorSchema = {12  type: "object",13  required: ["error"],14  properties: {15    error: {16      type: "object",17      required: ["code", "message", "request_id"],18      properties: {19        code: { type: "string", enum: [...ERROR_CODES], description: "Stable machine-readable error code." },20        message: { type: "string", description: "Human-readable explanation. May be more specific than the default message." },21        request_id: { type: ["string", "null"], description: "Request identifier, also sent as X-Fetcha-Request-ID." },22        details: {23          type: "object",24          additionalProperties: true,25          description: "Optional context: `issues` (INVALID_REQUEST), `retry_after_ms` (RATE_LIMITED), `limit` (CONCURRENCY_LIMIT), `limit`/`used` or `limit_usd`/`spent_usd` (USAGE_LIMIT_REACHED).",26        },27      },28    },29  },30} as const;3132function errorResponse(codes: string[]) {33  return {34    description: `Error. Possible codes: ${codes.join(", ")}.`,35    headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } },36    content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },37  };38}3940const byStatus = (codes: string[]) => {41  const out: Record<string, unknown> = {};42  const groups = new Map<number, string[]>();43  for (const c of codes) {44    const s = ERROR_HTTP_STATUS[c as keyof typeof ERROR_HTTP_STATUS];45    groups.set(s, [...(groups.get(s) ?? []), c]);46  }47  for (const [status, list] of [...groups.entries()].sort((a, b) => a[0] - b[0])) out[String(status)] = errorResponse(list);48  return out;49};5051const AUTH_ERRORS = ["INVALID_API_KEY", "EMAIL_NOT_VERIFIED", "FORBIDDEN"];5253const geoProps = {54  country: { type: "string", minLength: 2, maxLength: 2, description: "ISO 3166-1 alpha-2 country code of the exit IP (case-insensitive, normalised to upper case).", example: "CA" },55  region: { type: "string", maxLength: 64, description: "State or province. US states and Canadian provinces accept two-letter codes or names; other values are slugified.", example: "QC" },56  city: { type: "string", maxLength: 128, description: "City name, slugified.", example: "Quebec" },57} as const;5859const fetchRequest = {60  type: "object",61  additionalProperties: false,62  required: ["url"],63  properties: {64    url: { type: "string", minLength: 1, maxLength: 8192, format: "uri", description: "Absolute http(s) URL. Private, internal and non-http targets are refused (URL_NOT_ALLOWED)." },65    method: { type: "string", enum: [...HTTP_METHODS], default: "GET" },66    headers: { type: "object", additionalProperties: { type: "string", maxLength: 8192 }, maxProperties: 64, description: "Headers forwarded to the target; override Fetcha defaults." },67    cookies: { type: "object", additionalProperties: { type: "string", maxLength: 4096 }, description: "Cookies serialised into the Cookie header." },68    body: { oneOf: [{ type: "string", maxLength: 2_000_000 }, { type: "object", additionalProperties: true }], description: "Request body. Objects are JSON-serialised. Content-Type defaults to application/json. Ignored for GET/HEAD." },69    timeout: { type: "integer", minimum: 1000, maximum: 120_000, default: 30_000, description: "Overall deadline in ms for all attempts, including any browser render (max 120 s)." },70    ...geoProps,71    network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto", description: "Network class. Only `residential` is live; `auto` resolves to it. Other explicit classes return NETWORK_UNAVAILABLE." },72    session: { type: "string", maxLength: 64, description: "Session id (sess_…) from POST /v1/sessions." },73    browser: { type: "boolean", default: false, description: "Render the page in the managed headless browser routed through the same network, country and session." },74    browser_fallback: { type: "boolean", default: true, description: "When an HTTP attempt is blocked by a JavaScript challenge / anti-bot page, automatically retry in the browser." },75    javascript: { type: "boolean", default: true, description: "Browser: set false to render with scripting disabled." },76    wait_for: { type: "string", maxLength: 512, description: "Browser: CSS selector that must be present before capture." },77    wait_ms: { type: "integer", minimum: 0, maximum: 30_000, description: "Browser: extra settle time in ms after the wait condition." },78    wait_until: { type: "string", enum: ["load", "domcontentloaded", "networkidle"], default: "domcontentloaded", description: "Browser: navigation event to wait for." },79    block_resources: { type: "boolean", default: true, description: "Browser: skip images, fonts and media." },80    screenshot: { type: "boolean", default: false, description: "Browser: return a PNG screenshot (base64) in `screenshot`." },81    links: { type: "boolean", default: false, description: "Return every hyperlink of the page in `links[]` (absolute URLs)." },82    referer: { oneOf: [{ type: "string", enum: ["auto", "none"] }, { type: "string", format: "uri", maxLength: 2048 }], default: "auto", description: "Referer strategy: auto (none first, search-engine referer on retries), none, or a literal URL." },83    device: { type: "string", enum: [...DEVICES], description: "`mobile` sets an iPhone User-Agent (and viewport in the browser) unless one is provided. `tablet` currently has no effect." },84    locale: { type: "string", maxLength: 16, description: "Sets the Accept-Language header.", example: "fr-CA" },85    format: { type: "string", enum: [...OUTPUT_FORMATS], default: "html", description: "html/raw: body in `content`; text: readable text in `text` (content null); markdown: Markdown in `markdown` (content null); json: body in `content` and parsed value in `json`." },86    follow_redirects: { type: "boolean", default: true },87    max_redirects: { type: "integer", minimum: 0, maximum: 20, default: 10 },88    max_response_bytes: { type: "integer", minimum: 1024, maximum: 50_000_000, description: "Lower the response size cap for this request. Effective cap is min(value, 20 MB)." },89    cache: {90      type: "object",91      additionalProperties: false,92      properties: { enabled: { type: "boolean", default: false }, ttl: { type: "integer", minimum: 1, maximum: 86_400, default: 300 } },93      description: "Reserved. Accepted, ignored; metadata.cached is always false.",94    },95    retries: { type: "integer", minimum: 0, maximum: 5, default: 5, description: "Additional attempts after the first (max 5)." },96    debug: { type: "boolean", default: false, description: "Include metadata.debug.attempts." },97  },98} as const;99100const pageMetadata = {101  type: ["object", "null"],102  required: ["title", "description", "canonical", "lang", "og", "links_count"],103  properties: {104    title: { type: ["string", "null"] },105    description: { type: ["string", "null"] },106    canonical: { type: ["string", "null"] },107    lang: { type: ["string", "null"] },108    og: { type: "object", additionalProperties: { type: "string" }, description: "Open Graph properties." },109    links_count: { type: "integer" },110  },111  description: "Parsed page metadata (HTML responses only; null otherwise).",112} as const;113114const pageLink = {115  type: "object",116  required: ["url", "text", "internal", "nofollow"],117  properties: {118    url: { type: "string", format: "uri" },119    text: { type: "string" },120    internal: { type: "boolean", description: "Same registrable domain as the page." },121    nofollow: { type: "boolean" },122  },123} as const;124125const timing = {126  type: "object",127  required: ["dns_ms", "proxy_connect_ms", "tls_ms", "origin_ms", "processing_ms", "total_ms"],128  properties: {129    dns_ms: { type: "integer", description: "Hostname resolution for URL policy validation." },130    proxy_connect_ms: { type: "integer", description: "Reported as 0 in the current build (included in origin_ms)." },131    tls_ms: { type: "integer", description: "Reported as 0 in the current build (included in origin_ms)." },132    origin_ms: { type: "integer", description: "Time to first byte on the final attempt, including connection and upstream routing." },133    processing_ms: { type: "integer", description: "Decompression and format conversion." },134    total_ms: { type: "integer", description: "Whole request, all attempts." },135  },136} as const;137138const fetchResponse = {139  type: "object",140  required: ["request_id", "success", "status", "url", "final_url", "content", "content_type", "headers", "cookies", "metadata"],141  properties: {142    request_id: { type: "string", example: "req_k3j9d0f2a8b1c7e4" },143    success: { type: "boolean", description: "true for a 2xx/3xx final response that was not classified as a block page." },144    status: { type: "integer", description: "HTTP status returned by the target on the final attempt." },145    url: { type: "string" },146    final_url: { type: "string", description: "URL after redirects." },147    content: { type: ["string", "null"], description: "Body for html/raw/json formats (null for text). Binary content types are base64-encoded." },148    content_type: { type: ["string", "null"] },149    headers: { type: "object", additionalProperties: { type: "string" }, description: "Target response headers, lower-cased names, content-encoding removed." },150    cookies: {151      type: "array",152      items: { type: "object", required: ["name", "value"], properties: { name: { type: "string" }, value: { type: "string" }, domain: { type: "string" }, path: { type: "string" } } },153    },154    text: { type: ["string", "null"], description: "Only for format=text." },155    markdown: { type: ["string", "null"], description: "Only for format=markdown. Main content first, boilerplate removed." },156    json: { description: "Only for format=json when the body parsed as JSON." },157    page: { $ref: "#/components/schemas/PageMetadata" },158    links: { type: "array", items: { $ref: "#/components/schemas/PageLink" }, description: "Only with links=true on an HTML response." },159    screenshot: { type: "string", description: "Only with browser rendering and screenshot=true. PNG, base64." },160    metadata: {161      type: "object",162      required: ["network", "country", "mode", "attempts", "duration_ms", "bytes", "session", "cached"],163      properties: {164        network: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] },165        country: { type: ["string", "null"] },166        mode: { type: "string", enum: ["http", "browser"], description: "How the final attempt was made." },167        attempts: { type: "integer", minimum: 1 },168        duration_ms: { type: "integer" },169        bytes: { type: "integer", description: "Bytes transferred across all attempts." },170        session: { type: ["string", "null"] },171        cached: { type: "boolean", description: "Always false today." },172        timing: { $ref: "#/components/schemas/Timing" },173        debug: {174          type: "object",175          properties: {176            attempts: {177              type: "array",178              items: {179                type: "object",180                properties: {181                  provider: { type: "string", description: "Neutral route alias (network-a, network-b, …)." },182                  network: { type: "string" },183                  mode: { type: "string", enum: ["http", "browser"] },184                  country: { type: ["string", "null"] },185                  outcome: { type: "string", enum: ["success", "blocked", "timeout", "error", "provider_error", "too_large"] },186                  block_reason: { type: ["string", "null"], description: "Detector that classified a blocked attempt (cloudflare_challenge, datadome, captcha, soft_block, …)." },187                  status: { type: ["integer", "null"] },188                  duration_ms: { type: "integer" },189                },190              },191            },192          },193        },194      },195    },196  },197} as const;198199const sessionCreate = {200  type: "object",201  additionalProperties: false,202  properties: {203    ...geoProps,204    network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto" },205    ttl: { type: "integer", minimum: 60, maximum: 1800, default: 600, description: "Lifetime in seconds, fixed at creation." },206    label: { type: "string", maxLength: 128 },207  },208} as const;209210const session = {211  type: "object",212  required: ["id", "label", "status", "network", "country", "region", "city", "request_count", "last_used_at", "expires_at", "created_at"],213  properties: {214    id: { type: "string", example: "sess_8f2k1m9d3p7q4r6s" },215    label: { type: ["string", "null"] },216    status: { type: "string", enum: ["active", "expired", "closed"] },217    network: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] },218    country: { type: ["string", "null"] },219    region: { type: ["string", "null"], description: "Normalised slug." },220    city: { type: ["string", "null"], description: "Normalised slug." },221    request_count: { type: "integer" },222    last_used_at: { type: ["string", "null"], format: "date-time" },223    expires_at: { type: "string", format: "date-time" },224    created_at: { type: "string", format: "date-time" },225  },226} as const;227228const crawlCreate = {229  type: "object",230  additionalProperties: false,231  required: ["url"],232  properties: {233    url: { type: "string", minLength: 1, maxLength: 8192, format: "uri", description: "Seed URL (same URL policy as fetch)." },234    max_pages: { type: "integer", minimum: 1, maximum: 5000, default: 25, description: "Maximum pages to fetch (capped at 2,000)." },235    max_depth: { type: "integer", minimum: 0, maximum: 10, default: 2 },236    same_domain: { type: "boolean", default: true },237    allow_subdomains: { type: "boolean", default: false },238    include_patterns: { type: "array", maxItems: 50, items: { type: "string", maxLength: 512 }, description: "Glob with * or /regex/." },239    exclude_patterns: { type: "array", maxItems: 50, items: { type: "string", maxLength: 512 } },240    respect_robots: { type: "boolean", default: true },241    use_sitemap: { type: "boolean", default: false },242    concurrency: { type: "integer", minimum: 1, maximum: 10, default: 3 },243    delay_ms: { type: "integer", minimum: 0, maximum: 30_000, default: 0 },244    timeout: { type: "integer", minimum: 1000, maximum: 120_000, default: 30_000, description: "Per-page timeout." },245    format: { type: "string", enum: [...CRAWL_FORMATS], default: "markdown" },246    main_content: { type: "boolean", default: true },247    country: geoProps.country,248    network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto" },249    browser: { type: "boolean", default: false },250    browser_fallback: { type: "boolean", default: true },251    headers: { type: "object", additionalProperties: { type: "string", maxLength: 8192 }, maxProperties: 64 },252    webhook_url: { type: "string", format: "uri", maxLength: 2048, description: "Called once when the job finishes (delivery not yet active)." },253    label: { type: "string", maxLength: 128 },254  },255} as const;256257const crawlStats = {258  type: "object",259  required: ["discovered", "fetched", "ok", "blocked", "failed", "bytes"],260  properties: {261    discovered: { type: "integer" },262    fetched: { type: "integer" },263    ok: { type: "integer" },264    blocked: { type: "integer" },265    failed: { type: "integer" },266    bytes: { type: "integer" },267  },268} as const;269270const crawlJob = {271  type: "object",272  required: ["id", "status", "label", "seed_url", "domain", "options", "stats", "error", "created_at", "started_at", "completed_at"],273  properties: {274    id: { type: "string", example: "crawl_3k9d0f2a8b1c7e4m" },275    status: { type: "string", enum: [...CRAWL_STATUSES] },276    label: { type: ["string", "null"] },277    seed_url: { type: "string" },278    domain: { type: "string" },279    options: { $ref: "#/components/schemas/CrawlCreate" },280    stats: { $ref: "#/components/schemas/CrawlStats" },281    error: { type: ["object", "null"], properties: { code: { type: "string" }, message: { type: "string" } } },282    created_at: { type: "string", format: "date-time" },283    started_at: { type: ["string", "null"], format: "date-time" },284    completed_at: { type: ["string", "null"], format: "date-time" },285  },286} as const;287288const crawlCreated = {289  type: "object",290  required: ["id", "status", "seed_url", "created_at", "options"],291  properties: {292    id: { type: "string", example: "crawl_3k9d0f2a8b1c7e4m" },293    status: { type: "string", const: "queued" },294    seed_url: { type: "string" },295    created_at: { type: "string", format: "date-time" },296    options: { $ref: "#/components/schemas/CrawlCreate" },297  },298} as const;299300const crawlPage = {301  type: "object",302  required: ["id", "url", "final_url", "depth", "status", "http_status", "error_code", "title", "description", "content_type", "content", "links_count", "bytes", "duration_ms", "mode", "fetched_at"],303  properties: {304    id: { type: "string" },305    url: { type: "string" },306    final_url: { type: ["string", "null"] },307    depth: { type: "integer" },308    status: { type: "string", enum: ["success", "blocked", "failed"] },309    http_status: { type: ["integer", "null"] },310    error_code: { type: ["string", "null"] },311    title: { type: ["string", "null"] },312    description: { type: ["string", "null"] },313    content_type: { type: ["string", "null"] },314    content: { type: ["string", "null"], description: "Page content in the job's format." },315    links_count: { type: ["integer", "null"] },316    bytes: { type: ["integer", "null"] },317    duration_ms: { type: ["integer", "null"] },318    mode: { type: ["string", "null"], enum: ["http", "browser", null] },319    fetched_at: { type: ["string", "null"], format: "date-time" },320  },321} as const;322323const mapCreate = {324  type: "object",325  additionalProperties: false,326  required: ["url"],327  properties: {328    url: { type: "string", minLength: 1, maxLength: 8192, format: "uri" },329    limit: { type: "integer", minimum: 1, maximum: 10_000, default: 1000 },330    use_sitemap: { type: "boolean", default: true },331    use_links: { type: "boolean", default: true },332    same_domain: { type: "boolean", default: true },333    allow_subdomains: { type: "boolean", default: false },334    search: { type: "string", maxLength: 256, description: "Substring, glob (*) or /regex/ filter." },335    country: geoProps.country,336    network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto" },337    timeout: { type: "integer", minimum: 1000, maximum: 120_000, default: 30_000 },338  },339} as const;340341const mapResult = {342  type: "object",343  required: ["url", "count", "urls", "sources", "truncated"],344  properties: {345    url: { type: "string" },346    count: { type: "integer" },347    urls: { type: "array", items: { type: "string", format: "uri" } },348    sources: { type: "object", required: ["sitemap", "links"], properties: { sitemap: { type: "integer" }, links: { type: "integer" } } },349    truncated: { type: "boolean" },350  },351} as const;352353const CRAWL_ERRORS = [...AUTH_ERRORS, "INVALID_REQUEST", "URL_NOT_ALLOWED", "CRAWL_LIMIT_REACHED", "RATE_LIMITED", "INTERNAL_ERROR"];354355const plans = Object.values(PLAN_LIMITS).map((p) => `${p.label}: ${p.concurrency} concurrent, ${p.monthly_requests >= Number.MAX_SAFE_INTEGER ? "unlimited" : p.monthly_requests.toLocaleString("en-US")} requests/month, ${p.max_timeout_ms / 1000} s max timeout, ${p.max_retries} max retries, ${p.browser_concurrency} concurrent browser renders, ${p.crawl_max_pages.toLocaleString("en-US")} pages per crawl job, ${p.crawl_concurrent_jobs} concurrent crawl jobs`);356357const document = {358  openapi: "3.1.0",359  info: {360    title: "Fetcha API",361    version: "0.2.0",362    summary: "Intelligent Web Access Infrastructure",363    description: [364      "One API to fetch, render and crawl web pages through Fetcha's routing engine. Authenticate with `Authorization: Bearer fch_live_…` (or `X-API-Key`).",365      "",366      "Every response carries `X-Fetcha-Request-ID`. Errors use a single envelope `{ error: { code, message, request_id, details? } }`.",367      "",368      "Private platform (invitation-only). Plan: " + plans.join("; ") + ".",369      "",370      "Not yet available: browser actions (`POST /v1/browser`), structured extraction (`POST /v1/extract`), webhook delivery, datacenter/isp/mobile network classes.",371    ].join("\n"),372    contact: { name: "Fetcha support", email: "support@fetcha.co", url: `${BASE_URL}/docs` },373    termsOfService: `${BASE_URL}/legal/terms`,374  },375  servers: [{ url: BASE_URL }],376  externalDocs: { url: `${BASE_URL}/docs`, description: "Fetcha documentation" },377  tags: [378    { name: "Fetch", description: "Retrieve URLs through the routing engine." },379    { name: "Crawl", description: "Asynchronous site crawls and synchronous URL discovery." },380    { name: "Sessions", description: "Sticky exit identities." },381    { name: "Account", description: "Key introspection and usage." },382    { name: "Health", description: "Public health probes (no authentication)." },383  ],384  security: [{ bearerAuth: [] }, { apiKeyHeader: [] }],385  paths: {386    "/v1/fetch": {387      post: {388        tags: ["Fetch"],389        operationId: "fetch",390        summary: "Fetch a URL",391        description:392          "Fetches the URL through the best available route, retrying and escalating on blocks (up to a managed browser render for JavaScript challenges) within the request's single `timeout`. Returns 200 whenever the target answered, including 4xx/5xx and fully blocked targets (`success: false`). Requires scope `fetch:execute`.",393        requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/FetchRequest" }, example: { url: "https://example.com", country: "CA", format: "markdown" } } } },394        responses: {395          "200": {396            description: "Fetcha obtained a response from the target. Inspect `success`, `status` and `metadata.mode`.",397            headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } },398            content: { "application/json": { schema: { $ref: "#/components/schemas/FetchResponse" } } },399          },400          ...byStatus([401            ...AUTH_ERRORS,402            "INVALID_REQUEST",403            "URL_NOT_ALLOWED",404            "NETWORK_UNAVAILABLE",405            "BROWSER_UNAVAILABLE",406            "USAGE_LIMIT_REACHED",407            "SESSION_NOT_FOUND",408            "SESSION_EXPIRED",409            "RATE_LIMITED",410            "CONCURRENCY_LIMIT",411            "INTERNAL_ERROR",412            "TARGET_UNAVAILABLE",413            "RESPONSE_TOO_LARGE",414            "TOO_MANY_REDIRECTS",415            "PROVIDER_UNAVAILABLE",416            "TARGET_TIMEOUT",417            "BROWSER_TIMEOUT",418          ]),419        },420      },421    },422    "/v1/crawl": {423      post: {424        tags: ["Crawl"],425        operationId: "createCrawl",426        summary: "Start a crawl job",427        description: "Queues an asynchronous crawl from a seed URL. Each crawled page is a normal fetch request (source `crawl`). Requires scope `fetch:execute`. Limits: 2,000 pages per job, 5 concurrent jobs per organization.",428        requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CrawlCreate" }, example: { url: "https://docs.example.com/", max_pages: 200, max_depth: 3, include_patterns: ["/docs/*"], format: "markdown" } } } },429        responses: {430          "202": { description: "Job queued.", headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/CrawlCreated" } } } },431          ...byStatus(CRAWL_ERRORS),432        },433      },434      get: {435        tags: ["Crawl"],436        operationId: "listCrawls",437        summary: "List crawl jobs",438        description: "Most recent jobs of the project, newest first.",439        parameters: [{ name: "limit", in: "query", required: false, schema: { type: "integer", default: 50 } }],440        responses: {441          "200": { description: "Jobs.", content: { "application/json": { schema: { type: "object", required: ["data"], properties: { data: { type: "array", items: { $ref: "#/components/schemas/CrawlJob" } } } } } } },442          ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]),443        },444      },445    },446    "/v1/crawl/{id}": {447      parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" }, example: "crawl_3k9d0f2a8b1c7e4m" }],448      get: {449        tags: ["Crawl"],450        operationId: "getCrawl",451        summary: "Get a crawl job",452        responses: {453          "200": { description: "Job with live status and stats.", content: { "application/json": { schema: { $ref: "#/components/schemas/CrawlJob" } } } },454          ...byStatus([...AUTH_ERRORS, "CRAWL_NOT_FOUND", "INTERNAL_ERROR"]),455        },456      },457      delete: {458        tags: ["Crawl"],459        operationId: "cancelCrawl",460        summary: "Cancel a crawl job",461        description: "Stops a queued or running job. Pages already fetched remain available. Requires scope `fetch:execute`.",462        responses: {463          "200": { description: "Cancelled.", content: { "application/json": { schema: { type: "object", required: ["id", "status"], properties: { id: { type: "string" }, status: { type: "string", const: "cancelled" } } } } } },464          ...byStatus([...AUTH_ERRORS, "CRAWL_NOT_FOUND", "INTERNAL_ERROR"]),465        },466      },467    },468    "/v1/crawl/{id}/pages": {469      get: {470        tags: ["Crawl"],471        operationId: "listCrawlPages",472        summary: "List crawled pages",473        description: "Pages of a job, ordered by fetch time, with cursor pagination.",474        parameters: [475          { name: "id", in: "path", required: true, schema: { type: "string" } },476          { name: "cursor", in: "query", required: false, schema: { type: "string" }, description: "Opaque cursor from `next_cursor`." },477          { name: "limit", in: "query", required: false, schema: { type: "integer", default: 100 } },478          { name: "status", in: "query", required: false, schema: { type: "string", enum: ["success", "blocked", "failed"] } },479        ],480        responses: {481          "200": {482            description: "A page of results.",483            content: { "application/json": { schema: { type: "object", required: ["data", "next_cursor"], properties: { data: { type: "array", items: { $ref: "#/components/schemas/CrawlPage" } }, next_cursor: { type: ["string", "null"] } } } } },484          },485          ...byStatus([...AUTH_ERRORS, "CRAWL_NOT_FOUND", "INTERNAL_ERROR"]),486        },487      },488    },489    "/v1/map": {490      post: {491        tags: ["Crawl"],492        operationId: "mapSite",493        summary: "Map a site's URLs",494        description: "Synchronously lists the URLs of a site from its sitemap(s) and the links of the seed page (up to 60 s). Requires scope `fetch:execute`.",495        requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MapCreate" }, example: { url: "https://docs.example.com/", search: "/docs/*", limit: 500 } } } },496        responses: {497          "200": { description: "URL list.", headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/MapResult" } } } },498          ...byStatus([...AUTH_ERRORS, "INVALID_REQUEST", "URL_NOT_ALLOWED", "RATE_LIMITED", "TARGET_TIMEOUT", "TARGET_UNAVAILABLE", "INTERNAL_ERROR"]),499        },500      },501    },502    "/v1/sessions": {503      post: {504        tags: ["Sessions"],505        operationId: "createSession",506        summary: "Create a session",507        description: "Creates a sticky session pinned to one route. Supports the `Idempotency-Key` header (24 h, per project). Requires scope `sessions:write`.",508        parameters: [{ name: "Idempotency-Key", in: "header", required: false, schema: { type: "string" }, description: "Return the session previously created with the same key instead of creating a new one." }],509        requestBody: { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/SessionCreate" }, example: { country: "CA", region: "QC", ttl: 900, label: "checkout-user-42" } } } },510        responses: {511          "200": { description: "Session created (or existing session returned for a known Idempotency-Key).", content: { "application/json": { schema: { $ref: "#/components/schemas/Session" } } } },512          ...byStatus([...AUTH_ERRORS, "INVALID_REQUEST", "NETWORK_UNAVAILABLE", "INTERNAL_ERROR"]),513        },514      },515      get: {516        tags: ["Sessions"],517        operationId: "listSessions",518        summary: "List sessions",519        description: "The 100 most recent sessions of the project, newest first, including expired and closed ones.",520        responses: {521          "200": { description: "Sessions.", content: { "application/json": { schema: { type: "object", required: ["data"], properties: { data: { type: "array", items: { $ref: "#/components/schemas/Session" } } } } } } },522          ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]),523        },524      },525    },526    "/v1/sessions/{id}": {527      parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" }, example: "sess_8f2k1m9d3p7q4r6s" }],528      get: {529        tags: ["Sessions"],530        operationId: "getSession",531        summary: "Get a session",532        responses: {533          "200": { description: "Session.", content: { "application/json": { schema: { $ref: "#/components/schemas/Session" } } } },534          ...byStatus([...AUTH_ERRORS, "SESSION_NOT_FOUND", "INTERNAL_ERROR"]),535        },536      },537      delete: {538        tags: ["Sessions"],539        operationId: "closeSession",540        summary: "Close a session",541        description: "Closes the session immediately. Requires scope `sessions:write`.",542        responses: {543          "200": { description: "Closed.", content: { "application/json": { schema: { type: "object", required: ["id", "status"], properties: { id: { type: "string" }, status: { type: "string", const: "closed" } } } } } },544          ...byStatus([...AUTH_ERRORS, "SESSION_NOT_FOUND", "INTERNAL_ERROR"]),545        },546      },547    },548    "/v1/me": {549      get: {550        tags: ["Account"],551        operationId: "me",552        summary: "Introspect the API key",553        description: "Validates the key and returns the project, organization and key metadata. No scope required.",554        responses: {555          "200": {556            description: "Principal.",557            content: {558              "application/json": {559                schema: {560                  type: "object",561                  required: ["project", "organization", "key"],562                  properties: {563                    project: { type: "object", properties: { id: { type: "string" }, name: { type: "string" } } },564                    organization: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, plan: { type: "string", enum: Object.keys(PLAN_LIMITS) } } },565                    key: {566                      type: "object",567                      properties: {568                        id: { type: "string" },569                        name: { type: "string" },570                        mode: { type: "string", enum: ["live", "test"] },571                        scopes: { type: "array", items: { type: "string", enum: ["fetch:execute", "browser:use", "crawl:execute", "sessions:write", "usage:read"] } },572                      },573                    },574                  },575                },576              },577            },578          },579          ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]),580        },581      },582    },583    "/v1/usage": {584      get: {585        tags: ["Account"],586        operationId: "usage",587        summary: "Monthly usage",588        description: "Usage for the current calendar month (UTC) at organization and project level. Requires scope `usage:read`.",589        responses: {590          "200": {591            description: "Usage summary.",592            content: {593              "application/json": {594                schema: {595                  type: "object",596                  required: ["period_start", "plan", "organization", "project", "remaining_requests"],597                  properties: {598                    period_start: { type: "string", format: "date-time" },599                    plan: { type: "object", properties: { id: { type: "string" }, label: { type: "string" }, monthly_requests: { type: "integer" }, concurrency: { type: "integer" } } },600                    organization: { type: "object", properties: { requests: { type: "number" }, spend_usd: { type: "number" } } },601                    project: {602                      type: "object",603                      properties: {604                        requests: { type: "number" },605                        spend_usd: { type: "number" },606                        successful_requests: { type: "integer" },607                        success_rate: { type: ["number", "null"], description: "Percentage with one decimal; null without requests." },608                        bandwidth_bytes: { type: "number" },609                        latency_p50_ms: { type: "integer" },610                        latency_p95_ms: { type: "integer" },611                      },612                    },613                    remaining_requests: { type: "integer" },614                  },615                },616              },617            },618          },619          ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]),620        },621      },622    },623    "/api/health": {624      get: {625        tags: ["Health"],626        operationId: "health",627        summary: "Liveness",628        security: [],629        responses: { "200": { description: "OK.", content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", const: "ok" }, version: { type: "string" }, time: { type: "string", format: "date-time" } } } } } } },630      },631    },632    "/api/ready": {633      get: {634        tags: ["Health"],635        operationId: "ready",636        summary: "Readiness",637        security: [],638        responses: {639          "200": {640            description: "Ready.",641            content: {642              "application/json": {643                schema: {644                  type: "object",645                  properties: {646                    status: { type: "string", enum: ["ready", "degraded"] },647                    checks: { type: "object", properties: { database: { type: "boolean" }, cache: { type: "boolean" }, providers: { type: "boolean" } } },648                    available_networks: { type: "array", items: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] } },649                  },650                },651              },652            },653          },654          "503": { description: "Degraded (same body with status `degraded`)." },655        },656      },657    },658  },659  components: {660    securitySchemes: {661      bearerAuth: { type: "http", scheme: "bearer", description: "`Authorization: Bearer fch_live_…` or `fch_test_…`." },662      apiKeyHeader: { type: "apiKey", in: "header", name: "X-API-Key", description: "Alternative to the Authorization header. Takes precedence when both are present." },663    },664    headers: {665      "X-Fetcha-Request-ID": { schema: { type: "string" }, description: "Request identifier (req_…). Quote it to support." },666    },667    schemas: {668      Error: errorSchema,669      FetchRequest: fetchRequest,670      FetchResponse: fetchResponse,671      Timing: timing,672      PageMetadata: pageMetadata,673      PageLink: pageLink,674      SessionCreate: sessionCreate,675      Session: session,676      CrawlCreate: crawlCreate,677      CrawlCreated: crawlCreated,678      CrawlJob: crawlJob,679      CrawlStats: crawlStats,680      CrawlPage: crawlPage,681      MapCreate: mapCreate,682      MapResult: mapResult,683    },684  },685  "x-error-catalogue": Object.fromEntries(ERROR_CODES.map((c) => [c, { status: ERROR_HTTP_STATUS[c], message: ERROR_MESSAGES[c] }])),686};687688export const dynamic = "force-static";689690export function GET() {691  return new Response(JSON.stringify(document, null, 2), {692    status: 200,693    headers: {694      "content-type": "application/json; charset=utf-8",695      "content-disposition": 'inline; filename="fetcha-openapi.json"',696      "cache-control": "public, max-age=3600",697    },698  });699}700