feat(panels): Disease Outbreaks, Shipping Stress, Social Velocity, nuclear test site enrichment (#2375)

* feat(panels): Disease Outbreaks, Shipping Stress, Social Velocity, nuclear test site monitoring

- Add HealthService proto with ListDiseaseOutbreaks RPC (WHO + ProMED RSS)
- Add GetShippingStress RPC to SupplyChainService (Yahoo Finance carrier ETFs)
- Add GetSocialVelocity RPC to IntelligenceService (Reddit r/worldnews + r/geopolitics)
- Enrich earthquake seed with Haversine nuclear test-site proximity scoring
- Add 5 nuclear test sites to NUCLEAR_FACILITIES (Punggye-ri, Lop Nur, Novaya Zemlya, Nevada NTS, Semipalatinsk)
- Add shipping stress + social velocity seed loops to ais-relay.cjs
- Add seed-disease-outbreaks.mjs Railway cron script
- Wire all new RPCs: edge functions, handlers, gateway cache tiers, health.js STANDALONE_KEYS/SEED_META

* fix(relay): apply gold standard retry/TTL-extend pattern to shipping-stress and social-velocity seeders

* fix(review): address all PR #2375 review findings

- health.js: shippingStress maxStaleMin 30→45 (3x interval), socialVelocity 20→30 (3x interval)
- health.js: remove shippingStress/diseaseOutbreaks/socialVelocity from ON_DEMAND_KEYS (relay/cron seeds, not on-demand)
- cache-keys.ts: add shippingStress, diseaseOutbreaks, socialVelocity to BOOTSTRAP_CACHE_KEYS
- ais-relay.cjs: stressScore formula 50→40 (neutral market = moderate, not elevated)
- ais-relay.cjs: fetchedAt Date.now() (consistent with other seeders)
- ais-relay.cjs: deduplicate cross-subreddit article URLs in social velocity loop
- seed-disease-outbreaks.mjs: WHO URL → specific DON RSS endpoint (not dead general news feed)
- seed-disease-outbreaks.mjs: validate() requires outbreaks.length >= 1 (reject empty array)
- seed-disease-outbreaks.mjs: stable id using hash(link) not array index
- seed-disease-outbreaks.mjs: RSS regexes use [\s\S]*? for CDATA multiline content
- seed-earthquakes.mjs: Lop Nur coordinates corrected (41.39,89.03 not 41.75,88.35)
- seed-earthquakes.mjs: sourceVersion bumped to usgs-4.5-day-nuclear-v1
- earthquake.proto: fields 8-11 marked optional (distinguish not-enriched from enriched=false/0)
- buf generate: regenerate seismology service stubs

* revert(cache-keys): don't add new keys to bootstrap without frontend consumers

* fix(panels): address all P1/P2/P3 review findings for PR #2375

- proto: add INT64_ENCODING_NUMBER annotation + sebuf import to get_shipping_stress.proto (run make generate)
- bootstrap: register shippingStress (fast), socialVelocity (fast), diseaseOutbreaks (slow) in api/bootstrap.js + cache-keys.ts
- relay: update WIDGET_SYSTEM_PROMPT with new bootstrap keys and live RPCs for health/supply-chain/intelligence
- seeder: remove broken ProMED feed URL (promedmail.org/feed/ returns HTML 404); add 500K size guard to fetchRssItems; replace private COUNTRY_CODE_MAP with shared geo-extract.mjs; remove permanently-empty location field; bump sourceVersion to who-don-rss-v2
- handlers: remove dead .catch from all 3 new RPC handlers; fix stressLevel fallback to low; fix fetchedAt fallback to 0
- services: add fetchShippingStress, disease-outbreaks.ts, social-velocity.ts with getHydratedData consumers
This commit is contained in:
Elie Habib
2026-03-27 22:33:45 +04:00
committed by GitHub
parent 7d02699f9b
commit 1e1f377078
42 changed files with 1359 additions and 10 deletions

6
api/bootstrap.js vendored
View File

@@ -75,6 +75,9 @@ const BOOTSTRAP_CACHE_KEYS = {
natGasStorage: 'economic:nat-gas-storage:v1',
ecbFxRates: 'economic:ecb-fx-rates:v1',
euFsi: 'economic:fsi-eu:v1',
shippingStress: 'supply_chain:shipping_stress:v1',
socialVelocity: 'intelligence:social:reddit:v1',
diseaseOutbreaks: 'health:disease-outbreaks:v1',
};
const SLOW_KEYS = new Set([
@@ -102,12 +105,13 @@ const SLOW_KEYS = new Set([
'natGasStorage',
'ecbFxRates',
'euFsi',
'diseaseOutbreaks',
]);
const FAST_KEYS = new Set([
'earthquakes', 'outages', 'serviceStatuses', 'ddosAttacks', 'trafficAnomalies', 'macroSignals', 'chokepoints', 'chokepointTransits',
'marketQuotes', 'commodityQuotes', 'positiveGeoEvents', 'riskScores', 'flightDelays','insights', 'predictions',
'iranEvents', 'temporalAnomalies', 'weatherAlerts', 'spending', 'theaterPosture', 'gdeltIntel',
'correlationCards', 'forecasts', 'shippingRates',
'correlationCards', 'forecasts', 'shippingRates', 'shippingStress', 'socialVelocity',
]);
// No public/s-maxage: CF (in front of api.worldmonitor.app) ignores Vary: Origin and would

View File

@@ -112,6 +112,9 @@ const STANDALONE_KEYS = {
hormuzTracker: 'supply_chain:hormuz_tracker:v1',
simulationPackageLatest: 'forecast:simulation-package:latest',
simulationOutcomeLatest: 'forecast:simulation-outcome:latest',
shippingStress: 'supply_chain:shipping_stress:v1',
diseaseOutbreaks: 'health:disease-outbreaks:v1',
socialVelocity: 'intelligence:social:reddit:v1',
};
const SEED_META = {
@@ -209,6 +212,9 @@ const SEED_META = {
euYieldCurve: { key: 'seed-meta:economic:yield-curve-eu', maxStaleMin: 2880 }, // daily seed (weekdays); 2880min = 48h = 2x interval
euFsi: { key: 'seed-meta:economic:fsi-eu', maxStaleMin: 20160 }, // weekly seed (Saturday); 20160min = 14d = 2x interval
newsThreatSummary: { key: 'seed-meta:news:threat-summary', maxStaleMin: 60 }, // relay classify every ~20min; 60min = 3x interval
shippingStress: { key: 'seed-meta:supply_chain:shipping_stress', maxStaleMin: 45 }, // relay loop every 15min; 45 = 3x interval (was 30 = 2×, too tight on relay hiccup)
diseaseOutbreaks: { key: 'seed-meta:health:disease-outbreaks', maxStaleMin: 2880 }, // daily seed; 2880 = 48h = 2x interval
socialVelocity: { key: 'seed-meta:intelligence:social-reddit', maxStaleMin: 30 }, // relay loop every 10min; 30 = 3x interval (was 20 = equals retry window, too tight)
};
// Standalone keys that are populated on-demand by RPC handlers (not seeds).

9
api/health/v1/[rpc].ts Normal file
View File

@@ -0,0 +1,9 @@
export const config = { runtime: 'edge' };
import { createDomainGateway, serverOptions } from '../../../server/gateway';
import { createHealthServiceRoutes } from '../../../src/generated/server/worldmonitor/health/v1/service_server';
import { healthHandler } from '../../../server/worldmonitor/health/v1/handler';
export default createDomainGateway(
createHealthServiceRoutes(healthHandler, serverOptions),
);

View File

@@ -0,0 +1 @@
{"components":{"schemas":{"DiseaseOutbreakItem":{"description":"DiseaseOutbreakItem represents a single disease outbreak event.","properties":{"alertLevel":{"description":"Alert level: \"watch\" | \"warning\" | \"alert\".","type":"string"},"countryCode":{"description":"ISO2 country code when known.","type":"string"},"disease":{"description":"Disease or outbreak name.","type":"string"},"id":{"description":"Unique identifier (URL-derived).","type":"string"},"location":{"description":"Affected country or region.","type":"string"},"publishedAt":{"description":"Unix epoch milliseconds when published.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"sourceName":{"description":"Source name (e.g., \"WHO\", \"ProMED\", \"HealthMap\").","type":"string"},"sourceUrl":{"description":"Source URL.","type":"string"},"summary":{"description":"Short description from the source.","type":"string"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"ListDiseaseOutbreaksRequest":{"type":"object"},"ListDiseaseOutbreaksResponse":{"properties":{"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"outbreaks":{"items":{"$ref":"#/components/schemas/DiseaseOutbreakItem"},"type":"array"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"HealthService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/health/v1/list-disease-outbreaks":{"get":{"description":"ListDiseaseOutbreaks returns recent WHO/ProMED disease outbreak alerts.","operationId":"ListDiseaseOutbreaks","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDiseaseOutbreaksResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListDiseaseOutbreaks","tags":["HealthService"]}}}}

View File

@@ -0,0 +1,109 @@
openapi: 3.1.0
info:
title: HealthService API
version: 1.0.0
paths:
/api/health/v1/list-disease-outbreaks:
get:
tags:
- HealthService
summary: ListDiseaseOutbreaks
description: ListDiseaseOutbreaks returns recent WHO/ProMED disease outbreak alerts.
operationId: ListDiseaseOutbreaks
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListDiseaseOutbreaksResponse'
"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:
type: object
properties:
message:
type: string
description: Error message (e.g., 'user not found', 'database connection failed')
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
FieldViolation:
type: object
properties:
field:
type: string
description: The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')
description:
type: string
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
required:
- field
- description
description: FieldViolation describes a single validation error for a specific field.
ValidationError:
type: object
properties:
violations:
type: array
items:
$ref: '#/components/schemas/FieldViolation'
description: List of validation violations
required:
- violations
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
ListDiseaseOutbreaksRequest:
type: object
ListDiseaseOutbreaksResponse:
type: object
properties:
outbreaks:
type: array
items:
$ref: '#/components/schemas/DiseaseOutbreakItem'
fetchedAt:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
DiseaseOutbreakItem:
type: object
properties:
id:
type: string
description: Unique identifier (URL-derived).
disease:
type: string
description: Disease or outbreak name.
location:
type: string
description: Affected country or region.
countryCode:
type: string
description: ISO2 country code when known.
alertLevel:
type: string
description: 'Alert level: "watch" | "warning" | "alert".'
summary:
type: string
description: Short description from the source.
sourceUrl:
type: string
description: Source URL.
publishedAt:
type: integer
format: int64
description: 'Unix epoch milliseconds when published.. Warning: Values > 2^53 may lose precision in JavaScript'
sourceName:
type: string
description: Source name (e.g., "WHO", "ProMED", "HealthMap").
description: DiseaseOutbreakItem represents a single disease outbreak event.

File diff suppressed because one or more lines are too long

View File

@@ -607,6 +607,32 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/intelligence/v1/get-social-velocity:
get:
tags:
- IntelligenceService
summary: GetSocialVelocity
description: GetSocialVelocity returns trending Reddit posts from r/worldnews and r/geopolitics ranked by velocity.
operationId: GetSocialVelocity
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetSocialVelocityResponse'
"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:
@@ -1787,3 +1813,52 @@ components:
type: string
driver:
type: string
GetSocialVelocityRequest:
type: object
GetSocialVelocityResponse:
type: object
properties:
posts:
type: array
items:
$ref: '#/components/schemas/SocialVelocityPost'
fetchedAt:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
SocialVelocityPost:
type: object
properties:
id:
type: string
description: Reddit post ID.
title:
type: string
description: Post title.
subreddit:
type: string
description: Subreddit name (without r/ prefix).
url:
type: string
description: Direct URL to the post.
score:
type: integer
format: int32
description: Reddit score (upvotes - downvotes).
upvoteRatio:
type: number
format: double
description: Upvote ratio (0.01.0).
numComments:
type: integer
format: int32
description: Number of comments.
velocityScore:
type: number
format: double
description: Composite velocity score accounting for recency, score, and ratio.
createdAt:
type: integer
format: int64
description: 'Unix epoch milliseconds when posted.. Warning: Values > 2^53 may lose precision in JavaScript'
description: SocialVelocityPost represents a trending Reddit post with velocity scoring.

File diff suppressed because one or more lines are too long

View File

@@ -158,6 +158,19 @@ components:
sourceUrl:
type: string
description: URL to the USGS event detail page.
nearTestSite:
type: boolean
description: True when epicenter is within 100 km of a known nuclear test site.
testSiteName:
type: string
description: Name of the nearest test site (e.g. "Punggye-ri"), when near_test_site is true.
concernScore:
type: number
format: double
description: Composite concern score 0100 based on magnitude, depth, and proximity.
concernLevel:
type: string
description: 'Human-readable concern level: "low" | "moderate" | "elevated" | "critical".'
required:
- id
description: Earthquake represents a seismic event from USGS GeoJSON feed.

File diff suppressed because one or more lines are too long

View File

@@ -78,6 +78,32 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/supply-chain/v1/get-shipping-stress:
get:
tags:
- SupplyChainService
summary: GetShippingStress
description: GetShippingStress returns carrier market data and a composite stress index.
operationId: GetShippingStress
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetShippingStressResponse'
"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:
@@ -318,3 +344,50 @@ components:
sharePct:
type: number
format: double
GetShippingStressRequest:
type: object
GetShippingStressResponse:
type: object
properties:
carriers:
type: array
items:
$ref: '#/components/schemas/ShippingStressCarrier'
stressScore:
type: number
format: double
description: Composite stress score 0100 (higher = more disruption).
stressLevel:
type: string
description: '"low" | "moderate" | "elevated" | "critical".'
fetchedAt:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
ShippingStressCarrier:
type: object
properties:
symbol:
type: string
description: Ticker or identifier (e.g., "BDRY", "ZIM").
name:
type: string
description: Human-readable name.
price:
type: number
format: double
description: Current price.
changePct:
type: number
format: double
description: Percentage change from previous close.
carrierType:
type: string
description: 'Carrier type: "etf" | "carrier" | "index".'
sparkline:
type: array
items:
type: number
format: double
description: 30-day price sparkline.
description: ShippingStressCarrier represents market stress data for a carrier or shipping index.

View File

@@ -0,0 +1,34 @@
syntax = "proto3";
package worldmonitor.health.v1;
import "sebuf/http/annotations.proto";
// DiseaseOutbreakItem represents a single disease outbreak event.
message DiseaseOutbreakItem {
// Unique identifier (URL-derived).
string id = 1;
// Disease or outbreak name.
string disease = 2;
// Affected country or region.
string location = 3;
// ISO2 country code when known.
string country_code = 4;
// Alert level: "watch" | "warning" | "alert".
string alert_level = 5;
// Short description from the source.
string summary = 6;
// Source URL.
string source_url = 7;
// Unix epoch milliseconds when published.
int64 published_at = 8 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
// Source name (e.g., "WHO", "ProMED", "HealthMap").
string source_name = 9;
}
message ListDiseaseOutbreaksRequest {}
message ListDiseaseOutbreaksResponse {
repeated DiseaseOutbreakItem outbreaks = 1;
int64 fetched_at = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
}

View File

@@ -0,0 +1,15 @@
syntax = "proto3";
package worldmonitor.health.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/health/v1/list_disease_outbreaks.proto";
service HealthService {
option (sebuf.http.service_config) = {base_path: "/api/health/v1"};
// ListDiseaseOutbreaks returns recent WHO/ProMED disease outbreak alerts.
rpc ListDiseaseOutbreaks(ListDiseaseOutbreaksRequest) returns (ListDiseaseOutbreaksResponse) {
option (sebuf.http.config) = {path: "/list-disease-outbreaks", method: HTTP_METHOD_GET};
}
}

View File

@@ -0,0 +1,34 @@
syntax = "proto3";
package worldmonitor.intelligence.v1;
import "sebuf/http/annotations.proto";
// SocialVelocityPost represents a trending Reddit post with velocity scoring.
message SocialVelocityPost {
// Reddit post ID.
string id = 1;
// Post title.
string title = 2;
// Subreddit name (without r/ prefix).
string subreddit = 3;
// Direct URL to the post.
string url = 4;
// Reddit score (upvotes - downvotes).
int32 score = 5;
// Upvote ratio (0.01.0).
double upvote_ratio = 6;
// Number of comments.
int32 num_comments = 7;
// Composite velocity score accounting for recency, score, and ratio.
double velocity_score = 8;
// Unix epoch milliseconds when posted.
int64 created_at = 9 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
}
message GetSocialVelocityRequest {}
message GetSocialVelocityResponse {
repeated SocialVelocityPost posts = 1;
int64 fetched_at = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
}

View File

@@ -19,6 +19,7 @@ import "worldmonitor/intelligence/v1/search_gdelt_documents.proto";
import "worldmonitor/intelligence/v1/get_gdelt_topic_timeline.proto";
import "worldmonitor/intelligence/v1/list_cross_source_signals.proto";
import "worldmonitor/intelligence/v1/list_market_implications.proto";
import "worldmonitor/intelligence/v1/get_social_velocity.proto";
// IntelligenceService provides APIs for technical and strategic intelligence.
service IntelligenceService {
@@ -138,4 +139,9 @@ service IntelligenceService {
rpc ListMarketImplications(ListMarketImplicationsRequest) returns (ListMarketImplicationsResponse) {
option (sebuf.http.config) = {path: "/list-market-implications", method: HTTP_METHOD_GET};
}
// GetSocialVelocity returns trending Reddit posts from r/worldnews and r/geopolitics ranked by velocity.
rpc GetSocialVelocity(GetSocialVelocityRequest) returns (GetSocialVelocityResponse) {
option (sebuf.http.config) = {path: "/get-social-velocity", method: HTTP_METHOD_GET};
}
}

View File

@@ -26,4 +26,12 @@ message Earthquake {
int64 occurred_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
// URL to the USGS event detail page.
string source_url = 7;
// True when epicenter is within 100 km of a known nuclear test site.
optional bool near_test_site = 8;
// Name of the nearest test site (e.g. "Punggye-ri"), when near_test_site is true.
optional string test_site_name = 9;
// Composite concern score 0100 based on magnitude, depth, and proximity.
optional double concern_score = 10;
// Human-readable concern level: "low" | "moderate" | "elevated" | "critical".
optional string concern_level = 11;
}

View File

@@ -0,0 +1,32 @@
syntax = "proto3";
package worldmonitor.supply_chain.v1;
import "sebuf/http/annotations.proto";
// ShippingStressCarrier represents market stress data for a carrier or shipping index.
message ShippingStressCarrier {
// Ticker or identifier (e.g., "BDRY", "ZIM").
string symbol = 1;
// Human-readable name.
string name = 2;
// Current price.
double price = 3;
// Percentage change from previous close.
double change_pct = 4;
// Carrier type: "etf" | "carrier" | "index".
string carrier_type = 5;
// 30-day price sparkline.
repeated double sparkline = 6;
}
message GetShippingStressRequest {}
message GetShippingStressResponse {
repeated ShippingStressCarrier carriers = 1;
// Composite stress score 0100 (higher = more disruption).
double stress_score = 2;
// "low" | "moderate" | "elevated" | "critical".
string stress_level = 3;
int64 fetched_at = 4 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
}

View File

@@ -6,6 +6,7 @@ import "sebuf/http/annotations.proto";
import "worldmonitor/supply_chain/v1/get_shipping_rates.proto";
import "worldmonitor/supply_chain/v1/get_chokepoint_status.proto";
import "worldmonitor/supply_chain/v1/get_critical_minerals.proto";
import "worldmonitor/supply_chain/v1/get_shipping_stress.proto";
service SupplyChainService {
option (sebuf.http.service_config) = {base_path: "/api/supply-chain/v1"};
@@ -21,4 +22,9 @@ service SupplyChainService {
rpc GetCriticalMinerals(GetCriticalMineralsRequest) returns (GetCriticalMineralsResponse) {
option (sebuf.http.config) = {path: "/get-critical-minerals", method: HTTP_METHOD_GET};
}
// GetShippingStress returns carrier market data and a composite stress index.
rpc GetShippingStress(GetShippingStressRequest) returns (GetShippingStressResponse) {
option (sebuf.http.config) = {path: "/get-shipping-stress", method: HTTP_METHOD_GET};
}
}

View File

@@ -5000,6 +5000,173 @@ async function startUsniFleetSeedLoop() {
}, USNI_SEED_INTERVAL_MS).unref?.();
}
// ─────────────────────────────────────────────────────────────
// Shipping Stress Index — Yahoo Finance carrier/ETF market data
// ─────────────────────────────────────────────────────────────
const SHIPPING_STRESS_REDIS_KEY = 'supply_chain:shipping_stress:v1';
const SHIPPING_STRESS_TTL = 3600; // 1h — seed runs every 15min (4× safety margin)
const SHIPPING_STRESS_INTERVAL_MS = 15 * 60 * 1000;
const SHIPPING_CARRIERS = [
{ symbol: 'BDRY', name: 'Breakwave Dry Bulk ETF', carrierType: 'etf' },
{ symbol: 'ZIM', name: 'ZIM Integrated Shipping', carrierType: 'carrier' },
{ symbol: 'MATX', name: 'Matson Inc', carrierType: 'carrier' },
{ symbol: 'SBLK', name: 'Star Bulk Carriers', carrierType: 'carrier' },
{ symbol: 'GOGL', name: 'Golden Ocean Group', carrierType: 'carrier' },
];
let shippingStressInFlight = false;
let shippingStressRetryTimer = null;
const SHIPPING_STRESS_RETRY_MS = 20 * 60 * 1000;
async function seedShippingStress() {
if (shippingStressInFlight) { console.log('[ShippingStress] Skipped (in-flight)'); return; }
shippingStressInFlight = true;
if (shippingStressRetryTimer) { clearTimeout(shippingStressRetryTimer); shippingStressRetryTimer = null; }
console.log('[ShippingStress] Fetching...');
const t0 = Date.now();
try {
const results = [];
for (const carrier of SHIPPING_CARRIERS) {
await new Promise(r => setTimeout(r, 150));
const quote = await fetchYahooChartDirect(carrier.symbol);
if (!quote) continue;
results.push({
symbol: carrier.symbol,
name: carrier.name,
carrierType: carrier.carrierType,
price: quote.price,
changePct: Number(quote.change.toFixed(2)),
sparkline: quote.sparkline,
});
}
if (!results.length) {
console.warn('[ShippingStress] No carrier data — extending TTL, retrying in 20min');
try { await upstashExpire(SHIPPING_STRESS_REDIS_KEY, SHIPPING_STRESS_TTL); } catch {}
shippingStressRetryTimer = setTimeout(() => { seedShippingStress().catch(() => {}); }, SHIPPING_STRESS_RETRY_MS);
return;
}
const avgChange = results.reduce((a, b) => a + b.changePct, 0) / results.length;
// Neutral market (0% change) → score=40 (moderate). Positive change = lower stress.
const stressScore = Math.min(100, Math.max(0, Math.round(40 - avgChange * 3)));
const stressLevel = stressScore >= 75 ? 'critical' : stressScore >= 50 ? 'elevated' : stressScore >= 25 ? 'moderate' : 'low';
const payload = { carriers: results, stressScore, stressLevel, fetchedAt: Date.now() };
const ok = await upstashSet(SHIPPING_STRESS_REDIS_KEY, payload, SHIPPING_STRESS_TTL);
await upstashSet('seed-meta:supply_chain:shipping_stress', { fetchedAt: Date.now(), recordCount: results.length }, 604800);
console.log(`[ShippingStress] Seeded ${results.length} carriers score=${stressScore}/${stressLevel} (redis: ${ok ? 'OK' : 'FAIL'}) in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
} catch (e) {
console.warn('[ShippingStress] Seed error:', e?.message || e, '— extending TTL, retrying in 20min');
try { await upstashExpire(SHIPPING_STRESS_REDIS_KEY, SHIPPING_STRESS_TTL); } catch {}
shippingStressRetryTimer = setTimeout(() => { seedShippingStress().catch(() => {}); }, SHIPPING_STRESS_RETRY_MS);
} finally {
shippingStressInFlight = false;
}
}
async function startShippingStressSeedLoop() {
if (!UPSTASH_ENABLED) {
console.log('[ShippingStress] Disabled (no Upstash Redis)');
return;
}
console.log(`[ShippingStress] Seed loop starting (interval ${SHIPPING_STRESS_INTERVAL_MS / 1000 / 60}min)`);
seedShippingStress().catch(e => console.warn('[ShippingStress] Initial seed error:', e?.message || e));
setInterval(() => {
seedShippingStress().catch(e => console.warn('[ShippingStress] Seed error:', e?.message || e));
}, SHIPPING_STRESS_INTERVAL_MS).unref?.();
}
// ─────────────────────────────────────────────────────────────
// Social Velocity — Reddit r/worldnews + r/geopolitics trending
// ─────────────────────────────────────────────────────────────
const SOCIAL_VELOCITY_REDIS_KEY = 'intelligence:social:reddit:v1';
const SOCIAL_VELOCITY_TTL = 1800; // 30min — seed runs every 10min (3× safety margin)
const SOCIAL_VELOCITY_INTERVAL_MS = 10 * 60 * 1000;
const REDDIT_SUBREDDITS = ['worldnews', 'geopolitics'];
let socialVelocityInFlight = false;
let socialVelocityRetryTimer = null;
const SOCIAL_VELOCITY_RETRY_MS = 20 * 60 * 1000;
async function fetchRedditHot(subreddit) {
const url = `https://www.reddit.com/r/${subreddit}/hot.json?limit=25&raw_json=1`;
const resp = await fetch(url, {
headers: { Accept: 'application/json', 'User-Agent': 'WorldMonitor/1.0 (contact: info@worldmonitor.app)' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) { console.warn(`[SocialVelocity] Reddit r/${subreddit} HTTP ${resp.status}`); return []; }
const data = await resp.json();
return (data?.data?.children || []).map(c => c.data).filter(Boolean);
}
async function seedSocialVelocity() {
if (socialVelocityInFlight) { console.log('[SocialVelocity] Skipped (in-flight)'); return; }
socialVelocityInFlight = true;
if (socialVelocityRetryTimer) { clearTimeout(socialVelocityRetryTimer); socialVelocityRetryTimer = null; }
console.log('[SocialVelocity] Fetching...');
const t0 = Date.now();
try {
const nowSec = Date.now() / 1000;
const allPosts = [];
const seenUrls = new Set();
for (const sub of REDDIT_SUBREDDITS) {
await new Promise(r => setTimeout(r, 500));
const posts = await fetchRedditHot(sub);
for (const p of posts) {
// Deduplicate cross-subreddit reposts of the same article URL.
const articleUrl = p.url || '';
const isExternal = articleUrl && !articleUrl.includes('reddit.com');
if (isExternal && seenUrls.has(articleUrl)) continue;
if (isExternal) seenUrls.add(articleUrl);
const ageSec = Math.max(1, nowSec - (p.created_utc || nowSec));
const recencyFactor = Math.exp(-ageSec / (6 * 3600));
const velocityScore = Math.log1p(p.score || 1) * (p.upvote_ratio || 0.5) * recencyFactor * 100;
allPosts.push({
id: String(p.id || ''),
title: String(p.title || '').slice(0, 300),
subreddit: sub,
url: `https://reddit.com${p.permalink || ''}`,
score: p.score || 0,
upvoteRatio: p.upvote_ratio || 0,
numComments: p.num_comments || 0,
velocityScore: Math.round(velocityScore * 10) / 10,
createdAt: Math.round((p.created_utc || nowSec) * 1000),
});
}
}
if (!allPosts.length) {
console.warn('[SocialVelocity] No posts — extending TTL, retrying in 20min');
try { await upstashExpire(SOCIAL_VELOCITY_REDIS_KEY, SOCIAL_VELOCITY_TTL); } catch {}
socialVelocityRetryTimer = setTimeout(() => { seedSocialVelocity().catch(() => {}); }, SOCIAL_VELOCITY_RETRY_MS);
return;
}
allPosts.sort((a, b) => b.velocityScore - a.velocityScore);
const top = allPosts.slice(0, 30);
const payload = { posts: top, fetchedAt: Date.now() };
const ok = await upstashSet(SOCIAL_VELOCITY_REDIS_KEY, payload, SOCIAL_VELOCITY_TTL);
await upstashSet('seed-meta:intelligence:social-reddit', { fetchedAt: Date.now(), recordCount: top.length }, 604800);
console.log(`[SocialVelocity] Seeded ${top.length} posts (redis: ${ok ? 'OK' : 'FAIL'}) in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
} catch (e) {
console.warn('[SocialVelocity] Seed error:', e?.message || e, '— extending TTL, retrying in 20min');
try { await upstashExpire(SOCIAL_VELOCITY_REDIS_KEY, SOCIAL_VELOCITY_TTL); } catch {}
socialVelocityRetryTimer = setTimeout(() => { seedSocialVelocity().catch(() => {}); }, SOCIAL_VELOCITY_RETRY_MS);
} finally {
socialVelocityInFlight = false;
}
}
async function startSocialVelocitySeedLoop() {
if (!UPSTASH_ENABLED) {
console.log('[SocialVelocity] Disabled (no Upstash Redis)');
return;
}
console.log(`[SocialVelocity] Seed loop starting (interval ${SOCIAL_VELOCITY_INTERVAL_MS / 1000 / 60}min)`);
seedSocialVelocity().catch(e => console.warn('[SocialVelocity] Initial seed error:', e?.message || e));
setInterval(() => {
seedSocialVelocity().catch(e => console.warn('[SocialVelocity] Seed error:', e?.message || e));
}, SOCIAL_VELOCITY_INTERVAL_MS).unref?.();
}
function gzipSyncBuffer(body) {
try {
@@ -8279,11 +8446,15 @@ Infrastructure & Environment:
radiationWatch, weatherAlerts, outages, serviceStatuses, ddosAttacks, trafficAnomalies
Supply Chain & Trade:
shippingRates, chokepoints, chokepointTransits, minerals, customsRevenue, sanctionsPressure
shippingRates, chokepoints, chokepointTransits, minerals, customsRevenue, sanctionsPressure,
shippingStress
Consumer Prices:
consumerPricesOverview, consumerPricesCategories, consumerPricesMovers, consumerPricesSpread
Health & Social:
diseaseOutbreaks, socialVelocity
Other:
flightDelays, cyberThreats, positiveGeoEvents, predictions, forecasts, giving, insights
@@ -8293,7 +8464,10 @@ economic: list-world-bank-indicators (params: indicator, country_code),
get-fred-series (params: series_id e.g. UNRATE/CPIAUCSL/DGS10), get-eurostat-country-data
trade: get-trade-flows, get-trade-restrictions, get-tariff-trends, get-trade-barriers, list-comtrade-flows
aviation: get-airport-ops-summary (params: airport_code), get-carrier-ops (params: carrier_code), list-aviation-news
intelligence: get-country-intel-brief (params: country_code), get-country-facts (params: country_code)
intelligence: get-country-intel-brief (params: country_code), get-country-facts (params: country_code),
get-social-velocity
health: list-disease-outbreaks
supply-chain: get-shipping-stress
conflict: list-acled-events, get-humanitarian-summary (params: country_code)
market: get-country-stock-index (params: country_code), list-earnings-calendar, get-cot-positioning
consumer-prices: list-retailer-price-spreads
@@ -8852,11 +9026,15 @@ Infrastructure & Environment:
radiationWatch, weatherAlerts, outages, serviceStatuses, ddosAttacks, trafficAnomalies
Supply Chain & Trade:
shippingRates, chokepoints, chokepointTransits, minerals, customsRevenue, sanctionsPressure
shippingRates, chokepoints, chokepointTransits, minerals, customsRevenue, sanctionsPressure,
shippingStress
Consumer Prices:
consumerPricesOverview, consumerPricesCategories, consumerPricesMovers, consumerPricesSpread
Health & Social:
diseaseOutbreaks, socialVelocity
Other:
flightDelays, cyberThreats, positiveGeoEvents, predictions, forecasts, giving, insights
@@ -8866,7 +9044,10 @@ economic: list-world-bank-indicators (params: indicator, country_code),
get-fred-series (params: series_id e.g. UNRATE/CPIAUCSL/DGS10), get-eurostat-country-data
trade: get-trade-flows, get-trade-restrictions, get-tariff-trends, get-trade-barriers, list-comtrade-flows
aviation: get-airport-ops-summary (params: airport_code), get-carrier-ops (params: carrier_code), list-aviation-news
intelligence: get-country-intel-brief (params: country_code), get-country-facts (params: country_code)
intelligence: get-country-intel-brief (params: country_code), get-country-facts (params: country_code),
get-social-velocity
health: list-disease-outbreaks
supply-chain: get-shipping-stress
conflict: list-acled-events, get-humanitarian-summary (params: country_code)
market: get-country-stock-index (params: country_code), list-earnings-calendar, get-cot-positioning
consumer-prices: list-retailer-price-spreads
@@ -9084,6 +9265,8 @@ server.listen(PORT, () => {
startPortWatchSeedLoop();
startCorridorRiskSeedLoop();
startUsniFleetSeedLoop();
startShippingStressSeedLoop();
startSocialVelocitySeedLoop();
});
wss.on('connection', (ws, req) => {

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
import { extractCountryCode } from './shared/geo-extract.mjs';
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'health:disease-outbreaks:v1';
const CACHE_TTL = 86400; // 24h — daily seed
// WHO Disease Outbreak News RSS (specific DON feed, not general news)
const WHO_FEED = 'https://www.who.int/feeds/entity/csr/don/en/rss.xml';
// ProMED RSS — promedmail.org/feed/ returns HTML 404; omitted until a valid feed URL is confirmed
// const PROMED_FEED = 'https://promedmail.org/feed/';
const RSS_MAX_BYTES = 500_000; // guard against oversized responses before regex
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 detectAlertLevel(title, desc) {
const text = `${title} ${desc}`.toLowerCase();
if (text.includes('outbreak') || text.includes('emergency') || text.includes('epidemic') || text.includes('pandemic')) return 'alert';
if (text.includes('warning') || text.includes('spread') || text.includes('cases increasing')) return 'warning';
return 'watch';
}
function detectDisease(title) {
const lower = title.toLowerCase();
const known = ['mpox', 'monkeypox', 'ebola', 'cholera', 'covid', 'dengue', 'measles',
'polio', 'marburg', 'lassa', 'plague', 'yellow fever', 'typhoid', 'influenza',
'avian flu', 'h5n1', 'h5n2', 'anthrax', 'rabies', 'meningitis', 'hepatitis',
'nipah', 'rift valley', 'crimean-congo', 'leishmaniasis', 'malaria'];
for (const d of known) {
if (lower.includes(d)) return d.charAt(0).toUpperCase() + d.slice(1);
}
return 'Unknown Disease';
}
async function fetchRssItems(url, sourceName) {
try {
const resp = await fetch(url, {
headers: { Accept: 'application/rss+xml, application/xml, text/xml', 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) { console.warn(`[Disease] ${sourceName} HTTP ${resp.status}`); return []; }
const raw = await resp.text();
const xml = raw.length > RSS_MAX_BYTES ? raw.slice(0, RSS_MAX_BYTES) : raw;
const items = [];
const itemRe = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRe.exec(xml)) !== null) {
const block = match[1];
const title = (block.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/) || [])[1]?.trim() || '';
const link = (block.match(/<link>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/link>/) || [])[1]?.trim() || '';
const desc = (block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/) || [])[1]?.replace(/<[^>]+>/g, '').trim().slice(0, 300) || '';
const pubDate = (block.match(/<pubDate>(.*?)<\/pubDate>/) || [])[1]?.trim() || '';
const publishedMs = pubDate ? new Date(pubDate).getTime() : Date.now();
if (!title || isNaN(publishedMs)) continue;
items.push({ title, link, desc, publishedMs, sourceName });
}
return items;
} catch (e) {
console.warn(`[Disease] ${sourceName} fetch error:`, e?.message || e);
return [];
}
}
async function fetchDiseaseOutbreaks() {
const whoItems = await fetchRssItems(WHO_FEED, 'WHO');
const diseaseKeywords = ['outbreak', 'disease', 'virus', 'fever', 'flu', 'ebola', 'mpox',
'cholera', 'dengue', 'measles', 'polio', 'plague', 'avian', 'h5n1', 'epidemic',
'infection', 'pathogen', 'rabies', 'meningitis', 'hepatitis', 'nipah', 'marburg'];
const relevant = whoItems.filter(item => {
const text = `${item.title} ${item.desc}`.toLowerCase();
return diseaseKeywords.some(k => text.includes(k));
});
const outbreaks = relevant.map((item) => ({
id: `${item.sourceName.toLowerCase()}-${stableHash(item.link || item.title)}-${item.publishedMs}`,
disease: detectDisease(item.title),
countryCode: extractCountryCode(`${item.title} ${item.desc}`) ?? '',
alertLevel: detectAlertLevel(item.title, item.desc),
summary: item.desc,
sourceUrl: item.link,
publishedAt: item.publishedMs,
sourceName: item.sourceName,
}));
outbreaks.sort((a, b) => b.publishedAt - a.publishedAt);
return { outbreaks: outbreaks.slice(0, 50), fetchedAt: Date.now() };
}
function validate(data) {
return Array.isArray(data?.outbreaks) && data.outbreaks.length >= 1;
}
runSeed('health', 'disease-outbreaks', CANONICAL_KEY, fetchDiseaseOutbreaks, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: 'who-don-rss-v2',
}).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);
});

View File

@@ -8,6 +8,52 @@ const USGS_FEED_URL = 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary
const CANONICAL_KEY = 'seismology:earthquakes:v1';
const CACHE_TTL = 21600; // 6h — 6x the 1h cron interval (was 2x = survived only 1 missed run)
const TEST_SITES = [
{ name: 'Punggye-ri', lat: 41.28, lon: 129.08 },
{ name: 'Lop Nur', lat: 41.39, lon: 89.03 },
{ name: 'Novaya Zemlya', lat: 73.37, lon: 54.78 },
{ name: 'Nevada NTS', lat: 37.07, lon: -116.05 },
{ name: 'Semipalatinsk', lat: 50.07, lon: 78.43 },
];
const TEST_SITE_RADIUS_KM = 100;
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function enrichWithTestSite(eq) {
const lat = eq.location?.latitude ?? 0;
const lon = eq.location?.longitude ?? 0;
let nearest = null;
let nearestKm = Infinity;
for (const site of TEST_SITES) {
const km = haversineKm(lat, lon, site.lat, site.lon);
if (km < nearestKm) { nearestKm = km; nearest = site; }
}
if (nearest && nearestKm <= TEST_SITE_RADIUS_KM) {
const mag = eq.magnitude ?? 0;
const depthFactor = Math.max(0, 1 - (eq.depthKm ?? 0) / 100);
const raw =
(mag / 9) * 0.6 +
((TEST_SITE_RADIUS_KM - nearestKm) / TEST_SITE_RADIUS_KM) * 0.25 +
depthFactor * 0.15;
const concernScore = Math.min(100, Math.round(raw * 100));
const concernLevel =
concernScore >= 75 ? 'critical'
: concernScore >= 50 ? 'elevated'
: concernScore >= 25 ? 'moderate'
: 'low';
return { ...eq, nearTestSite: true, testSiteName: nearest.name, concernScore, concernLevel };
}
return eq;
}
async function fetchEarthquakes() {
const resp = await fetch(USGS_FEED_URL, {
headers: { Accept: 'application/json', 'User-Agent': CHROME_UA },
@@ -33,7 +79,7 @@ async function fetchEarthquakes() {
sourceUrl: String(f.properties?.url || ''),
}));
return { earthquakes };
return { earthquakes: earthquakes.map(enrichWithTestSite) };
}
function validate(data) {
@@ -43,7 +89,7 @@ function validate(data) {
runSeed('seismology', 'earthquakes', CANONICAL_KEY, fetchEarthquakes, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: 'usgs-4.5-day',
sourceVersion: 'usgs-4.5-day-nuclear-v1',
}).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);

View File

@@ -77,6 +77,9 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
euGasStorage: 'economic:eu-gas-storage:v1',
eurostatCountryData: 'economic:eurostat-country-data:v1',
euFsi: 'economic:fsi-eu:v1',
shippingStress: 'supply_chain:shipping_stress:v1',
socialVelocity: 'intelligence:social:reddit:v1',
diseaseOutbreaks: 'health:disease-outbreaks:v1',
};
export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
@@ -115,4 +118,7 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
euGasStorage: 'slow',
eurostatCountryData: 'slow',
euFsi: 'slow',
shippingStress: 'fast',
socialVelocity: 'fast',
diseaseOutbreaks: 'slow',
};

View File

@@ -195,6 +195,9 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
'/api/economic/v1/get-eurostat-country-data': 'slow',
'/api/economic/v1/get-eu-gas-storage': 'slow',
'/api/economic/v1/get-eu-fsi': 'slow',
'/api/supply-chain/v1/get-shipping-stress': 'medium',
'/api/health/v1/list-disease-outbreaks': 'slow',
'/api/intelligence/v1/get-social-velocity': 'fast',
};
import { PREMIUM_RPC_PATHS } from '../src/shared/premium-paths';

View File

@@ -0,0 +1,7 @@
import type { HealthServiceHandler } from '../../../../src/generated/server/worldmonitor/health/v1/service_server';
import { listDiseaseOutbreaks } from './list-disease-outbreaks';
export const healthHandler: HealthServiceHandler = {
listDiseaseOutbreaks,
};

View File

@@ -0,0 +1,18 @@
import type {
HealthServiceHandler,
ServerContext,
ListDiseaseOutbreaksRequest,
ListDiseaseOutbreaksResponse,
} from '../../../../src/generated/server/worldmonitor/health/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const REDIS_KEY = 'health:disease-outbreaks:v1';
export const listDiseaseOutbreaks: HealthServiceHandler['listDiseaseOutbreaks'] = async (
_ctx: ServerContext,
_req: ListDiseaseOutbreaksRequest,
): Promise<ListDiseaseOutbreaksResponse> => {
const data = (await getCachedJson(REDIS_KEY, true)) as ListDiseaseOutbreaksResponse | null;
return data ?? { outbreaks: [], fetchedAt: 0 };
};

View File

@@ -0,0 +1,18 @@
import type {
IntelligenceServiceHandler,
ServerContext,
GetSocialVelocityRequest,
GetSocialVelocityResponse,
} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const REDIS_KEY = 'intelligence:social:reddit:v1';
export const getSocialVelocity: IntelligenceServiceHandler['getSocialVelocity'] = async (
_ctx: ServerContext,
_req: GetSocialVelocityRequest,
): Promise<GetSocialVelocityResponse> => {
const data = (await getCachedJson(REDIS_KEY, true)) as GetSocialVelocityResponse | null;
return data ?? { posts: [], fetchedAt: 0 };
};

View File

@@ -17,6 +17,7 @@ import { listCompanySignals } from './list-company-signals';
import { getGdeltTopicTimeline } from './get-gdelt-topic-timeline';
import { listCrossSourceSignals } from './list-cross-source-signals';
import { listMarketImplications } from './list-market-implications';
import { getSocialVelocity } from './get-social-velocity';
export const intelligenceHandler: IntelligenceServiceHandler = {
getRiskScores,
@@ -36,4 +37,5 @@ export const intelligenceHandler: IntelligenceServiceHandler = {
getGdeltTopicTimeline,
listCrossSourceSignals,
listMarketImplications,
getSocialVelocity,
};

View File

@@ -0,0 +1,18 @@
import type {
SupplyChainServiceHandler,
ServerContext,
GetShippingStressRequest,
GetShippingStressResponse,
} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const REDIS_KEY = 'supply_chain:shipping_stress:v1';
export const getShippingStress: SupplyChainServiceHandler['getShippingStress'] = async (
_ctx: ServerContext,
_req: GetShippingStressRequest,
): Promise<GetShippingStressResponse> => {
const data = (await getCachedJson(REDIS_KEY, true)) as GetShippingStressResponse | null;
return data ?? { carriers: [], stressScore: 0, stressLevel: 'low', fetchedAt: 0 };
};

View File

@@ -3,9 +3,11 @@ import type { SupplyChainServiceHandler } from '../../../../src/generated/server
import { getShippingRates } from './get-shipping-rates';
import { getChokepointStatus } from './get-chokepoint-status';
import { getCriticalMinerals } from './get-critical-minerals';
import { getShippingStress } from './get-shipping-stress';
export const supplyChainHandler: SupplyChainServiceHandler = {
getShippingRates,
getChokepointStatus,
getCriticalMinerals,
getShippingStress,
};

View File

@@ -3154,6 +3154,12 @@ export const NUCLEAR_FACILITIES: NuclearFacility[] = [
{ id: 'dimona', name: 'Dimona', lat: 31.0, lon: 35.15, type: 'weapons', status: 'active' },
{ id: 'pakistan_kahuta', name: 'Kahuta', lat: 33.59, lon: 73.40, type: 'enrichment', status: 'active' },
{ id: 'pakistan_khushab', name: 'Khushab', lat: 32.02, lon: 72.22, type: 'weapons', status: 'active' },
// Historical nuclear test sites (enriches earthquake proximity alerts)
{ id: 'punggye_ri', name: 'Punggye-ri', lat: 41.28, lon: 129.08, type: 'test-site', status: 'active' },
{ id: 'lop_nur', name: 'Lop Nur', lat: 41.75, lon: 88.35, type: 'test-site', status: 'inactive', operator: 'CN' },
{ id: 'novaya_zemlya', name: 'Novaya Zemlya', lat: 73.37, lon: 54.78, type: 'test-site', status: 'inactive', operator: 'RU' },
{ id: 'nevada_nts', name: 'Nevada NTS', lat: 37.07, lon: -116.05, type: 'test-site', status: 'inactive', operator: 'US' },
{ id: 'semipalatinsk', name: 'Semipalatinsk', lat: 50.07, lon: 78.43, type: 'test-site', status: 'inactive', operator: 'KZ' },
];
// Used by Map.ts (2D SVG) which looks up countries via TopoJSON numeric IDs.

View File

@@ -839,6 +839,10 @@ const seedAllDynamicData = (): void => {
location: { latitude: 34.1, longitude: -118.2 },
occurredAt: new Date('2026-02-01T10:00:00.000Z').getTime(),
sourceUrl: 'https://example.com/eq',
nearTestSite: false,
testSiteName: '',
concernScore: 0,
concernLevel: '',
},
];

View File

@@ -0,0 +1,111 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/health/v1/service.proto
export interface ListDiseaseOutbreaksRequest {
}
export interface ListDiseaseOutbreaksResponse {
outbreaks: DiseaseOutbreakItem[];
fetchedAt: number;
}
export interface DiseaseOutbreakItem {
id: string;
disease: string;
location: string;
countryCode: string;
alertLevel: string;
summary: string;
sourceUrl: string;
publishedAt: number;
sourceName: string;
}
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface HealthServiceClientOptions {
fetch?: typeof fetch;
defaultHeaders?: Record<string, string>;
}
export interface HealthServiceCallOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
export class HealthServiceClient {
private baseURL: string;
private fetchFn: typeof fetch;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string, options?: HealthServiceClientOptions) {
this.baseURL = baseURL.replace(/\/+$/, "");
this.fetchFn = options?.fetch ?? globalThis.fetch;
this.defaultHeaders = { ...options?.defaultHeaders };
}
async listDiseaseOutbreaks(req: ListDiseaseOutbreaksRequest, options?: HealthServiceCallOptions): Promise<ListDiseaseOutbreaksResponse> {
let path = "/api/health/v1/list-disease-outbreaks";
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 ListDiseaseOutbreaksResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {
try {
const parsed = JSON.parse(body);
if (parsed.violations) {
throw new ValidationError(parsed.violations);
}
} catch (e) {
if (e instanceof ValidationError) throw e;
}
}
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -438,6 +438,26 @@ export interface MarketImplicationCard {
driver: string;
}
export interface GetSocialVelocityRequest {
}
export interface GetSocialVelocityResponse {
posts: SocialVelocityPost[];
fetchedAt: number;
}
export interface SocialVelocityPost {
id: string;
title: string;
subreddit: string;
url: string;
score: number;
upvoteRatio: number;
numComments: number;
velocityScore: number;
createdAt: number;
}
export type SeverityLevel = "SEVERITY_LEVEL_UNSPECIFIED" | "SEVERITY_LEVEL_LOW" | "SEVERITY_LEVEL_MEDIUM" | "SEVERITY_LEVEL_HIGH";
export type TrendDirection = "TREND_DIRECTION_UNSPECIFIED" | "TREND_DIRECTION_RISING" | "TREND_DIRECTION_STABLE" | "TREND_DIRECTION_FALLING";
@@ -929,6 +949,29 @@ export class IntelligenceServiceClient {
return await resp.json() as ListMarketImplicationsResponse;
}
async getSocialVelocity(req: GetSocialVelocityRequest, options?: IntelligenceServiceCallOptions): Promise<GetSocialVelocityResponse> {
let path = "/api/intelligence/v1/get-social-velocity";
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 GetSocialVelocityResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -23,6 +23,10 @@ export interface Earthquake {
location?: GeoCoordinates;
occurredAt: number;
sourceUrl: string;
nearTestSite?: boolean;
testSiteName?: string;
concernScore?: number;
concernLevel?: string;
}
export interface GeoCoordinates {

View File

@@ -106,6 +106,25 @@ export interface MineralProducer {
sharePct: number;
}
export interface GetShippingStressRequest {
}
export interface GetShippingStressResponse {
carriers: ShippingStressCarrier[];
stressScore: number;
stressLevel: string;
fetchedAt: number;
}
export interface ShippingStressCarrier {
symbol: string;
name: string;
price: number;
changePct: number;
carrierType: string;
sparkline: number[];
}
export interface FieldViolation {
field: string;
description: string;
@@ -223,6 +242,29 @@ export class SupplyChainServiceClient {
return await resp.json() as GetCriticalMineralsResponse;
}
async getShippingStress(req: GetShippingStressRequest, options?: SupplyChainServiceCallOptions): Promise<GetShippingStressResponse> {
let path = "/api/supply-chain/v1/get-shipping-stress";
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 GetShippingStressResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -0,0 +1,117 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/health/v1/service.proto
export interface ListDiseaseOutbreaksRequest {
}
export interface ListDiseaseOutbreaksResponse {
outbreaks: DiseaseOutbreakItem[];
fetchedAt: number;
}
export interface DiseaseOutbreakItem {
id: string;
disease: string;
location: string;
countryCode: string;
alertLevel: string;
summary: string;
sourceUrl: string;
publishedAt: number;
sourceName: string;
}
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface ServerContext {
request: Request;
pathParams: Record<string, string>;
headers: Record<string, string>;
}
export interface ServerOptions {
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined;
}
export interface RouteDescriptor {
method: string;
path: string;
handler: (req: Request) => Promise<Response>;
}
export interface HealthServiceHandler {
listDiseaseOutbreaks(ctx: ServerContext, req: ListDiseaseOutbreaksRequest): Promise<ListDiseaseOutbreaksResponse>;
}
export function createHealthServiceRoutes(
handler: HealthServiceHandler,
options?: ServerOptions,
): RouteDescriptor[] {
return [
{
method: "GET",
path: "/api/health/v1/list-disease-outbreaks",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = {} as ListDiseaseOutbreaksRequest;
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.listDiseaseOutbreaks(ctx, body);
return new Response(JSON.stringify(result as ListDiseaseOutbreaksResponse), {
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" },
});
}
},
},
];
}

View File

@@ -438,6 +438,26 @@ export interface MarketImplicationCard {
driver: string;
}
export interface GetSocialVelocityRequest {
}
export interface GetSocialVelocityResponse {
posts: SocialVelocityPost[];
fetchedAt: number;
}
export interface SocialVelocityPost {
id: string;
title: string;
subreddit: string;
url: string;
score: number;
upvoteRatio: number;
numComments: number;
velocityScore: number;
createdAt: number;
}
export type SeverityLevel = "SEVERITY_LEVEL_UNSPECIFIED" | "SEVERITY_LEVEL_LOW" | "SEVERITY_LEVEL_MEDIUM" | "SEVERITY_LEVEL_HIGH";
export type TrendDirection = "TREND_DIRECTION_UNSPECIFIED" | "TREND_DIRECTION_RISING" | "TREND_DIRECTION_STABLE" | "TREND_DIRECTION_FALLING";
@@ -514,6 +534,7 @@ export interface IntelligenceServiceHandler {
getGdeltTopicTimeline(ctx: ServerContext, req: GetGdeltTopicTimelineRequest): Promise<GetGdeltTopicTimelineResponse>;
listCrossSourceSignals(ctx: ServerContext, req: ListCrossSourceSignalsRequest): Promise<ListCrossSourceSignalsResponse>;
listMarketImplications(ctx: ServerContext, req: ListMarketImplicationsRequest): Promise<ListMarketImplicationsResponse>;
getSocialVelocity(ctx: ServerContext, req: GetSocialVelocityRequest): Promise<GetSocialVelocityResponse>;
}
export function createIntelligenceServiceRoutes(
@@ -1297,6 +1318,43 @@ export function createIntelligenceServiceRoutes(
}
},
},
{
method: "GET",
path: "/api/intelligence/v1/get-social-velocity",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = {} as GetSocialVelocityRequest;
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getSocialVelocity(ctx, body);
return new Response(JSON.stringify(result as GetSocialVelocityResponse), {
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" },
});
}
},
},
];
}

View File

@@ -23,6 +23,10 @@ export interface Earthquake {
location?: GeoCoordinates;
occurredAt: number;
sourceUrl: string;
nearTestSite?: boolean;
testSiteName?: string;
concernScore?: number;
concernLevel?: string;
}
export interface GeoCoordinates {

View File

@@ -106,6 +106,25 @@ export interface MineralProducer {
sharePct: number;
}
export interface GetShippingStressRequest {
}
export interface GetShippingStressResponse {
carriers: ShippingStressCarrier[];
stressScore: number;
stressLevel: string;
fetchedAt: number;
}
export interface ShippingStressCarrier {
symbol: string;
name: string;
price: number;
changePct: number;
carrierType: string;
sparkline: number[];
}
export interface FieldViolation {
field: string;
description: string;
@@ -154,6 +173,7 @@ export interface SupplyChainServiceHandler {
getShippingRates(ctx: ServerContext, req: GetShippingRatesRequest): Promise<GetShippingRatesResponse>;
getChokepointStatus(ctx: ServerContext, req: GetChokepointStatusRequest): Promise<GetChokepointStatusResponse>;
getCriticalMinerals(ctx: ServerContext, req: GetCriticalMineralsRequest): Promise<GetCriticalMineralsResponse>;
getShippingStress(ctx: ServerContext, req: GetShippingStressRequest): Promise<GetShippingStressResponse>;
}
export function createSupplyChainServiceRoutes(
@@ -272,6 +292,43 @@ export function createSupplyChainServiceRoutes(
}
},
},
{
method: "GET",
path: "/api/supply-chain/v1/get-shipping-stress",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = {} as GetShippingStressRequest;
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getShippingStress(ctx, body);
return new Response(JSON.stringify(result as GetShippingStressResponse), {
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" },
});
}
},
},
];
}

View File

@@ -0,0 +1,24 @@
import { getRpcBaseUrl } from '@/services/rpc-client';
import {
HealthServiceClient,
type ListDiseaseOutbreaksResponse,
type DiseaseOutbreakItem,
} from '@/generated/client/worldmonitor/health/v1/service_client';
import { getHydratedData } from '@/services/bootstrap';
export type { ListDiseaseOutbreaksResponse, DiseaseOutbreakItem };
const client = new HealthServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) });
const emptyOutbreaks: ListDiseaseOutbreaksResponse = { outbreaks: [], fetchedAt: 0 };
export async function fetchDiseaseOutbreaks(): Promise<ListDiseaseOutbreaksResponse> {
const hydrated = getHydratedData('diseaseOutbreaks') as ListDiseaseOutbreaksResponse | undefined;
if (hydrated?.outbreaks?.length) return hydrated;
try {
return await client.listDiseaseOutbreaks({});
} catch {
return emptyOutbreaks;
}
}

View File

@@ -0,0 +1,24 @@
import { getRpcBaseUrl } from '@/services/rpc-client';
import {
IntelligenceServiceClient,
type GetSocialVelocityResponse,
type SocialVelocityPost,
} from '@/generated/client/worldmonitor/intelligence/v1/service_client';
import { getHydratedData } from '@/services/bootstrap';
export type { GetSocialVelocityResponse, SocialVelocityPost };
const client = new IntelligenceServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) });
const emptyVelocity: GetSocialVelocityResponse = { posts: [], fetchedAt: 0 };
export async function fetchSocialVelocity(): Promise<GetSocialVelocityResponse> {
const hydrated = getHydratedData('socialVelocity') as GetSocialVelocityResponse | undefined;
if (hydrated?.posts?.length) return hydrated;
try {
return await client.getSocialVelocity({});
} catch {
return emptyVelocity;
}
}

View File

@@ -4,6 +4,7 @@ import {
type GetShippingRatesResponse,
type GetChokepointStatusResponse,
type GetCriticalMineralsResponse,
type GetShippingStressResponse,
type ShippingIndex,
type ChokepointInfo,
type CriticalMineral,
@@ -17,6 +18,7 @@ export type {
GetShippingRatesResponse,
GetChokepointStatusResponse,
GetCriticalMineralsResponse,
GetShippingStressResponse,
ShippingIndex,
ChokepointInfo,
CriticalMineral,
@@ -74,3 +76,16 @@ export async function fetchCriticalMinerals(): Promise<GetCriticalMineralsRespon
return emptyMinerals;
}
}
const emptyShippingStress: GetShippingStressResponse = { carriers: [], stressScore: 0, stressLevel: 'low', fetchedAt: 0 };
export async function fetchShippingStress(): Promise<GetShippingStressResponse> {
const hydrated = getHydratedData('shippingStress') as GetShippingStressResponse | undefined;
if (hydrated?.carriers?.length) return hydrated;
try {
return await client.getShippingStress({});
} catch {
return emptyShippingStress;
}
}