SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
5.0 KB · 114 lines typescript
Raw Blame History
1import type { ExtractionResult } from '@rareindex/shared';2import type { FetchOptions } from '../types.js';34export interface FirecrawlEngineOptions {5  apiKey: string;6  baseUrl?: string;7  defaultTimeoutMs?: number;8}910interface FirecrawlScrapeResponse {11  success: boolean;12  data?: {13    markdown?: string;14    html?: string;15    rawHtml?: string;16    json?: unknown;17    links?: string[];18    metadata?: { statusCode?: number; sourceURL?: string; url?: string; title?: string; error?: string; creditsUsed?: number };19  };20  error?: string;21  creditsUsed?: number;22}2324/**25 * Firecrawl v2 REST client (§103). Preferred engine for parseable public pages: returns markdown +26 * HTML (+ optional JSON extraction with a schema). Never used to bypass access controls (§179).27 */28export function createFirecrawlEngine(opts: FirecrawlEngineOptions) {29  const base = (opts.baseUrl ?? 'https://api.firecrawl.dev').replace(/\/$/, '');30  const headers = { authorization: `Bearer ${opts.apiKey}`, 'content-type': 'application/json' };3132  async function post<T>(path: string, body: unknown, timeoutMs: number): Promise<T> {33    const ctrl = new AbortController();34    const timer = setTimeout(() => ctrl.abort(), timeoutMs + 5_000);35    try {36      const res = await fetch(`${base}${path}`, { method: 'POST', headers, body: JSON.stringify(body), signal: ctrl.signal });37      const text = await res.text();38      let json: unknown = null;39      try {40        json = JSON.parse(text);41      } catch {42        throw new Error(`firecrawl ${path} HTTP ${res.status}: ${text.slice(0, 200)}`);43      }44      if (!res.ok) {45        const msg = (json as { error?: string })?.error ?? text.slice(0, 200);46        throw new Error(`firecrawl ${path} HTTP ${res.status}: ${msg}`);47      }48      return json as T;49    } finally {50      clearTimeout(timer);51    }52  }5354  async function scrape(url: string, o: FetchOptions = {}): Promise<ExtractionResult> {55    const started = Date.now();56    const timeoutMs = o.timeoutMs ?? opts.defaultTimeoutMs ?? 60_000;57    const formats: unknown[] = ['markdown', 'html', 'rawHtml'];58    if (o.jsonSchema || o.jsonPrompt) formats.push({ type: 'json', ...(o.jsonSchema ? { schema: o.jsonSchema } : {}), ...(o.jsonPrompt ? { prompt: o.jsonPrompt } : {}) });59    const body: Record<string, unknown> = {60      url,61      formats,62      onlyMainContent: false,63      timeout: timeoutMs,64      ...(o.waitForMs ? { waitFor: o.waitForMs } : {}),65      ...(o.country ? { location: { country: o.country.toUpperCase() } } : {}),66      ...(o.headers ? { headers: o.headers } : {}),67    };68    try {69      const r = await post<FirecrawlScrapeResponse>('/v2/scrape', body, timeoutMs);70      const d = r.data;71      const status = d?.metadata?.statusCode ?? null;72      const ok = Boolean(r.success && d && (status === null || status < 400));73      return {74        success: ok,75        engine: 'firecrawl',76        url,77        finalUrl: d?.metadata?.url ?? d?.metadata?.sourceURL ?? null,78        httpStatus: status,79        // rawHtml keeps <script> blocks (__NEXT_DATA__, JSON-LD); the cleaned html is the fallback.80        html: d?.rawHtml ?? d?.html ?? null,81        markdown: d?.markdown ?? null,82        json: d?.json ?? null,83        qualityScore: 0,84        requiresReview: false,85        error: ok ? null : (r.error ?? d?.metadata?.error ?? `status ${status}`),86        costCredits: r.creditsUsed ?? d?.metadata?.creditsUsed ?? 1,87        durationMs: Date.now() - started,88        fetchedAt: new Date(),89      };90    } catch (err) {91      return { success: false, engine: 'firecrawl', url, finalUrl: null, httpStatus: null, html: null, markdown: null, json: null, qualityScore: 0, requiresReview: false, error: err instanceof Error ? err.message : String(err), costCredits: 0, durationMs: Date.now() - started, fetchedAt: new Date() };92    }93  }9495  async function search(query: string, o: { limit?: number; scrape?: boolean; country?: string; tbs?: string } = {}) {96    const body: Record<string, unknown> = { query, limit: o.limit ?? 10, ...(o.country ? { country: o.country } : {}), ...(o.tbs ? { tbs: o.tbs } : {}) };97    if (o.scrape) body.scrapeOptions = { formats: ['markdown'] };98    const r = await post<{ success: boolean; data?: { web?: Array<{ url: string; title?: string; description?: string; markdown?: string }> } | Array<{ url: string; title?: string; description?: string; markdown?: string }>; creditsUsed?: number }>('/v2/search', body, 60_000);99    const data = r.data;100    const items = Array.isArray(data) ? data : (data?.web ?? []);101    return { items, credits: r.creditsUsed ?? items.length };102  }103104  async function map(url: string, o: { search?: string; limit?: number } = {}) {105    const r = await post<{ success: boolean; links?: Array<string | { url: string }>; creditsUsed?: number }>('/v2/map', { url, limit: o.limit ?? 5000, ...(o.search ? { search: o.search } : {}) }, 60_000);106    const links = (r.links ?? []).map((l) => (typeof l === 'string' ? l : l.url));107    return { links, credits: r.creditsUsed ?? 1 };108  }109110  return { scrape, search, map };111}112113export type FirecrawlEngine = ReturnType<typeof createFirecrawlEngine>;114