import type { ExtractionResult } from '@rareindex/shared'; import type { FetchOptions } from '../types.js'; export interface ScrapflyEngineOptions { apiKey: string; baseUrl?: string; defaultCountry?: string; defaultTimeoutMs?: number; } interface ScrapflyResponse { result?: { content?: string; status_code?: number; url?: string; success?: boolean; error?: { message?: string; code?: string } | null; format?: string; extracted_data?: { data?: unknown } | unknown; }; context?: { cost?: { total?: number } }; config?: Record; cost?: number; message?: string; } /** * Scrapfly REST client (§104): secondary engine for JS-heavy or difficult public pages, with * geo routing and rendering. `asp` (anti-scraping protection) is only used on publicly accessible * pages within the source's terms — never to defeat logins, paywalls or CAPTCHAs (§179). */ export function createScrapflyEngine(opts: ScrapflyEngineOptions) { const base = (opts.baseUrl ?? 'https://api.scrapfly.io').replace(/\/$/, ''); async function scrape(url: string, o: FetchOptions = {}): Promise { const started = Date.now(); const timeoutMs = o.timeoutMs ?? opts.defaultTimeoutMs ?? 90_000; const params = new URLSearchParams({ key: opts.apiKey, url, asp: 'true', render_js: o.renderJs === false ? 'false' : 'true', country: (o.country ?? opts.defaultCountry ?? 'us').toLowerCase(), retry: 'true', format: 'raw', }); if (o.waitForMs) params.set('rendering_wait', String(Math.min(o.waitForMs, 25_000))); if (o.extractionModel) params.set('extraction_model', o.extractionModel); if (o.headers) for (const [k, v] of Object.entries(o.headers)) params.append(`headers[${k}]`, v); if (o.method === 'POST') params.set('method', 'POST'); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs + 5_000); try { const res = await fetch(`${base}/scrape?${params.toString()}`, { method: o.method === 'POST' ? 'POST' : 'GET', body: o.method === 'POST' && o.body !== undefined ? JSON.stringify(o.body) : undefined, headers: o.method === 'POST' ? { 'content-type': 'application/json' } : undefined, signal: ctrl.signal, }); const text = await res.text(); let json: ScrapflyResponse | null = null; try { json = JSON.parse(text) as ScrapflyResponse; } catch { return fail(`scrapfly HTTP ${res.status}: ${text.slice(0, 200)}`, started, url); } const r = json.result; const status = r?.status_code ?? null; const cost = json.context?.cost?.total ?? json.cost ?? 1; if (!res.ok || !r) return fail(`scrapfly HTTP ${res.status}: ${json.message ?? r?.error?.message ?? 'no result'}`, started, url, cost); const ok = Boolean(r.success !== false && (status === null || status < 400)); const content = r.content ?? null; const extracted = (r.extracted_data as { data?: unknown } | undefined)?.data ?? r.extracted_data ?? null; const looksJson = content && /^\s*[[{]/.test(content); let parsed: unknown = extracted; if (!parsed && looksJson) { try { parsed = JSON.parse(content!); } catch { parsed = null; } } return { success: ok, engine: 'scrapfly', url, finalUrl: r.url ?? null, httpStatus: status, html: content, markdown: null, json: parsed, qualityScore: 0, requiresReview: false, error: ok ? null : (r.error?.message ?? `status ${status}`), costCredits: cost, durationMs: Date.now() - started, fetchedAt: new Date(), }; } catch (err) { return fail(err instanceof Error ? err.message : String(err), started, url); } finally { clearTimeout(timer); } } function fail(error: string, started: number, url: string, cost = 0): ExtractionResult { return { success: false, engine: 'scrapfly', url, finalUrl: null, httpStatus: null, html: null, markdown: null, json: null, qualityScore: 0, requiresReview: false, error, costCredits: cost, durationMs: Date.now() - started, fetchedAt: new Date() }; } return { scrape }; } export type ScrapflyEngine = ReturnType;