/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: apps/ingest/src/sources/usgs.ts * Purpose: USGS FDSN event service — count endpoint fetcher and pure GeoJSON parsers (event-driven, no interpolation) */ import { type FetchLike, defaultFetch } from "../fetch-like.js"; export const USGS_FDSN_BASE = "https://earthquake.usgs.gov/fdsnws/event/1"; /** Build the count-endpoint URL for the trailing window. nowMs is a parameter so tests stay deterministic. */ export function usgsCountUrl( windowHours: number, minMagnitude: number, nowMs: number = Date.now(), ): string { const start = new Date(nowMs - windowHours * 3_600_000).toISOString(); return ( `${USGS_FDSN_BASE}/count?format=geojson` + `&starttime=${encodeURIComponent(start)}&minmagnitude=${minMagnitude}` ); } /** * Pure parser for the count endpoint payload: { count: n, maxAllowed: m }. * Throws a descriptive error on anything malformed. */ export function parseUsgsCount(json: unknown): number { if (typeof json !== "object" || json === null) throw new Error("USGS count parse error: payload is not an object"); const count = (json as { count?: unknown }).count; if (typeof count !== "number" || !Number.isInteger(count) || count < 0) throw new Error( `USGS count parse error: 'count' must be a non-negative integer, got ${JSON.stringify(count)}`, ); return count; } export interface UsgsLastMajor { mag: number; place: string; timeIso: string; } /** * Pure parser for the query endpoint (orderby=time&limit=1): features[0] → last major quake. * Throws a descriptive error on malformed GeoJSON. */ export function parseUsgsLastMajor(geojson: unknown): UsgsLastMajor { if (typeof geojson !== "object" || geojson === null) throw new Error("USGS query parse error: payload is not an object"); const features = (geojson as { features?: unknown }).features; if (!Array.isArray(features) || features.length === 0) throw new Error("USGS query parse error: 'features' is missing or empty"); const first = features[0] as { properties?: unknown }; if (typeof first !== "object" || first === null || typeof first.properties !== "object" || first.properties === null) throw new Error("USGS query parse error: features[0].properties is missing"); const props = first.properties as { mag?: unknown; place?: unknown; time?: unknown }; if (typeof props.mag !== "number" || !Number.isFinite(props.mag)) throw new Error("USGS query parse error: features[0].properties.mag is not a finite number"); if (typeof props.place !== "string" || props.place.length === 0) throw new Error("USGS query parse error: features[0].properties.place is not a string"); if (typeof props.time !== "number" || !Number.isFinite(props.time)) throw new Error( "USGS query parse error: features[0].properties.time is not a millisecond timestamp", ); return { mag: props.mag, place: props.place, timeIso: new Date(props.time).toISOString() }; } /** Fetch the earthquake count for the trailing window (M >= minMagnitude). Inject fetchImpl in tests. */ export async function fetchUsgsQuakeCount( windowHours: number, minMagnitude: number, fetchImpl: FetchLike = defaultFetch, ): Promise { const url = usgsCountUrl(windowHours, minMagnitude); const res = await fetchImpl(url); if (!res.ok) throw new Error(`USGS count fetch failed: HTTP ${res.status} for ${url}`); const text = await res.text(); let json: unknown; try { json = JSON.parse(text); } catch { throw new Error("USGS count fetch failed: response is not valid JSON"); } return parseUsgsCount(json); }