mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Add climate news seed and ListClimateNews RPC (#2532)
* Add climate news seed and ListClimateNews RPC * Wire climate news into bootstrap and fix generated climate stubs * fix(climate): align seed health interval and parse Atom entries per feed * fix(climate-news): TTL 90min, retry timer on failure, named cache key constant - CACHE_TTL: 1800 to 5400 (90min = 3x 30-min relay interval, gold standard) - ais-relay: add 20-min retry timer on subprocess failure; clear on success - cache-keys.ts: export CLIMATE_NEWS_KEY named constant - list-climate-news.ts: import CLIMATE_NEWS_KEY instead of hard-coding string --------- Co-authored-by: Elie Habib <elie.habib@gmail.com>
This commit is contained in:
3
api/bootstrap.js
vendored
3
api/bootstrap.js
vendored
@@ -27,6 +27,7 @@ const BOOTSTRAP_CACHE_KEYS = {
|
||||
giving: 'giving:summary:v1',
|
||||
climateAnomalies: 'climate:anomalies:v2',
|
||||
co2Monitoring: 'climate:co2-monitoring:v1',
|
||||
climateNews: 'climate:news-intelligence:v1',
|
||||
radiationWatch: 'radiation:observations:v1',
|
||||
thermalEscalation: 'thermal:escalation:v1',
|
||||
crossSourceSignals: 'intelligence:cross-source-signals:v1',
|
||||
@@ -86,7 +87,7 @@ const BOOTSTRAP_CACHE_KEYS = {
|
||||
|
||||
const SLOW_KEYS = new Set([
|
||||
'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving',
|
||||
'sectors', 'etfFlows', 'wildfires', 'climateAnomalies', 'co2Monitoring',
|
||||
'sectors', 'etfFlows', 'wildfires', 'climateAnomalies', 'co2Monitoring', 'climateNews',
|
||||
'radiationWatch', 'thermalEscalation', 'crossSourceSignals',
|
||||
'cyberThreats', 'techReadiness', 'progressData', 'renewableEnergy',
|
||||
'naturalEvents',
|
||||
|
||||
@@ -122,6 +122,7 @@ const STANDALONE_KEYS = {
|
||||
simulationPackageLatest: 'forecast:simulation-package:latest',
|
||||
simulationOutcomeLatest: 'forecast:simulation-outcome:latest',
|
||||
newsThreatSummary: 'news:threat:summary:v1',
|
||||
climateNews: 'climate:news-intelligence:v1',
|
||||
};
|
||||
|
||||
const SEED_META = {
|
||||
@@ -131,6 +132,7 @@ const SEED_META = {
|
||||
climateAnomalies: { key: 'seed-meta:climate:anomalies', maxStaleMin: 120 }, // runs as independent Railway cron (0 */2 * * *)
|
||||
climateZoneNormals: { key: 'seed-meta:climate:zone-normals', maxStaleMin: 89280 }, // monthly cron on the 1st; 62d = 2x 31-day cadence
|
||||
co2Monitoring: { key: 'seed-meta:climate:co2-monitoring', maxStaleMin: 4320 }, // daily cron at 06:00 UTC; 72h tolerates two missed runs
|
||||
climateNews: { key: 'seed-meta:climate:news-intelligence', maxStaleMin: 90 }, // relay loop every 30min; 90 = 3× interval
|
||||
unrestEvents: { key: 'seed-meta:unrest:events', maxStaleMin: 120 }, // 45min cron; 120 = 2h grace (was 75 = 30min buffer, too tight)
|
||||
cyberThreats: { key: 'seed-meta:cyber:threats', maxStaleMin: 240 }, // 2h interval; 240min = 2x interval
|
||||
cryptoQuotes: { key: 'seed-meta:market:crypto', maxStaleMin: 30 },
|
||||
|
||||
@@ -14,6 +14,7 @@ const SEED_DOMAINS = {
|
||||
'climate:anomalies': { key: 'seed-meta:climate:anomalies', intervalMin: 60 },
|
||||
'climate:zone-normals': { key: 'seed-meta:climate:zone-normals', intervalMin: 44640 },
|
||||
'climate:co2-monitoring': { key: 'seed-meta:climate:co2-monitoring', intervalMin: 2160 },
|
||||
'climate:news-intelligence': { key: 'seed-meta:climate:news-intelligence', intervalMin: 30 },
|
||||
// Phase 2 — Parameterized endpoints
|
||||
'unrest:events': { key: 'seed-meta:unrest:events', intervalMin: 15 },
|
||||
'cyber:threats': { key: 'seed-meta:cyber:threats', intervalMin: 240 },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -75,6 +75,32 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/api/climate/v1/list-climate-news:
|
||||
get:
|
||||
tags:
|
||||
- ClimateService
|
||||
summary: ListClimateNews
|
||||
description: ListClimateNews retrieves latest climate/environment intelligence headlines from seeded RSS feeds.
|
||||
operationId: ListClimateNews
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ListClimateNewsResponse'
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
default:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
components:
|
||||
schemas:
|
||||
Error:
|
||||
@@ -267,3 +293,39 @@ components:
|
||||
type: number
|
||||
format: double
|
||||
description: Year-over-year delta vs same calendar month, in ppm.
|
||||
ListClimateNewsRequest:
|
||||
type: object
|
||||
ListClimateNewsResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ClimateNewsItem'
|
||||
fetchedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
ClimateNewsItem:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Unique identifier (URL hash + publish timestamp).
|
||||
title:
|
||||
type: string
|
||||
description: Article headline.
|
||||
url:
|
||||
type: string
|
||||
description: Canonical article URL.
|
||||
sourceName:
|
||||
type: string
|
||||
description: Source publication name.
|
||||
publishedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Publication time as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
summary:
|
||||
type: string
|
||||
description: Short summary/description (max 300 chars in seed pipeline).
|
||||
description: ClimateNewsItem represents a single climate/environment news article.
|
||||
|
||||
21
proto/worldmonitor/climate/v1/climate_news_item.proto
Normal file
21
proto/worldmonitor/climate/v1/climate_news_item.proto
Normal file
@@ -0,0 +1,21 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.climate.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
|
||||
// ClimateNewsItem represents a single climate/environment news article.
|
||||
message ClimateNewsItem {
|
||||
// Unique identifier (URL hash + publish timestamp).
|
||||
string id = 1;
|
||||
// Article headline.
|
||||
string title = 2;
|
||||
// Canonical article URL.
|
||||
string url = 3;
|
||||
// Source publication name.
|
||||
string source_name = 4;
|
||||
// Publication time as Unix epoch milliseconds.
|
||||
int64 published_at = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
// Short summary/description (max 300 chars in seed pipeline).
|
||||
string summary = 6;
|
||||
}
|
||||
13
proto/worldmonitor/climate/v1/list_climate_news.proto
Normal file
13
proto/worldmonitor/climate/v1/list_climate_news.proto
Normal file
@@ -0,0 +1,13 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.climate.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/climate/v1/climate_news_item.proto";
|
||||
|
||||
message ListClimateNewsRequest {}
|
||||
|
||||
message ListClimateNewsResponse {
|
||||
repeated ClimateNewsItem items = 1;
|
||||
int64 fetched_at = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package worldmonitor.climate.v1;
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/climate/v1/get_co2_monitoring.proto";
|
||||
import "worldmonitor/climate/v1/list_climate_anomalies.proto";
|
||||
import "worldmonitor/climate/v1/list_climate_news.proto";
|
||||
|
||||
// ClimateService provides APIs for climate anomaly data sourced from Open-Meteo.
|
||||
service ClimateService {
|
||||
@@ -19,4 +20,9 @@ service ClimateService {
|
||||
rpc GetCo2Monitoring(GetCo2MonitoringRequest) returns (GetCo2MonitoringResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-co2-monitoring", method: HTTP_METHOD_GET};
|
||||
}
|
||||
|
||||
// ListClimateNews retrieves latest climate/environment intelligence headlines from seeded RSS feeds.
|
||||
rpc ListClimateNews(ListClimateNewsRequest) returns (ListClimateNewsResponse) {
|
||||
option (sebuf.http.config) = {path: "/list-climate-news", method: HTTP_METHOD_GET};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const https = require('https');
|
||||
const zlib = require('zlib');
|
||||
const path = require('path');
|
||||
const { readFileSync } = require('fs');
|
||||
const { execFile } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
const v8 = require('v8');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
@@ -5457,6 +5458,77 @@ async function startSocialVelocitySeedLoop() {
|
||||
}, SOCIAL_VELOCITY_INTERVAL_MS).unref?.();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Climate News Intelligence — delegated to standalone seed script
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const CLIMATE_NEWS_SEED_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const CLIMATE_NEWS_SEED_TIMEOUT_MS = 4 * 60 * 1000;
|
||||
const CLIMATE_NEWS_SEED_RETRY_MS = 20 * 60 * 1000;
|
||||
const CLIMATE_NEWS_SEED_SCRIPT = path.join(__dirname, 'seed-climate-news.mjs');
|
||||
|
||||
let climateNewsSeedInFlight = false;
|
||||
let climateNewsRetryTimer = null;
|
||||
|
||||
function relayLogScriptOutput(prefix, stream) {
|
||||
if (!stream) return;
|
||||
const trimmed = String(stream).trim();
|
||||
if (!trimmed) return;
|
||||
for (const line of trimmed.split('\n')) console.log(`${prefix} ${line}`);
|
||||
}
|
||||
|
||||
function runClimateNewsSeedScript() {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(process.execPath, [CLIMATE_NEWS_SEED_SCRIPT], {
|
||||
env: process.env,
|
||||
timeout: CLIMATE_NEWS_SEED_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}, (err, stdout, stderr) => {
|
||||
relayLogScriptOutput('[ClimateNewsSeed]', stdout);
|
||||
if (stderr) {
|
||||
const trimmedErr = String(stderr).trim();
|
||||
if (trimmedErr) {
|
||||
for (const line of trimmedErr.split('\n')) console.warn(`[ClimateNewsSeed] ${line}`);
|
||||
}
|
||||
}
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function seedClimateNews() {
|
||||
if (climateNewsSeedInFlight) {
|
||||
console.log('[ClimateNewsSeed] Skipped (in-flight)');
|
||||
return;
|
||||
}
|
||||
climateNewsSeedInFlight = true;
|
||||
if (climateNewsRetryTimer) { clearTimeout(climateNewsRetryTimer); climateNewsRetryTimer = null; }
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await runClimateNewsSeedScript();
|
||||
console.log(`[ClimateNewsSeed] Completed in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||||
} catch (e) {
|
||||
const message = e?.killed ? 'timeout' : (e?.message || e);
|
||||
console.warn('[ClimateNewsSeed] Seed error:', message);
|
||||
climateNewsRetryTimer = setTimeout(() => { seedClimateNews().catch(() => {}); }, CLIMATE_NEWS_SEED_RETRY_MS);
|
||||
} finally {
|
||||
climateNewsSeedInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startClimateNewsSeedLoop() {
|
||||
if (!UPSTASH_ENABLED) {
|
||||
console.log('[ClimateNewsSeed] Disabled (no Upstash Redis)');
|
||||
return;
|
||||
}
|
||||
console.log(`[ClimateNewsSeed] Seed loop starting (interval ${CLIMATE_NEWS_SEED_INTERVAL_MS / 1000 / 60}min)`);
|
||||
seedClimateNews().catch((e) => console.warn('[ClimateNewsSeed] Initial seed error:', e?.message || e));
|
||||
setInterval(() => {
|
||||
seedClimateNews().catch((e) => console.warn('[ClimateNewsSeed] Seed error:', e?.message || e));
|
||||
}, CLIMATE_NEWS_SEED_INTERVAL_MS).unref?.();
|
||||
}
|
||||
|
||||
function gzipSyncBuffer(body) {
|
||||
try {
|
||||
return zlib.gzipSync(typeof body === 'string' ? Buffer.from(body) : body);
|
||||
@@ -9940,6 +10012,7 @@ server.listen(PORT, () => {
|
||||
startUsniFleetSeedLoop();
|
||||
startShippingStressSeedLoop();
|
||||
startSocialVelocitySeedLoop();
|
||||
startClimateNewsSeedLoop();
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
|
||||
166
scripts/seed-climate-news.mjs
Normal file
166
scripts/seed-climate-news.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
const CANONICAL_KEY = 'climate:news-intelligence:v1';
|
||||
const CACHE_TTL = 5400; // 90min = 3× 30-min relay interval (gold standard: TTL ≥ 3× interval)
|
||||
const MAX_ITEMS = 100;
|
||||
const RSS_MAX_BYTES = 500_000;
|
||||
|
||||
const FEEDS = [
|
||||
{ sourceName: 'Carbon Brief', url: 'https://www.carbonbrief.org/feed' },
|
||||
{ sourceName: 'The Guardian Environment', url: 'https://www.theguardian.com/environment/climate-crisis/rss' },
|
||||
{ sourceName: 'ReliefWeb Disasters', url: 'https://reliefweb.int/updates/rss.xml?content=reports&country=0&theme=4590' },
|
||||
{ sourceName: 'NASA Earth Observatory', url: 'https://earthobservatory.nasa.gov/feeds/earth-observatory.rss' },
|
||||
{ sourceName: 'NOAA Climate News', url: 'https://www.noaa.gov/taxonomy/term/28/rss' },
|
||||
{ sourceName: 'Phys.org Earth Science', url: 'https://phys.org/rss-feed/earth-news/earth-sciences/' },
|
||||
{ sourceName: 'Copernicus/ECMWF', url: 'https://atmosphere.copernicus.eu/rss' },
|
||||
{ sourceName: 'Inside Climate News', url: 'https://insideclimatenews.org/feed/' },
|
||||
{ sourceName: 'Climate Central', url: 'https://www.climatecentral.org/rss' },
|
||||
];
|
||||
|
||||
function stableHash(str) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
|
||||
return Math.abs(h).toString(36);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(text) {
|
||||
return text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ');
|
||||
}
|
||||
|
||||
function extractTag(block, tagName) {
|
||||
const re = new RegExp(`<${tagName}[^>]*>(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?<\\/${tagName}>`, 'i');
|
||||
return (block.match(re) || [])[1]?.trim() || '';
|
||||
}
|
||||
|
||||
function cleanSummary(raw) {
|
||||
return decodeHtmlEntities(raw).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300);
|
||||
}
|
||||
|
||||
function parseDateMs(block) {
|
||||
const raw = extractTag(block, 'pubDate')
|
||||
|| extractTag(block, 'published')
|
||||
|| extractTag(block, 'updated')
|
||||
|| extractTag(block, 'dc:date');
|
||||
if (!raw) return 0;
|
||||
const ms = new Date(raw).getTime();
|
||||
return Number.isFinite(ms) ? ms : 0;
|
||||
}
|
||||
|
||||
function extractLink(block) {
|
||||
const direct = extractTag(block, 'link');
|
||||
if (direct) return decodeHtmlEntities(direct).trim();
|
||||
const href = (block.match(/<link[^>]*\bhref=(["'])(.*?)\1[^>]*\/?>/i) || [])[2] || '';
|
||||
return decodeHtmlEntities(href).trim();
|
||||
}
|
||||
|
||||
function parseRssItems(xml, sourceName) {
|
||||
const bounded = xml.length > RSS_MAX_BYTES ? xml.slice(0, RSS_MAX_BYTES) : xml;
|
||||
const items = [];
|
||||
const seenIds = new Set();
|
||||
|
||||
const pushParsedItem = (block, summaryTags) => {
|
||||
const title = decodeHtmlEntities(extractTag(block, 'title'));
|
||||
const url = extractLink(block);
|
||||
const publishedAt = parseDateMs(block);
|
||||
const rawSummary = summaryTags.map((tag) => extractTag(block, tag)).find(Boolean) || '';
|
||||
if (!title || !url || !publishedAt) return;
|
||||
|
||||
const id = `${stableHash(url)}-${publishedAt}`;
|
||||
if (seenIds.has(id)) return;
|
||||
seenIds.add(id);
|
||||
|
||||
items.push({
|
||||
id,
|
||||
title,
|
||||
url,
|
||||
sourceName,
|
||||
publishedAt,
|
||||
summary: cleanSummary(rawSummary),
|
||||
});
|
||||
};
|
||||
|
||||
const itemRe = /<item\b[^>]*>([\s\S]*?)<\/item>/gi;
|
||||
let match;
|
||||
while ((match = itemRe.exec(bounded)) !== null) {
|
||||
pushParsedItem(match[1], ['description', 'summary', 'content:encoded']);
|
||||
}
|
||||
|
||||
// Parse Atom entries per-feed as well; do not gate on RSS <item> presence.
|
||||
const entryRe = /<entry\b[^>]*>([\s\S]*?)<\/entry>/gi;
|
||||
while ((match = entryRe.exec(bounded)) !== null) {
|
||||
pushParsedItem(match[1], ['summary', 'content']);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function fetchFeed(feed) {
|
||||
try {
|
||||
const resp = await fetch(feed.url, {
|
||||
headers: {
|
||||
Accept: 'application/rss+xml, application/xml, text/xml, */*',
|
||||
'User-Agent': CHROME_UA,
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(`[ClimateNews] ${feed.sourceName} HTTP ${resp.status}`);
|
||||
return [];
|
||||
}
|
||||
const xml = await resp.text();
|
||||
const items = parseRssItems(xml, feed.sourceName);
|
||||
console.log(`[ClimateNews] ${feed.sourceName}: ${items.length} items`);
|
||||
return items;
|
||||
} catch (e) {
|
||||
console.warn(`[ClimateNews] ${feed.sourceName} fetch error:`, e?.message || e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchClimateNews() {
|
||||
const settled = await Promise.allSettled(FEEDS.map(fetchFeed));
|
||||
const allItems = [];
|
||||
for (const result of settled) {
|
||||
if (result.status === 'fulfilled') allItems.push(...result.value);
|
||||
}
|
||||
|
||||
allItems.sort((a, b) => b.publishedAt - a.publishedAt);
|
||||
|
||||
// Deduplicate by URL hash, keep newest occurrence.
|
||||
const seenUrlHashes = new Set();
|
||||
const deduped = [];
|
||||
for (const item of allItems) {
|
||||
const urlHash = stableHash(item.url);
|
||||
if (seenUrlHashes.has(urlHash)) continue;
|
||||
seenUrlHashes.add(urlHash);
|
||||
deduped.push(item);
|
||||
if (deduped.length >= MAX_ITEMS) break;
|
||||
}
|
||||
|
||||
return { items: deduped, fetchedAt: Date.now() };
|
||||
}
|
||||
|
||||
function validate(data) {
|
||||
return Array.isArray(data?.items) && data.items.length >= 1;
|
||||
}
|
||||
|
||||
runSeed('climate', 'news-intelligence', CANONICAL_KEY, fetchClimateNews, {
|
||||
validateFn: validate,
|
||||
ttlSeconds: CACHE_TTL,
|
||||
sourceVersion: 'climate-rss-v1',
|
||||
recordCount: (data) => data?.items?.length || 0,
|
||||
}).catch((err) => {
|
||||
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
|
||||
console.error('FATAL:', (err.message || err) + _cause);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -50,6 +50,13 @@ const TESTS = [
|
||||
minRecords: 1,
|
||||
field: 'anomalies',
|
||||
},
|
||||
{
|
||||
name: 'Climate News',
|
||||
endpoint: '/api/climate/v1/list-climate-news',
|
||||
validate: (d) => Array.isArray(d.items) && d.items.length > 0,
|
||||
minRecords: 1,
|
||||
field: 'items',
|
||||
},
|
||||
|
||||
// Phase 2 — Parameterized endpoints
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ export const SIMULATION_PACKAGE_LATEST_KEY = 'forecast:simulation-package:latest
|
||||
export const CLIMATE_ANOMALIES_KEY = 'climate:anomalies:v2';
|
||||
export const CLIMATE_ZONE_NORMALS_KEY = 'climate:zone-normals:v1';
|
||||
export const CLIMATE_CO2_MONITORING_KEY = 'climate:co2-monitoring:v1';
|
||||
export const CLIMATE_NEWS_KEY = 'climate:news-intelligence:v1';
|
||||
|
||||
/**
|
||||
* Static cache keys for the bootstrap endpoint.
|
||||
@@ -32,6 +33,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
|
||||
giving: 'giving:summary:v1',
|
||||
climateAnomalies: 'climate:anomalies:v2',
|
||||
co2Monitoring: 'climate:co2-monitoring:v1',
|
||||
climateNews: 'climate:news-intelligence:v1',
|
||||
radiationWatch: 'radiation:observations:v1',
|
||||
thermalEscalation: 'thermal:escalation:v1',
|
||||
crossSourceSignals: 'intelligence:cross-source-signals:v1',
|
||||
@@ -92,7 +94,7 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
|
||||
minerals: 'slow', giving: 'slow', sectors: 'slow',
|
||||
progressData: 'slow', renewableEnergy: 'slow',
|
||||
etfFlows: 'slow', shippingRates: 'fast', wildfires: 'slow',
|
||||
climateAnomalies: 'slow', co2Monitoring: 'slow', sanctionsPressure: 'slow', radiationWatch: 'slow', thermalEscalation: 'slow', crossSourceSignals: 'slow', cyberThreats: 'slow', techReadiness: 'slow',
|
||||
climateAnomalies: 'slow', co2Monitoring: 'slow', climateNews: 'slow', sanctionsPressure: 'slow', radiationWatch: 'slow', thermalEscalation: 'slow', crossSourceSignals: 'slow', cyberThreats: 'slow', techReadiness: 'slow',
|
||||
theaterPosture: 'fast', naturalEvents: 'slow',
|
||||
cryptoQuotes: 'slow', gulfQuotes: 'slow', stablecoinMarkets: 'slow',
|
||||
unrestEvents: 'slow', ucdpEvents: 'slow', techEvents: 'slow',
|
||||
|
||||
@@ -108,6 +108,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/intelligence/v1/get-gdelt-topic-timeline': 'medium',
|
||||
'/api/climate/v1/list-climate-anomalies': 'static',
|
||||
'/api/climate/v1/get-co2-monitoring': 'static',
|
||||
'/api/climate/v1/list-climate-news': 'slow',
|
||||
'/api/sanctions/v1/list-sanctions-pressure': 'static',
|
||||
'/api/sanctions/v1/lookup-sanction-entity': 'no-store',
|
||||
'/api/radiation/v1/list-radiation-observations': 'slow',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const SEED_ONLY_BOOTSTRAP_CACHE_KEYS = {
|
||||
renewableEnergy: 'economic:worldbank-renewable:v1',
|
||||
positiveGeoEvents: 'positive_events:geo-bootstrap:v1',
|
||||
weatherAlerts: 'weather:alerts:v1',
|
||||
climateNews: 'climate:news-intelligence:v1',
|
||||
spending: 'economic:spending:v1',
|
||||
techEvents: 'research:tech-events-bootstrap:v1',
|
||||
gdeltIntel: 'intelligence:gdelt-intel:v1',
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { ClimateServiceHandler } from '../../../../src/generated/server/wor
|
||||
|
||||
import { getCo2Monitoring } from './get-co2-monitoring';
|
||||
import { listClimateAnomalies } from './list-climate-anomalies';
|
||||
import { listClimateNews } from './list-climate-news';
|
||||
|
||||
export const climateHandler: ClimateServiceHandler = {
|
||||
getCo2Monitoring,
|
||||
listClimateAnomalies,
|
||||
listClimateNews,
|
||||
};
|
||||
|
||||
25
server/worldmonitor/climate/v1/list-climate-news.ts
Normal file
25
server/worldmonitor/climate/v1/list-climate-news.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* ListClimateNews RPC -- reads seeded climate news data from Railway seed cache.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ClimateServiceHandler,
|
||||
ServerContext,
|
||||
ListClimateNewsRequest,
|
||||
ListClimateNewsResponse,
|
||||
} from '../../../../src/generated/server/worldmonitor/climate/v1/service_server';
|
||||
|
||||
import { getCachedJson } from '../../../_shared/redis';
|
||||
import { CLIMATE_NEWS_KEY } from '../../../_shared/cache-keys';
|
||||
|
||||
export const listClimateNews: ClimateServiceHandler['listClimateNews'] = async (
|
||||
_ctx: ServerContext,
|
||||
_req: ListClimateNewsRequest,
|
||||
): Promise<ListClimateNewsResponse> => {
|
||||
try {
|
||||
const result = await getCachedJson(CLIMATE_NEWS_KEY, true) as ListClimateNewsResponse | null;
|
||||
return result ?? { items: [], fetchedAt: 0 };
|
||||
} catch {
|
||||
return { items: [], fetchedAt: 0 };
|
||||
}
|
||||
};
|
||||
@@ -59,6 +59,23 @@ export interface Co2DataPoint {
|
||||
anomaly: number;
|
||||
}
|
||||
|
||||
export interface ListClimateNewsRequest {
|
||||
}
|
||||
|
||||
export interface ListClimateNewsResponse {
|
||||
items: ClimateNewsItem[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export interface ClimateNewsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
sourceName: string;
|
||||
publishedAt: number;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export type AnomalySeverity = "ANOMALY_SEVERITY_UNSPECIFIED" | "ANOMALY_SEVERITY_NORMAL" | "ANOMALY_SEVERITY_MODERATE" | "ANOMALY_SEVERITY_EXTREME";
|
||||
|
||||
export type AnomalyType = "ANOMALY_TYPE_UNSPECIFIED" | "ANOMALY_TYPE_WARM" | "ANOMALY_TYPE_COLD" | "ANOMALY_TYPE_WET" | "ANOMALY_TYPE_DRY" | "ANOMALY_TYPE_MIXED";
|
||||
@@ -161,6 +178,29 @@ export class ClimateServiceClient {
|
||||
return await resp.json() as GetCo2MonitoringResponse;
|
||||
}
|
||||
|
||||
async listClimateNews(req: ListClimateNewsRequest, options?: ClimateServiceCallOptions): Promise<ListClimateNewsResponse> {
|
||||
let path = "/api/climate/v1/list-climate-news";
|
||||
const url = this.baseURL + path;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as ListClimateNewsResponse;
|
||||
}
|
||||
|
||||
private async handleError(resp: Response): Promise<never> {
|
||||
const body = await resp.text();
|
||||
if (resp.status === 400) {
|
||||
|
||||
@@ -59,6 +59,23 @@ export interface Co2DataPoint {
|
||||
anomaly: number;
|
||||
}
|
||||
|
||||
export interface ListClimateNewsRequest {
|
||||
}
|
||||
|
||||
export interface ListClimateNewsResponse {
|
||||
items: ClimateNewsItem[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export interface ClimateNewsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
sourceName: string;
|
||||
publishedAt: number;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export type AnomalySeverity = "ANOMALY_SEVERITY_UNSPECIFIED" | "ANOMALY_SEVERITY_NORMAL" | "ANOMALY_SEVERITY_MODERATE" | "ANOMALY_SEVERITY_EXTREME";
|
||||
|
||||
export type AnomalyType = "ANOMALY_TYPE_UNSPECIFIED" | "ANOMALY_TYPE_WARM" | "ANOMALY_TYPE_COLD" | "ANOMALY_TYPE_WET" | "ANOMALY_TYPE_DRY" | "ANOMALY_TYPE_MIXED";
|
||||
@@ -110,6 +127,7 @@ export interface RouteDescriptor {
|
||||
export interface ClimateServiceHandler {
|
||||
listClimateAnomalies(ctx: ServerContext, req: ListClimateAnomaliesRequest): Promise<ListClimateAnomaliesResponse>;
|
||||
getCo2Monitoring(ctx: ServerContext, req: GetCo2MonitoringRequest): Promise<GetCo2MonitoringResponse>;
|
||||
listClimateNews(ctx: ServerContext, req: ListClimateNewsRequest): Promise<ListClimateNewsResponse>;
|
||||
}
|
||||
|
||||
export function createClimateServiceRoutes(
|
||||
@@ -203,6 +221,43 @@ export function createClimateServiceRoutes(
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/climate/v1/list-climate-news",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const body = {} as ListClimateNewsRequest;
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.listClimateNews(ctx, body);
|
||||
return new Response(JSON.stringify(result as ListClimateNewsResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('Bootstrap key hydration coverage', () => {
|
||||
const allSrc = srcFiles.map(f => readFileSync(f, 'utf-8')).join('\n');
|
||||
|
||||
// Keys with planned but not-yet-wired consumers
|
||||
const PENDING_CONSUMERS = new Set(['chokepointTransits', 'correlationCards', 'euGasStorage']);
|
||||
const PENDING_CONSUMERS = new Set(['chokepointTransits', 'correlationCards', 'euGasStorage', 'climateNews']);
|
||||
for (const key of keys) {
|
||||
if (PENDING_CONSUMERS.has(key)) continue;
|
||||
assert.ok(
|
||||
|
||||
Reference in New Issue
Block a user