import type { ExtractionResult } from '@rareindex/shared'; import type { FetchOptions } from '../types.js'; import { policyFor } from '../domains.js'; export const DEFAULT_UA = 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data; market-data research; contact: data@rareindex.io)'; /** Used only where connectors/domains.json sets userAgent:"browser" (site serves bots a degraded page; terms allow). */ export const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15'; export interface HttpEngineOptions { userAgent?: string; defaultTimeoutMs?: number; maxRetries?: number; } /** * Direct HTTP engine for official APIs, feeds and plain public pages. Honest user agent, no * evasion, exponential backoff on 429/5xx, and conditional requests when the caller passes * If-None-Match / If-Modified-Since headers (budget engine). */ export function createHttpEngine(opts: HttpEngineOptions = {}) { const ua = opts.userAgent ?? DEFAULT_UA; async function fetchUrl(url: string, o: FetchOptions = {}): Promise { const started = Date.now(); const policy = policyFor(url); const maxRetries = opts.maxRetries ?? policy.maxRetries; const timeoutMs = o.timeoutMs ?? opts.defaultTimeoutMs ?? policy.timeoutMs; let attempt = 0; let lastErr: string | null = null; while (attempt <= maxRetries) { attempt++; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(url, { method: o.method ?? 'GET', headers: { 'user-agent': o.headers?.['user-agent'] ?? (policyFor(url).userAgent === 'browser' ? BROWSER_UA : ua), accept: o.responseType === 'text' ? 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' : 'application/json, text/plain, */*', 'accept-language': 'en-US,en;q=0.9', ...(o.body !== undefined ? { 'content-type': 'application/json' } : {}), ...(o.headers ?? {}), }, body: o.body !== undefined ? JSON.stringify(o.body) : undefined, signal: ctrl.signal, redirect: 'follow', }); clearTimeout(timer); const status = res.status; if (status === 304) { return { success: true, engine: 'api', url, finalUrl: res.url, httpStatus: 304, html: null, markdown: null, json: null, qualityScore: 1, requiresReview: false, error: null, costCredits: 0, durationMs: Date.now() - started, fetchedAt: new Date() }; } if ((status === 429 || status >= 500) && attempt <= maxRetries) { const retryAfter = Number(res.headers.get('retry-after')); // Never sleep longer than a minute inside a fetch: a long Retry-After (comics.org sends ~30 min) is a // hard rate limit → return the 429 and let the scheduler/circuit breaker back off instead. if (Number.isFinite(retryAfter) && retryAfter > 60) { return { success: false, engine: 'api', url, finalUrl: res.url, httpStatus: status, html: null, markdown: null, json: null, qualityScore: 0, requiresReview: false, error: `HTTP ${status} (retry-after ${retryAfter}s)`, costCredits: 0, durationMs: Date.now() - started, fetchedAt: new Date() }; } const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : Math.min(30_000, 1000 * 2 ** attempt); lastErr = `HTTP ${status}`; await new Promise((r) => setTimeout(r, wait)); continue; } const ctype = res.headers.get('content-type') ?? ''; if (o.responseType === 'binary') { const buf = new Uint8Array(await res.arrayBuffer()); const okB = res.ok && (o.failOnHttpError === false || status < 400); return { success: okB, engine: 'api', url, finalUrl: res.url, httpStatus: status, html: null, markdown: null, json: null, buffer: buf, qualityScore: okB ? 1 : 0, requiresReview: false, error: okB ? null : `HTTP ${status}`, costCredits: 0, durationMs: Date.now() - started, fetchedAt: new Date() }; } const isJson = o.responseType !== 'text' && (ctype.includes('json') || o.responseType === 'json'); let json: unknown = null; let text: string | null = null; if (isJson) { const raw = await res.text(); try { json = raw ? JSON.parse(raw) : null; } catch { text = raw; } } else { text = await res.text(); } const ok = res.ok && (o.failOnHttpError === false || status < 400); return { success: ok, engine: 'api', url, finalUrl: res.url, httpStatus: status, html: text, markdown: null, json, qualityScore: ok ? 1 : 0, requiresReview: false, error: ok ? null : `HTTP ${status}`, costCredits: 0, durationMs: Date.now() - started, fetchedAt: new Date(), }; } catch (err) { clearTimeout(timer); lastErr = err instanceof Error ? err.message : String(err); if (attempt > maxRetries) break; await new Promise((r) => setTimeout(r, Math.min(15_000, 500 * 2 ** attempt))); } } return { success: false, engine: 'api', url, finalUrl: null, httpStatus: null, html: null, markdown: null, json: null, qualityScore: 0, requiresReview: false, error: lastErr ?? 'fetch failed', costCredits: 0, durationMs: Date.now() - started, fetchedAt: new Date() }; } return { fetch: fetchUrl }; } export type HttpEngine = ReturnType;