/** * gz-crawl client — a single-file, dependency-free TypeScript SDK for the gz-crawl API * (browser rendering + web search). Copy this file into your project and use it: * * import { GzCrawlClient } from "./gz-crawl-client"; * const gz = new GzCrawlClient({ baseUrl: "https://browser.proma.ai", apiKey: process.env.GZ_CRAWL_KEY }); * * const page = await gz.scrape({ url: "https://example.com", formats: ["markdown"] }); * const hits = await gz.search({ query: "cloudflare workers", limit: 5 }); * const raw = await gz.fetchRaw("https://example.com"); // { status, contentType, body } * * Uses the global `fetch`, so it runs on Node 18+, Bun, Cloudflare Workers, Deno, and browsers. * Latest copy: GET {baseUrl}/client.ts */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type ScrapeFormat = | "markdown" | "html" | "rawHtml" | "links" | "screenshot" | "pdf" | "json" | "capture"; /** `cf` = Cloudflare Browser Run (default) · `playwright` = full Chromium · `fetch` = impit fast-fetch. */ export type RenderEngine = "cf" | "playwright" | "fetch"; export interface ScrapeAction { type: "wait" | "click" | "scroll" | "write" | "press"; selector?: string; milliseconds?: number; text?: string; key?: string; direction?: "up" | "down"; } export interface ScreenshotOptions { fullPage?: boolean; maxEdge?: number; slices?: boolean; } export interface ScrapeRequest { url: string; formats?: ScrapeFormat[]; onlyMainContent?: boolean; waitFor?: number; timeout?: number; mobile?: boolean; headers?: Record; /** HTTP method + request body — only honored by the `fetch` engine (impit). Default GET. */ method?: string; body?: string; location?: { country?: string; languages?: string[] }; actions?: ScrapeAction[]; screenshot?: ScreenshotOptions; /** Block ads/trackers + hide cookie banners (playwright engine). */ blockAds?: boolean; hideSelectors?: string[]; engine?: RenderEngine; maxAge?: number; } export interface ScrapeMetadata { title?: string; description?: string; sourceURL: string; statusCode?: number; contentType?: string; engine: "cf" | "playwright" | "fetch"; cached: boolean; durationMs?: number; } export interface CaptureData { embedded: Record; network: Array<{ url: string; method: string; status: number; contentType?: string; resourceType?: string; body?: string }>; cookies: Array>; } export interface ScrapeData { markdown?: string; html?: string; rawHtml?: string; links?: string[]; /** base64 `data:` URL. */ screenshot?: string; screenshotSlices?: string[]; /** base64 `data:` URL. */ pdf?: string; json?: unknown; capture?: CaptureData; metadata: ScrapeMetadata; } export type SearchSource = "web" | "news" | "images"; export interface SearchRequest { query: string; limit?: number; sources?: SearchSource[]; categories?: string[]; includeDomains?: string[]; excludeDomains?: string[]; location?: string; country?: string; tbs?: string | null; rerank?: boolean; /** Fill each result's `markdown` by fetching the page. */ contents?: boolean; } export interface SearchResult { url: string; title: string | null; description: string | null; score: number | null; publishedDate: string | null; author: string | null; markdown?: string | null; source?: SearchSource; imageUrl?: string | null; thumbnail?: string | null; } export interface SearchResponse { provider: string; cached: boolean; results: SearchResult[]; } export interface CrawlRequest { url: string; limit?: number; maxDepth?: number; includePaths?: string[]; excludePaths?: string[]; formats?: ScrapeFormat[]; } export interface CrawlJob { id: string; status: "scraping" | "completed" | "failed"; total: number; completed: number; data: Array<{ url: string; markdown?: string; links?: string[]; metadata?: ScrapeMetadata; error?: string }>; error?: string; } export interface MapRequest { url: string; search?: string; limit?: number; } export interface BatchScrapeRequest { urls: string[]; formats?: ScrapeFormat[]; } export interface BatchScrapeResponse { total: number; results: Array<{ url: string; success: boolean; data?: ScrapeData; error?: string }>; } export interface CaptureRequest { url: string; waitFor?: number; headers?: Record; } /** A single captured network exchange (rich — for extraction analysis). */ export interface CaptureNetworkRecord { url: string; method: string; resourceType: string; requestHeaders: Record; postData?: string; status?: number; statusText?: string; responseHeaders: Record; mimeType?: string; body?: string; fromCache?: boolean; failed?: boolean; errorText?: string; } /** Full browser capture: rendered page's network log + visible anchors + embedded data + cookies. */ export interface CaptureResult { finalUrl: string; status?: number; network: CaptureNetworkRecord[]; anchors: { strings: string[]; numbers: string[] }; embedded: { globals: Array<{ location: string; source: string; json: string }>; scripts: string[] }; cookies: Array<{ name: string; value: string; domain?: string; path?: string }>; } // --------------------------------------------------------------------------- // Client // --------------------------------------------------------------------------- export interface GzCrawlOptions { /** Base URL of the gz-crawl service, e.g. "https://browser.proma.ai". */ baseUrl: string; /** API key (sent as `x-api-key`). Optional if the service has auth disabled. */ apiKey?: string; /** Per-request timeout in ms (default 60000). */ timeoutMs?: number; /** Override the fetch implementation (defaults to the global `fetch`). */ fetchImpl?: typeof fetch; } export class GzCrawlError extends Error { constructor( message: string, readonly status: number, readonly code?: string, readonly details?: unknown, ) { super(message); this.name = "GzCrawlError"; } } interface CallOpts { /** Bypass the server-side cache (`?fresh=1`). */ fresh?: boolean; } export class GzCrawlClient { private baseUrl: string; private apiKey?: string; private timeoutMs: number; private fetchImpl: typeof fetch; constructor(opts: GzCrawlOptions) { if (!opts?.baseUrl) throw new Error("GzCrawlClient: baseUrl is required"); this.baseUrl = opts.baseUrl.replace(/\/$/, ""); this.apiKey = opts.apiKey; this.timeoutMs = opts.timeoutMs ?? 60000; this.fetchImpl = opts.fetchImpl ?? fetch; } /** Scrape one URL → markdown/html/links/screenshot/pdf/... */ async scrape(req: ScrapeRequest, opts: CallOpts = {}): Promise { const { data } = await this.call<{ data: ScrapeData }>("POST", "/v1/scrape", req, opts); return data; } /** * Convenience: browserless fetch of a URL → the raw response body (HTML/JSON/XML). Defaults * to a GET on the `fetch` engine; pass `method`/`body` to replay a POST (impit only). */ async fetchRaw( url: string, opts: { headers?: Record; engine?: RenderEngine; method?: string; body?: string; fresh?: boolean; } = {}, ): Promise<{ status: number; contentType?: string; body: string }> { const data = await this.scrape( { url, engine: opts.engine ?? "fetch", formats: ["rawHtml"], headers: opts.headers, method: opts.method, body: opts.body, }, { fresh: opts.fresh }, ); return { status: data.metadata.statusCode ?? 200, contentType: data.metadata.contentType, body: data.rawHtml ?? "" }; } /** Web search (SearXNG-backed). */ async search(req: SearchRequest, opts: CallOpts = {}): Promise { return this.call("POST", "/v1/search", req, opts); } /** Find pages similar to a URL. */ async similar(req: { url: string; limit?: number }, opts: CallOpts = {}): Promise { return this.call("POST", "/v1/similar", req, opts); } /** Start an async site crawl → a job id. Poll with `crawlStatus`. */ async crawl(req: CrawlRequest): Promise<{ id: string }> { return this.call<{ id: string }>("POST", "/v1/crawl", req); } async crawlStatus(id: string): Promise { return this.call("GET", `/v1/crawl/${encodeURIComponent(id)}`); } /** Enumerate a site's URLs (sitemap + on-page links). */ async map(req: MapRequest): Promise<{ links: string[] }> { return this.call<{ links: string[] }>("POST", "/v1/map", req); } /** Scrape many URLs concurrently. */ async batchScrape(req: BatchScrapeRequest): Promise { return this.call("POST", "/v1/batch/scrape", req); } /** * Rich browser capture (rendered page's network log + anchors + embedded data + cookies) — * the raw material for building an extraction recipe without running a browser yourself. */ async capture(req: CaptureRequest, opts: CallOpts = {}): Promise { const { capture } = await this.call<{ capture: CaptureResult }>("POST", "/v1/capture", req, opts); return capture; } // ------------------------------------------------------------------------- private async call(method: string, path: string, body?: unknown, opts: CallOpts = {}): Promise { const url = `${this.baseUrl}${path}${opts.fresh ? "?fresh=1" : ""}`; const headers: Record = { accept: "application/json" }; if (this.apiKey) headers["x-api-key"] = this.apiKey; if (body !== undefined) headers["content-type"] = "application/json"; let res: Response; try { res = await this.fetchImpl(url, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(this.timeoutMs), }); } catch (e) { throw new GzCrawlError(`gz-crawl request failed: ${e instanceof Error ? e.message : String(e)}`, 0); } const text = await res.text(); let json: any; try { json = text ? JSON.parse(text) : {}; } catch { throw new GzCrawlError(`gz-crawl returned non-JSON (HTTP ${res.status})`, res.status, "bad_response", text.slice(0, 300)); } if (!res.ok || json?.success === false) { const err = json?.error ?? {}; throw new GzCrawlError(err.message ?? `gz-crawl error (HTTP ${res.status})`, res.status, err.code, err.details); } return json as T; } } /** Functional alias: `const gz = createGzCrawlClient(url, key)`. */ export function createGzCrawlClient(baseUrl: string, apiKey?: string): GzCrawlClient { return new GzCrawlClient({ baseUrl, apiKey }); }