mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-05-14 02:56:21 +02:00
* feat: seed GDELT intelligence topics to Redis with bootstrap hydration Add standalone seed script that pre-populates all 6 Live Intelligence topics (military, cyber, nuclear, sanctions, intelligence, maritime) from the GDELT Doc API into Redis. Frontend consumes bootstrap data lazily via the service layer, falling back to RPC if unavailable. - scripts/seed-gdelt-intel.mjs: new seed script with per-topic 429 retry - api/bootstrap.js: register gdeltIntel in FAST_KEYS - api/health.js: register in BOOTSTRAP_KEYS + SEED_META + dataSize - api/seed-health.js: register in SEED_DOMAINS - scripts/_seed-utils.mjs: add topics to recordCount detection - src/services/gdelt-intel.ts: lazy bootstrap consumption in service layer * fix(seed): align staleness thresholds and strengthen GDELT validation - seed-health intervalMin 30→60 so staleness (120min) matches health.js maxStaleMin - validate requires ≥3/6 topics populated (not just military) - recordCount sums articles across topics instead of reporting topic count
166 lines
6.1 KiB
JavaScript
166 lines
6.1 KiB
JavaScript
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
|
import { validateApiKey } from './_api-key.js';
|
|
|
|
export const config = { runtime: 'edge' };
|
|
|
|
const BOOTSTRAP_CACHE_KEYS = {
|
|
earthquakes: 'seismology:earthquakes:v1',
|
|
outages: 'infra:outages:v1',
|
|
serviceStatuses: 'infra:service-statuses:v1',
|
|
marketQuotes: 'market:stocks-bootstrap:v1',
|
|
commodityQuotes: 'market:commodities-bootstrap:v1',
|
|
sectors: 'market:sectors:v1',
|
|
etfFlows: 'market:etf-flows:v1',
|
|
macroSignals: 'economic:macro-signals:v1',
|
|
bisPolicy: 'economic:bis:policy:v1',
|
|
bisExchange: 'economic:bis:eer:v1',
|
|
bisCredit: 'economic:bis:credit:v1',
|
|
shippingRates: 'supply_chain:shipping:v2',
|
|
chokepoints: 'supply_chain:chokepoints:v2',
|
|
minerals: 'supply_chain:minerals:v2',
|
|
giving: 'giving:summary:v1',
|
|
climateAnomalies: 'climate:anomalies:v1',
|
|
wildfires: 'wildfire:fires:v1',
|
|
cyberThreats: 'cyber:threats-bootstrap:v2',
|
|
techReadiness: 'economic:worldbank-techreadiness:v1',
|
|
progressData: 'economic:worldbank-progress:v1',
|
|
renewableEnergy: 'economic:worldbank-renewable:v1',
|
|
positiveGeoEvents: 'positive-events:geo-bootstrap:v1',
|
|
theaterPosture: 'theater-posture:sebuf:stale:v1',
|
|
riskScores: 'risk:scores:sebuf:stale:v1',
|
|
naturalEvents: 'natural:events:v1',
|
|
flightDelays: 'aviation:delays-bootstrap:v1',
|
|
insights: 'news:insights:v1',
|
|
predictions: 'prediction:markets-bootstrap:v1',
|
|
cryptoQuotes: 'market:crypto:v1',
|
|
gulfQuotes: 'market:gulf-quotes:v1',
|
|
stablecoinMarkets: 'market:stablecoins:v1',
|
|
unrestEvents: 'unrest:events:v1',
|
|
iranEvents: 'conflict:iran-events:v1',
|
|
ucdpEvents: 'conflict:ucdp-events:v1',
|
|
temporalAnomalies: 'temporal:anomalies:v1',
|
|
weatherAlerts: 'weather:alerts:v1',
|
|
spending: 'economic:spending:v1',
|
|
techEvents: 'research:tech-events-bootstrap:v1',
|
|
gdeltIntel: 'intelligence:gdelt-intel:v1',
|
|
};
|
|
|
|
const SLOW_KEYS = new Set([
|
|
'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving',
|
|
'sectors', 'etfFlows', 'shippingRates', 'wildfires', 'climateAnomalies',
|
|
'cyberThreats', 'techReadiness', 'progressData', 'renewableEnergy',
|
|
'naturalEvents',
|
|
'cryptoQuotes', 'gulfQuotes', 'stablecoinMarkets', 'unrestEvents', 'ucdpEvents',
|
|
'techEvents',
|
|
]);
|
|
const FAST_KEYS = new Set([
|
|
'earthquakes', 'outages', 'serviceStatuses', 'macroSignals', 'chokepoints',
|
|
'marketQuotes', 'commodityQuotes', 'positiveGeoEvents', 'riskScores', 'flightDelays','insights', 'predictions',
|
|
'iranEvents', 'temporalAnomalies', 'weatherAlerts', 'spending', 'theaterPosture', 'gdeltIntel',
|
|
]);
|
|
|
|
const TIER_CACHE = {
|
|
slow: 'public, s-maxage=3600, stale-while-revalidate=600, stale-if-error=3600',
|
|
fast: 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900',
|
|
};
|
|
const TIER_CDN_CACHE = {
|
|
slow: 'public, s-maxage=7200, stale-while-revalidate=1800, stale-if-error=7200',
|
|
fast: 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900',
|
|
};
|
|
|
|
const NEG_SENTINEL = '__WM_NEG__';
|
|
|
|
async function getCachedJsonBatch(keys) {
|
|
const result = new Map();
|
|
if (keys.length === 0) return result;
|
|
|
|
const url = process.env.UPSTASH_REDIS_REST_URL;
|
|
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
if (!url || !token) return result;
|
|
|
|
// Always read unprefixed keys — bootstrap is a read-only consumer of
|
|
// production cache data. Preview/branch deploys don't run handlers that
|
|
// populate prefixed keys, so prefixing would always miss.
|
|
const pipeline = keys.map((k) => ['GET', k]);
|
|
const resp = await fetch(`${url}/pipeline`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(pipeline),
|
|
signal: AbortSignal.timeout(3000),
|
|
});
|
|
if (!resp.ok) return result;
|
|
|
|
const data = await resp.json();
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const raw = data[i]?.result;
|
|
if (raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed !== NEG_SENTINEL) result.set(keys[i], parsed);
|
|
} catch { /* skip malformed */ }
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export default async function handler(req) {
|
|
if (isDisallowedOrigin(req))
|
|
return new Response('Forbidden', { status: 403 });
|
|
|
|
const cors = getCorsHeaders(req);
|
|
if (req.method === 'OPTIONS')
|
|
return new Response(null, { status: 204, headers: cors });
|
|
|
|
const apiKeyResult = validateApiKey(req);
|
|
if (apiKeyResult.required && !apiKeyResult.valid)
|
|
return new Response(JSON.stringify({ error: apiKeyResult.error }), {
|
|
status: 401, headers: { ...cors, 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
const url = new URL(req.url);
|
|
const tier = url.searchParams.get('tier');
|
|
let registry;
|
|
if (tier === 'slow' || tier === 'fast') {
|
|
const tierSet = tier === 'slow' ? SLOW_KEYS : FAST_KEYS;
|
|
registry = Object.fromEntries(Object.entries(BOOTSTRAP_CACHE_KEYS).filter(([k]) => tierSet.has(k)));
|
|
} else {
|
|
const requested = url.searchParams.get('keys')?.split(',').filter(Boolean).sort();
|
|
registry = requested
|
|
? Object.fromEntries(Object.entries(BOOTSTRAP_CACHE_KEYS).filter(([k]) => requested.includes(k)))
|
|
: BOOTSTRAP_CACHE_KEYS;
|
|
}
|
|
|
|
const keys = Object.values(registry);
|
|
const names = Object.keys(registry);
|
|
|
|
let cached;
|
|
try {
|
|
cached = await getCachedJsonBatch(keys);
|
|
} catch {
|
|
return new Response(JSON.stringify({ data: {}, missing: names }), {
|
|
status: 200,
|
|
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
|
|
});
|
|
}
|
|
|
|
const data = {};
|
|
const missing = [];
|
|
for (let i = 0; i < names.length; i++) {
|
|
const val = cached.get(keys[i]);
|
|
if (val !== undefined) data[names[i]] = val;
|
|
else missing.push(names[i]);
|
|
}
|
|
|
|
const cacheControl = (tier && TIER_CACHE[tier]) || 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900';
|
|
|
|
return new Response(JSON.stringify({ data, missing }), {
|
|
status: 200,
|
|
headers: {
|
|
...cors,
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': cacheControl,
|
|
'CDN-Cache-Control': (tier && TIER_CDN_CACHE[tier]) || TIER_CDN_CACHE.fast,
|
|
},
|
|
});
|
|
}
|