Files
cup26/server/src/ingest/fetcher.ts
T
NilsBriggen b6f62679c9 v2 Phase 1: ingestion + SQLite foundation
- Persistence via Node's built-in node:sqlite (zero native deps) on a Docker
  volume: response cache, source health, fixture↔provider id map, per-fixture
  enrichment, ingest log. Runtime bumped to node:24-slim + --experimental-sqlite.
- Resilient fetcher: DB cache + per-source rate-limit + jittered backoff +
  circuit-breaker (blocked sources trip + skip; UI reads DB, never breaks).
- ESPN hidden API as the PRIMARY rich source (works from the VPS where SofaScore
  403s): scoreboard (live scores w/ clock) + summary (venue, H2H, recent form,
  lineups, team stats). football-data / SofaScore are fallbacks.
- Scheduler: maps all 72 group fixtures to ESPN event ids, polls live on a
  dynamic cadence, enriches imminent fixtures into match_ext. /api/sources/health.
- Verified: DB populates (real H2H + form for opening matches), 22 tests pass,
  Docker image runs node:sqlite on the volume and persists across restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:21:01 +02:00

89 lines
3.1 KiB
TypeScript

import { cacheGet, cacheSet, healthGet, healthRecord } from '../db/db';
// One resilient fetch path for every source: DB-cached, rate-limited, and
// circuit-broken. A blocked source (e.g. SofaScore 403 from the VPS) trips its
// breaker and is skipped; callers transparently get stale cache or a thrown
// error they already handle — the UI, which reads the DB, never blocks.
const CIRCUIT_THRESHOLD = 4; // consecutive failures before opening
const CIRCUIT_COOLDOWN = 10 * 60_000; // base cooldown, escalates with failures
const lastCall = new Map<string, number>();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export function browserHeaders(extra: Record<string, string> = {}): Record<string, string> {
return {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
...extra,
};
}
export interface FetchOpts {
/** Cache freshness window (ms). */
ttl: number;
/** Minimum interval between calls to this source (ms). */
minInterval?: number;
headers?: Record<string, string>;
timeoutMs?: number;
/** Parse as text instead of JSON. */
text?: boolean;
}
export function circuitOpen(source: string): boolean {
const h = healthGet(source);
return !!h && h.open_until > Date.now();
}
async function rateLimit(source: string, minInterval: number): Promise<void> {
if (!minInterval) return;
const last = lastCall.get(source) ?? 0;
const wait = minInterval - (Date.now() - last);
if (wait > 0) await sleep(wait + Math.random() * 250); // jitter
lastCall.set(source, Date.now());
}
/**
* Fetch JSON (or text) for `source` at `url`, keyed in the cache by `key`.
* Returns fresh cache when valid, otherwise fetches; on failure returns stale
* cache if present, else throws.
*/
export async function cachedFetch<T = unknown>(
source: string,
key: string,
url: string,
opts: FetchOpts,
): Promise<T> {
const cached = cacheGet(key);
if (cached?.fresh) return (opts.text ? cached.value : JSON.parse(cached.value)) as T;
if (circuitOpen(source)) {
if (cached) return (opts.text ? cached.value : JSON.parse(cached.value)) as T;
throw new Error(`${source}: circuit open`);
}
await rateLimit(source, opts.minInterval ?? 0);
try {
const res = await fetch(url, {
headers: browserHeaders(opts.headers),
signal: AbortSignal.timeout(opts.timeoutMs ?? 12_000),
});
if (!res.ok) throw new Error(`${res.status}`);
const body = await res.text();
cacheSet(key, body, opts.ttl);
healthRecord(source, true);
return (opts.text ? body : JSON.parse(body)) as T;
} catch (e) {
const note = e instanceof Error ? e.message : String(e);
const consec = (healthGet(source)?.consec_fail ?? 0) + 1;
const openMs = consec >= CIRCUIT_THRESHOLD ? CIRCUIT_COOLDOWN * Math.min(6, consec - CIRCUIT_THRESHOLD + 1) : undefined;
healthRecord(source, false, note, openMs);
if (cached) return (opts.text ? cached.value : JSON.parse(cached.value)) as T; // serve stale
throw new Error(`${source} fetch failed: ${note}`);
}
}