mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
6
api/bootstrap.js
vendored
6
api/bootstrap.js
vendored
@@ -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
|
||||
|
||||
@@ -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
9
api/health/v1/[rpc].ts
Normal 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),
|
||||
);
|
||||
1
docs/api/HealthService.openapi.json
Normal file
1
docs/api/HealthService.openapi.json
Normal 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"]}}}}
|
||||
109
docs/api/HealthService.openapi.yaml
Normal file
109
docs/api/HealthService.openapi.yaml
Normal 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
@@ -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.0–1.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
@@ -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 0–100 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
@@ -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 0–100 (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.
|
||||
|
||||
34
proto/worldmonitor/health/v1/list_disease_outbreaks.proto
Normal file
34
proto/worldmonitor/health/v1/list_disease_outbreaks.proto
Normal 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];
|
||||
}
|
||||
15
proto/worldmonitor/health/v1/service.proto
Normal file
15
proto/worldmonitor/health/v1/service.proto
Normal 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};
|
||||
}
|
||||
}
|
||||
34
proto/worldmonitor/intelligence/v1/get_social_velocity.proto
Normal file
34
proto/worldmonitor/intelligence/v1/get_social_velocity.proto
Normal 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.0–1.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];
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 0–100 based on magnitude, depth, and proximity.
|
||||
optional double concern_score = 10;
|
||||
// Human-readable concern level: "low" | "moderate" | "elevated" | "critical".
|
||||
optional string concern_level = 11;
|
||||
}
|
||||
|
||||
32
proto/worldmonitor/supply_chain/v1/get_shipping_stress.proto
Normal file
32
proto/worldmonitor/supply_chain/v1/get_shipping_stress.proto
Normal 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 0–100 (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];
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
112
scripts/seed-disease-outbreaks.mjs
Normal file
112
scripts/seed-disease-outbreaks.mjs
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
7
server/worldmonitor/health/v1/handler.ts
Normal file
7
server/worldmonitor/health/v1/handler.ts
Normal 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,
|
||||
};
|
||||
18
server/worldmonitor/health/v1/list-disease-outbreaks.ts
Normal file
18
server/worldmonitor/health/v1/list-disease-outbreaks.ts
Normal 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 };
|
||||
};
|
||||
18
server/worldmonitor/intelligence/v1/get-social-velocity.ts
Normal file
18
server/worldmonitor/intelligence/v1/get-social-velocity.ts
Normal 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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
18
server/worldmonitor/supply-chain/v1/get-shipping-stress.ts
Normal file
18
server/worldmonitor/supply-chain/v1/get-shipping-stress.ts
Normal 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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
111
src/generated/client/worldmonitor/health/v1/service_client.ts
Normal file
111
src/generated/client/worldmonitor/health/v1/service_client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface Earthquake {
|
||||
location?: GeoCoordinates;
|
||||
occurredAt: number;
|
||||
sourceUrl: string;
|
||||
nearTestSite?: boolean;
|
||||
testSiteName?: string;
|
||||
concernScore?: number;
|
||||
concernLevel?: string;
|
||||
}
|
||||
|
||||
export interface GeoCoordinates {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
117
src/generated/server/worldmonitor/health/v1/service_server.ts
Normal file
117
src/generated/server/worldmonitor/health/v1/service_server.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface Earthquake {
|
||||
location?: GeoCoordinates;
|
||||
occurredAt: number;
|
||||
sourceUrl: string;
|
||||
nearTestSite?: boolean;
|
||||
testSiteName?: string;
|
||||
concernScore?: number;
|
||||
concernLevel?: string;
|
||||
}
|
||||
|
||||
export interface GeoCoordinates {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
24
src/services/disease-outbreaks.ts
Normal file
24
src/services/disease-outbreaks.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
24
src/services/social-velocity.ts
Normal file
24
src/services/social-velocity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user