type Direction = "up" | "down" | "flat"; type FredSeries = { id: string; symbol: string; name: string; currency: boolean; decimals: number; multiplier?: number; }; type Observation = { date: string; value: number; }; const series: FredSeries[] = [ { id: "SP500", symbol: "S&P 500", name: "Index", currency: false, decimals: 2 }, { id: "NASDAQCOM", symbol: "NASDAQ", name: "Index", currency: false, decimals: 2 }, { id: "DJIA", symbol: "DOW", name: "Index", currency: false, decimals: 2 }, { id: "DCOILWTICO", symbol: "WTI", name: "USD / barrel", currency: true, decimals: 2 }, { id: "DHHNGSP", symbol: "NAT GAS", name: "USD / MMBtu", currency: true, decimals: 2 }, { id: "PCOPPUSDM", symbol: "COPPER", name: "USD / lb", currency: true, decimals: 2, multiplier: 1 / 2_204.62262185 }, ]; const maxResponseBytes = 1024 * 1024; const monthNumbers: Record = { Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06", Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12", }; export const dynamic = "force-dynamic"; function normalizeDate(value: string) { const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (iso) return value; const monthly = value.match(/^([A-Z][a-z]{2})\s+(\d{4})$/); if (monthly && monthNumbers[monthly[1]]) return `${monthly[2]}-${monthNumbers[monthly[1]]}-01`; return null; } function parseSeriesPage(html: string) { const observations: Observation[] = []; for (const row of html.match(/]*)?>[\s\S]*?<\/tr>/gi) ?? []) { const rawDate = row.match(/]*>\s*([^<]+?):(?: |\s)*<\/td>/i)?.[1]?.trim(); const rawValue = row.match(/]*class=["'][^"']*\bseries-obs\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/i)?.[1]; if (!rawDate || !rawValue) continue; const date = normalizeDate(rawDate); const value = Number(rawValue.replace(/<[^>]+>/g, "").replace(/,/g, "").replace(/−/g, "-").trim()); if (!date || !Number.isFinite(value)) continue; observations.push({ date, value }); } return observations.sort((left, right) => left.date.localeCompare(right.date)); } function formatValue(value: number, config: FredSeries) { const formatted = new Intl.NumberFormat("en-US", { minimumFractionDigits: config.decimals, maximumFractionDigits: config.decimals, }).format(value); return config.currency ? `$${formatted}` : formatted; } async function readSeries(config: FredSeries) { const url = new URL(`/series/${encodeURIComponent(config.id)}`, "https://fred.stlouisfed.org"); const response = await fetch(url, { headers: { Accept: "text/html,application/xhtml+xml", "User-Agent": "Dayline-TV/1.0 (+https://fred.stlouisfed.org)", }, signal: AbortSignal.timeout(12_000), }); if (!response.ok) throw new Error(`${config.id} returned ${response.status}`); const declaredLength = Number(response.headers.get("content-length") ?? 0); if (declaredLength > maxResponseBytes) throw new Error(`${config.id} response is too large`); const html = await response.text(); if (new TextEncoder().encode(html).byteLength > maxResponseBytes) throw new Error(`${config.id} response is too large`); const observations = parseSeriesPage(html); if (observations.length < 2) throw new Error(`${config.id} has fewer than two observations`); const previous = observations.at(-2)!; const latest = observations.at(-1)!; const multiplier = config.multiplier ?? 1; const value = latest.value * multiplier; const move = ((latest.value - previous.value) / previous.value) * 100; const direction: Direction = move > 0.004 ? "up" : move < -0.004 ? "down" : "flat"; return { symbol: config.symbol, name: config.name, value: formatValue(value, config), move: `${move > 0 ? "+" : ""}${move.toFixed(2)}%`, direction, seriesId: config.id, observedAt: latest.date, }; } export async function GET() { const results: PromiseSettledResult>>[] = []; for (let index = 0; index < series.length; index += 2) { const batch = await Promise.allSettled(series.slice(index, index + 2).map(readSeries)); results.push(...batch); } const items = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []); return Response.json( { items, seriesAvailable: items.length, seriesTotal: series.length, updatedAt: new Date().toISOString(), }, { status: items.length ? 200 : 503, headers: { "Cache-Control": "public, max-age=900, stale-while-revalidate=21600", "X-Content-Type-Options": "nosniff", }, }, ); }