import type { Observation } from "@websensor/core"; import { assertUrlAllowed } from "@websensor/core"; /** * Scrapfly fallback (acquisition hierarchy step 14). Used only when ordinary HTTP failed with * an anti-bot response (403/429/503) AND the source registry allows it (`fallback.scrapfly`). * Budgeted per day; never used for the baseline crawl of ordinary pages. * Docs: https://scrapfly.io/docs/scrape-api/getting-started — GET /scrape?key&url&asp&render_js * Response: { result: { content, status_code, success, response_headers, url }, context: { cost } } */ let dayKey = ""; let used = 0; export function scrapflyBudget(): { used: number; limit: number; day: string } { roll(); return { used, limit: Number(process.env.WS_SCRAPFLY_DAILY_BUDGET ?? 300), day: dayKey }; } function roll(): void { const today = new Date().toISOString().slice(0, 10); if (dayKey !== today) { dayKey = today; used = 0; } } export function scrapflyAvailable(): boolean { roll(); return Boolean(process.env.SCRAPFLY_API_KEY) && used < Number(process.env.WS_SCRAPFLY_DAILY_BUDGET ?? 300); } export async function scrapflyFetch(sensorId: string, url: string, opts: { renderJs?: boolean; country?: string } = {}): Promise { const started = Date.now(); const key = process.env.SCRAPFLY_API_KEY; const fail = (code: string, message: string): Observation => ({ sensorId, url, fetchedAt: new Date(), notModified: false, error: { code, message }, meta: { status: 0, url, finalUrl: url, contentType: null, contentLength: 0, etag: null, lastModified: null, durationMs: Date.now() - started, redirects: 0, method: "API", headers: {} } }); if (!key) return fail("scrapfly_unavailable", "SCRAPFLY_API_KEY not configured"); if (!scrapflyAvailable()) return fail("scrapfly_budget", "daily Scrapfly budget exhausted"); try { await assertUrlAllowed(url); } catch (e) { return fail("ssrf_blocked", (e as Error).message); } used++; const q = new URLSearchParams({ key, url, asp: "true", render_js: opts.renderJs ? "true" : "false", country: opts.country ?? "us", retry: "true" }); const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), 90_000); try { const res = await fetch(`https://api.scrapfly.io/scrape?${q.toString()}`, { signal: ac.signal, headers: { accept: "application/json" } }); const json = (await res.json()) as { result?: { content?: string; status_code?: number; success?: boolean; reason?: string; response_headers?: Record; url?: string; error?: { message?: string } }; message?: string; code?: string }; const r = json.result; if (!res.ok || !r) return fail("scrapfly_error", `${res.status} ${json.message ?? json.code ?? "no result"}`); if (!r.success) return fail("scrapfly_failed", `${r.status_code ?? 0} ${r.reason ?? r.error?.message ?? "upstream failed"}`); const body = Buffer.from(r.content ?? "", "utf8"); const h = Object.fromEntries(Object.entries(r.response_headers ?? {}).map(([k, v]) => [k.toLowerCase(), String(v)])); return { sensorId, url, fetchedAt: new Date(), notModified: false, body, meta: { status: r.status_code ?? 200, url, finalUrl: r.url ?? url, contentType: h["content-type"] ?? null, contentLength: body.length, etag: null, lastModified: h["last-modified"] ?? null, durationMs: Date.now() - started, redirects: 0, method: "API", headers: { ...h, "x-websensor-via": "scrapfly" } }, }; } catch (e) { return fail(ac.signal.aborted ? "timeout" : "scrapfly_error", (e as Error).message); } finally { clearTimeout(timer); } }