spb/earth-now Public License
earth-now.co — real-time planetary dashboard: live world metrics modeled, not streamed.
TypeScript 93%
Shell 2.3%
SQL 1.4%
JavaScript 1.3%
Dockerfile 1.2%
CSS 0.8%
1/**2 * earth-now.co3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: apps/ingest/src/sources/usgs.ts6 * Purpose: USGS FDSN event service — count endpoint fetcher and pure GeoJSON parsers (event-driven, no interpolation)7 */89import { type FetchLike, defaultFetch } from "../fetch-like.js";1011export const USGS_FDSN_BASE = "https://earthquake.usgs.gov/fdsnws/event/1";1213/** Build the count-endpoint URL for the trailing window. nowMs is a parameter so tests stay deterministic. */14export function usgsCountUrl(15 windowHours: number,16 minMagnitude: number,17 nowMs: number = Date.now(),18): string {19 const start = new Date(nowMs - windowHours * 3_600_000).toISOString();20 return (21 `${USGS_FDSN_BASE}/count?format=geojson` +22 `&starttime=${encodeURIComponent(start)}&minmagnitude=${minMagnitude}`23 );24}2526/**27 * Pure parser for the count endpoint payload: { count: n, maxAllowed: m }.28 * Throws a descriptive error on anything malformed.29 */30export function parseUsgsCount(json: unknown): number {31 if (typeof json !== "object" || json === null)32 throw new Error("USGS count parse error: payload is not an object");33 const count = (json as { count?: unknown }).count;34 if (typeof count !== "number" || !Number.isInteger(count) || count < 0)35 throw new Error(36 `USGS count parse error: 'count' must be a non-negative integer, got ${JSON.stringify(count)}`,37 );38 return count;39}4041export interface UsgsLastMajor {42 mag: number;43 place: string;44 timeIso: string;45}4647/**48 * Pure parser for the query endpoint (orderby=time&limit=1): features[0] → last major quake.49 * Throws a descriptive error on malformed GeoJSON.50 */51export function parseUsgsLastMajor(geojson: unknown): UsgsLastMajor {52 if (typeof geojson !== "object" || geojson === null)53 throw new Error("USGS query parse error: payload is not an object");54 const features = (geojson as { features?: unknown }).features;55 if (!Array.isArray(features) || features.length === 0)56 throw new Error("USGS query parse error: 'features' is missing or empty");57 const first = features[0] as { properties?: unknown };58 if (typeof first !== "object" || first === null || typeof first.properties !== "object" || first.properties === null)59 throw new Error("USGS query parse error: features[0].properties is missing");60 const props = first.properties as { mag?: unknown; place?: unknown; time?: unknown };61 if (typeof props.mag !== "number" || !Number.isFinite(props.mag))62 throw new Error("USGS query parse error: features[0].properties.mag is not a finite number");63 if (typeof props.place !== "string" || props.place.length === 0)64 throw new Error("USGS query parse error: features[0].properties.place is not a string");65 if (typeof props.time !== "number" || !Number.isFinite(props.time))66 throw new Error(67 "USGS query parse error: features[0].properties.time is not a millisecond timestamp",68 );69 return { mag: props.mag, place: props.place, timeIso: new Date(props.time).toISOString() };70}7172/** Fetch the earthquake count for the trailing window (M >= minMagnitude). Inject fetchImpl in tests. */73export async function fetchUsgsQuakeCount(74 windowHours: number,75 minMagnitude: number,76 fetchImpl: FetchLike = defaultFetch,77): Promise<number> {78 const url = usgsCountUrl(windowHours, minMagnitude);79 const res = await fetchImpl(url);80 if (!res.ok) throw new Error(`USGS count fetch failed: HTTP ${res.status} for ${url}`);81 const text = await res.text();82 let json: unknown;83 try {84 json = JSON.parse(text);85 } catch {86 throw new Error("USGS count fetch failed: response is not valid JSON");87 }88 return parseUsgsCount(json);89}90