SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
3.6 KB · 69 lines typescript
Raw Blame History
1import type { Observation } from "@websensor/core";2import { assertUrlAllowed } from "@websensor/core";34/**5 * Scrapfly fallback (acquisition hierarchy step 14). Used only when ordinary HTTP failed with6 * an anti-bot response (403/429/503) AND the source registry allows it (`fallback.scrapfly`).7 * Budgeted per day; never used for the baseline crawl of ordinary pages.8 * Docs: https://scrapfly.io/docs/scrape-api/getting-started — GET /scrape?key&url&asp&render_js9 * Response: { result: { content, status_code, success, response_headers, url }, context: { cost } }10 */11let dayKey = "";12let used = 0;1314export function scrapflyBudget(): { used: number; limit: number; day: string } {15  roll();16  return { used, limit: Number(process.env.WS_SCRAPFLY_DAILY_BUDGET ?? 300), day: dayKey };17}1819function roll(): void {20  const today = new Date().toISOString().slice(0, 10);21  if (dayKey !== today) {22    dayKey = today;23    used = 0;24  }25}2627export function scrapflyAvailable(): boolean {28  roll();29  return Boolean(process.env.SCRAPFLY_API_KEY) && used < Number(process.env.WS_SCRAPFLY_DAILY_BUDGET ?? 300);30}3132export async function scrapflyFetch(sensorId: string, url: string, opts: { renderJs?: boolean; country?: string } = {}): Promise<Observation> {33  const started = Date.now();34  const key = process.env.SCRAPFLY_API_KEY;35  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: {} } });36  if (!key) return fail("scrapfly_unavailable", "SCRAPFLY_API_KEY not configured");37  if (!scrapflyAvailable()) return fail("scrapfly_budget", "daily Scrapfly budget exhausted");38  try {39    await assertUrlAllowed(url);40  } catch (e) {41    return fail("ssrf_blocked", (e as Error).message);42  }43  used++;44  const q = new URLSearchParams({ key, url, asp: "true", render_js: opts.renderJs ? "true" : "false", country: opts.country ?? "us", retry: "true" });45  const ac = new AbortController();46  const timer = setTimeout(() => ac.abort(), 90_000);47  try {48    const res = await fetch(`https://api.scrapfly.io/scrape?${q.toString()}`, { signal: ac.signal, headers: { accept: "application/json" } });49    const json = (await res.json()) as { result?: { content?: string; status_code?: number; success?: boolean; reason?: string; response_headers?: Record<string, string>; url?: string; error?: { message?: string } }; message?: string; code?: string };50    const r = json.result;51    if (!res.ok || !r) return fail("scrapfly_error", `${res.status} ${json.message ?? json.code ?? "no result"}`);52    if (!r.success) return fail("scrapfly_failed", `${r.status_code ?? 0} ${r.reason ?? r.error?.message ?? "upstream failed"}`);53    const body = Buffer.from(r.content ?? "", "utf8");54    const h = Object.fromEntries(Object.entries(r.response_headers ?? {}).map(([k, v]) => [k.toLowerCase(), String(v)]));55    return {56      sensorId,57      url,58      fetchedAt: new Date(),59      notModified: false,60      body,61      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" } },62    };63  } catch (e) {64    return fail(ac.signal.aborted ? "timeout" : "scrapfly_error", (e as Error).message);65  } finally {66    clearTimeout(timer);67  }68}69