Files
intelligence-terminal/apis/sources/bls.mjs
Firdavs d22f36e158 fix: prevent infinite loading screen by adding sweep timeouts
The dashboard would hang indefinitely on the loading screen because:

1. `bls.mjs` used a raw `fetch()` without any timeout/AbortSignal —
   if the BLS API was slow or unresponsive, it would block forever.

2. `runSource()` in `briefing.mjs` had no per-source timeout, so a
   single hanging API could stall the entire sweep indefinitely.

3. `server.mjs` loaded cached `latest.json` via a fire-and-forget
   promise (`.then()`) instead of `await`, meaning the dashboard
   never received the cached data before the sweep started.

4. `loading.html` relied solely on SSE for redirect — if the SSE
   connection missed the update event, the page would never redirect.

Changes:
- Add 15s AbortController timeout to BLS `getSeries()` fetch call
- Add 30s per-source timeout via `Promise.race()` in `runSource()`
- Await `synthesize()` when loading cached data so the dashboard
  serves instantly on restart when `runs/latest.json` exists
- Add 5s fallback polling to loading page alongside SSE

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 21:56:08 +03:00

167 lines
5.3 KiB
JavaScript

// BLS — Bureau of Labor Statistics
// CPI, unemployment, nonfarm payrolls, PPI. No auth required (v1 API).
// v2 with registration key supports more requests; v1 is rate-limited but functional.
import { safeFetch, daysAgo } from '../utils/fetch.mjs';
const V1_BASE = 'https://api.bls.gov/publicAPI/v1/timeseries/data/';
const V2_BASE = 'https://api.bls.gov/publicAPI/v2/timeseries/data/';
// Key economic series
const SERIES = {
'CUUR0000SA0': 'CPI-U All Items',
'CUUR0000SA0L1E': 'CPI-U Core (ex Food & Energy)',
'LNS14000000': 'Unemployment Rate',
'CES0000000001': 'Nonfarm Payrolls (thousands)',
'WPUFD49104': 'PPI Final Demand',
};
// Fetch a single series via GET (v1, no key needed)
export async function getSeriesV1(seriesId) {
return safeFetch(`${V1_BASE}/${seriesId}`);
}
// Fetch one or more series via POST (v2 if key available, v1 otherwise)
export async function getSeries(seriesIds, opts = {}) {
const { startYear, endYear, apiKey } = opts;
const now = new Date();
const start = startYear || String(now.getFullYear() - 1);
const end = endYear || String(now.getFullYear());
const base = apiKey ? V2_BASE : V1_BASE;
const payload = {
seriesid: Array.isArray(seriesIds) ? seriesIds : [seriesIds],
startyear: start,
endyear: end,
};
if (apiKey) payload.registrationkey = apiKey;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
const res = await fetch(base, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timer);
return await res.json();
} catch (e) {
return { error: e.message };
}
}
// Extract the latest observation from a BLS series response
function latestFromSeries(seriesData) {
if (!seriesData?.data?.length) return null;
// BLS returns data sorted by year desc, period desc
// Filter out unavailable values (BLS uses "-" for missing data)
const valid = seriesData.data.filter(d => d.value !== '-' && d.value !== '.');
if (!valid.length) return null;
const sorted = [...valid].sort((a, b) => {
const ya = parseInt(a.year), yb = parseInt(b.year);
if (ya !== yb) return yb - ya;
// period is M01..M12 or M13 (annual avg) or Q01..Q05
return b.period.localeCompare(a.period);
});
return sorted[0];
}
// Get the two most recent observations to compute month-over-month change
function momChange(seriesData) {
if (!seriesData?.data?.length || seriesData.data.length < 2) return null;
const sorted = [...seriesData.data]
.filter(d => d.period.startsWith('M') && d.period !== 'M13' && d.value !== '-' && d.value !== '.')
.sort((a, b) => {
const ya = parseInt(a.year), yb = parseInt(b.year);
if (ya !== yb) return yb - ya;
return b.period.localeCompare(a.period);
});
if (sorted.length < 2) return null;
const curr = parseFloat(sorted[0].value);
const prev = parseFloat(sorted[1].value);
if (isNaN(curr) || isNaN(prev) || prev === 0) return null;
return {
current: curr,
previous: prev,
change: +(curr - prev).toFixed(4),
changePct: +(((curr - prev) / prev) * 100).toFixed(4),
currentPeriod: `${sorted[0].year}-${sorted[0].period}`,
previousPeriod: `${sorted[1].year}-${sorted[1].period}`,
};
}
// Briefing — pull latest CPI, unemployment, payrolls
export async function briefing(apiKey) {
const seriesIds = Object.keys(SERIES);
const resp = await getSeries(seriesIds, { apiKey });
if (resp.error) {
return { source: 'BLS', error: resp.error, timestamp: new Date().toISOString() };
}
if (resp.status !== 'REQUEST_SUCCEEDED' || !resp.Results?.series?.length) {
return {
source: 'BLS',
error: resp.message?.[0] || 'BLS API returned no data',
rawStatus: resp.status,
timestamp: new Date().toISOString(),
};
}
const indicators = [];
const signals = [];
for (const s of resp.Results.series) {
const id = s.seriesID;
const label = SERIES[id] || id;
const latest = latestFromSeries(s);
const mom = momChange(s);
if (!latest) {
indicators.push({ id, label, value: null, date: null });
continue;
}
const value = parseFloat(latest.value);
const period = `${latest.year}-${latest.period}`;
indicators.push({
id,
label,
value,
period,
date: latest.year + '-' + latest.period.replace('M', '').padStart(2, '0'),
momChange: mom ? mom.change : null,
momChangePct: mom ? mom.changePct : null,
});
// Generate signals
if (id === 'LNS14000000' && value > 5.0) {
signals.push(`Unemployment elevated at ${value}%`);
}
if (id === 'CUUR0000SA0' && mom && mom.changePct > 0.4) {
signals.push(`CPI-U MoM jump: ${mom.changePct}% (${mom.previousPeriod} -> ${mom.currentPeriod})`);
}
if (id === 'CUUR0000SA0L1E' && mom && mom.changePct > 0.3) {
signals.push(`Core CPI MoM rising: ${mom.changePct}%`);
}
if (id === 'CES0000000001' && mom && mom.change < -50) {
signals.push(`Nonfarm payrolls dropped by ${Math.abs(mom.change)}K`);
}
}
return {
source: 'BLS',
timestamp: new Date().toISOString(),
indicators,
signals,
};
}
if (process.argv[1]?.endsWith('bls.mjs')) {
const data = await briefing(process.env.BLS_API_KEY);
console.log(JSON.stringify(data, null, 2));
}