feat: phase 12 — 68→81 tools, 5 geospatial datasets, 5 new sources, news ticker fix
New static datasets (config/): - cables.py: 34 undersea fiber-optic cables with landing points, capacity, owners - datacenters.py: 48 AI datacenter clusters with power capacity (MW) - spaceports.py: 27 launch facilities worldwide - minerals.py: 27 critical mineral deposits (lithium, cobalt, rare earths, etc.) - exchanges.py: 82 stock exchanges across 4 tiers with country→ticker mapping New source modules (sources/): - hacker_news.py: HN Firebase API top stories - github_trending.py: GitHub search API trending repos - arxiv_papers.py: arXiv Atom API with regex XML parsing - usa_spending.py: USAspending.gov federal agency budgets - environmental.py: NASA EONET natural events + GDACS disaster alerts Extended existing modules: - markets.py: fetch_country_stocks() — any country by ISO-3 code - military.py: fetch_aircraft_details_batch() — parallel batch lookup (max 20) - geospatial.py: 5 new fetch functions for cables, datacenters, spaceports, minerals, exchanges - circuit_breaker.py: per-source configurable thresholds - cache.py: freshness() method for per-source staleness tracking Fixed news ticker (News 0 → News 17+): - Root cause: RSS feeds fetched sequentially by category (14 categories × slow feeds = timeout) - Fix: single asyncio.gather() for all 80+ feeds with 12s per-feed hard timeout - Reduced per-request HTTP timeout from 15s to 8s for RSS feeds 13 new tools wired in server.py, 14 new tests (76 total, all passing). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
986f79fb8f
commit
6075910505
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## What This Is
|
||||
|
||||
World Intelligence MCP Server — 68 tools across 27 domains providing real-time global intelligence from free public APIs. Serves three interfaces: MCP stdio (for Claude Code/Cursor), a live Starlette dashboard with SSE, and a Click CLI with Rich output.
|
||||
World Intelligence MCP Server — 81 tools across 30 domains providing real-time global intelligence from free public APIs. Serves three interfaces: MCP stdio (for Claude Code/Cursor), a live Starlette dashboard with SSE, and a Click CLI with Rich output. Python 3.11+, built with hatchling.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -28,6 +28,8 @@ intel status # cache + circuit breaker health
|
||||
|
||||
# Dashboard (requires [dashboard] extra)
|
||||
intel-dashboard --port 8501
|
||||
# Or with .env auto-loaded:
|
||||
./run-dashboard.sh
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -46,11 +48,15 @@ dashboard/app.py (SSE) ─┘
|
||||
- `CircuitBreaker`: Per-source tracking. 3 consecutive failures trips the breaker for 5 minutes. Each RSS feed gets its own breaker (`rss:bbc_world`).
|
||||
- `Cache`: SQLite WAL-mode TTL cache. `get()` returns live data, `get_stale()` returns expired data for fallback.
|
||||
|
||||
**Source modules** (`sources/*.py`): Each module exports `async def fetch_*(fetcher: Fetcher, **kwargs) -> dict`. Pure data fetching — no MCP awareness. 25 modules covering markets, seismology, military, cyber, health, etc.
|
||||
**Source modules** (`sources/*.py`): Each module exports `async def fetch_*(fetcher: Fetcher, **kwargs) -> dict`. Pure data fetching — no MCP awareness. 30 modules covering markets, seismology, military, cyber, health, tech, environmental, etc.
|
||||
|
||||
**Analysis modules** (`analysis/*.py`): Cross-domain intelligence that consumes outputs from multiple sources. Includes signal aggregation, instability indexing, NLP (entity extraction, classification, clustering, spike detection via Welford's algorithm), and strategic synthesis.
|
||||
|
||||
**Static config** (`config/*.py`): Curated datasets — 22 intel hotspots, 70+ military bases, 40 ports, 24 pipelines, 24 nuclear facilities, 105 major cities, 28 world leaders, 36 APT groups.
|
||||
**Reports** (`reports/*.py`): Jinja2-templated HTML/Markdown reports (daily brief, country dossier, threat landscape). `generator.py` orchestrates parallel source fetches. Output dir defaults to `$STORAGE_BASE/reports/intel` or `INTEL_REPORT_DIR` env var.
|
||||
|
||||
**Static config** (`config/*.py`): Curated datasets — 22 intel hotspots, 70+ military bases, 40 ports, 24 pipelines, 24 nuclear facilities, 34 undersea cables, 48 AI datacenters, 27 spaceports, 27 mineral deposits, 82 stock exchanges, 105 major cities, 28 world leaders, 36 APT groups.
|
||||
|
||||
**Dashboard** (`dashboard/`): Self-contained Starlette app with a single `index.html` template (no frontend build step). SSE endpoint streams all domains in parallel via `asyncio.gather()`, refreshes every 30 seconds. Loads `.env` from project root on startup.
|
||||
|
||||
## Adding a New Tool
|
||||
|
||||
@@ -65,8 +71,7 @@ dashboard/app.py (SSE) ─┘
|
||||
- **Source name string**: The `source` parameter in `fetcher.get_json()` identifies the API for circuit breaking and rate limiting. Must match entries in `_SOURCE_RATE_LIMITS` if rate-limited (e.g., `"yahoo-finance"`, `"coingecko"`, `"adsblol"`).
|
||||
- **Tool dispatch**: `server.py` uses Python `match/case` to route tool names to source functions. Tool names follow `intel_*` convention.
|
||||
- **All source functions take `fetcher` as first arg** — never construct your own httpx client.
|
||||
- **Dashboard SSE**: `dashboard/app.py` fetches all domains in parallel via `asyncio.gather()`, streams updates every 30 seconds.
|
||||
- **Tests strip proxy env vars** automatically via `conftest.py` fixture (prevents SOCKS proxy interference).
|
||||
- **Tests strip proxy env vars** automatically via `conftest.py` fixture (prevents SOCKS proxy interference). The conftest also resets global fetcher rate-limit locks between tests to avoid cross-event-loop binding.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
||||
@@ -118,6 +118,27 @@ class Cache:
|
||||
"db_path": str(self.db_path),
|
||||
}
|
||||
|
||||
def freshness(self) -> dict[str, Any]:
|
||||
"""Per-source freshness report — shows age of newest entry per source prefix."""
|
||||
conn = self._get_conn()
|
||||
now = time.time()
|
||||
rows = conn.execute(
|
||||
"SELECT key, created_at, expires_at FROM cache ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
sources: dict[str, dict] = {}
|
||||
for key, created_at, expires_at in rows:
|
||||
# Extract source prefix (e.g., "markets:quotes:^GSPC" → "markets")
|
||||
prefix = key.split(":")[0] if ":" in key else key
|
||||
if prefix not in sources:
|
||||
age_s = now - created_at
|
||||
is_stale = now > expires_at
|
||||
sources[prefix] = {
|
||||
"last_updated_s_ago": round(age_s, 1),
|
||||
"is_stale": is_stale,
|
||||
"newest_key": key,
|
||||
}
|
||||
return sources
|
||||
|
||||
def close(self) -> None:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
|
||||
@@ -29,9 +29,11 @@ class CircuitBreaker:
|
||||
self,
|
||||
failure_threshold: int = 3,
|
||||
cooldown_seconds: float = 300.0,
|
||||
per_source_config: dict[str, dict] | None = None,
|
||||
):
|
||||
self.failure_threshold = failure_threshold
|
||||
self.cooldown_seconds = cooldown_seconds
|
||||
self._per_source: dict[str, dict] = per_source_config or {}
|
||||
self._states: dict[str, _State] = {}
|
||||
|
||||
def _get(self, source: str) -> _State:
|
||||
@@ -39,12 +41,20 @@ class CircuitBreaker:
|
||||
self._states[source] = _State()
|
||||
return self._states[source]
|
||||
|
||||
def _threshold_for(self, source: str) -> int:
|
||||
"""Get failure threshold for a specific source."""
|
||||
return self._per_source.get(source, {}).get("failure_threshold", self.failure_threshold)
|
||||
|
||||
def _cooldown_for(self, source: str) -> float:
|
||||
"""Get cooldown seconds for a specific source."""
|
||||
return self._per_source.get(source, {}).get("cooldown_seconds", self.cooldown_seconds)
|
||||
|
||||
def is_available(self, source: str) -> bool:
|
||||
"""Check if source is available (circuit closed or cooldown elapsed)."""
|
||||
state = self._get(source)
|
||||
if not state.is_open:
|
||||
return True
|
||||
if time.time() - state.tripped_at >= self.cooldown_seconds:
|
||||
if time.time() - state.tripped_at >= self._cooldown_for(source):
|
||||
return True # allow probe
|
||||
return False
|
||||
|
||||
@@ -61,13 +71,13 @@ class CircuitBreaker:
|
||||
state.failures += 1
|
||||
state.last_failure = time.time()
|
||||
state.total_failures += 1
|
||||
if state.failures >= self.failure_threshold and not state.is_open:
|
||||
if state.failures >= self._threshold_for(source) and not state.is_open:
|
||||
state.is_open = True
|
||||
state.tripped_at = time.time()
|
||||
state.total_trips += 1
|
||||
logger.warning(
|
||||
"Circuit breaker TRIPPED for %s (failures=%d, cooldown=%.0fs)",
|
||||
source, state.failures, self.cooldown_seconds,
|
||||
source, state.failures, self._cooldown_for(source),
|
||||
)
|
||||
|
||||
def status(self) -> dict[str, dict]:
|
||||
@@ -76,7 +86,7 @@ class CircuitBreaker:
|
||||
result = {}
|
||||
for source, state in self._states.items():
|
||||
if state.is_open:
|
||||
remaining = max(0, self.cooldown_seconds - (now - state.tripped_at))
|
||||
remaining = max(0, self._cooldown_for(source) - (now - state.tripped_at))
|
||||
status = "open" if remaining > 0 else "half-open"
|
||||
else:
|
||||
remaining = 0
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Undersea cable routes with landing points.
|
||||
|
||||
Pure data module — no I/O, no external dependencies.
|
||||
Sources: TeleGeography Submarine Cable Map, ITU cable database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
UNDERSEA_CABLES: list[dict] = [
|
||||
# Transatlantic
|
||||
{"name": "TAT-14", "status": "decommissioned", "rfs_year": 2001, "length_km": 15428, "capacity_tbps": 3.2,
|
||||
"owners": ["Deutsche Telekom", "AT&T", "Orange"], "landing_points": [
|
||||
{"name": "Tuckerton", "country": "USA", "lat": 39.60, "lon": -74.34},
|
||||
{"name": "Manasquan", "country": "USA", "lat": 40.12, "lon": -74.04},
|
||||
{"name": "Blaabjerg", "country": "Denmark", "lat": 55.60, "lon": 8.12},
|
||||
{"name": "Norden", "country": "Germany", "lat": 53.60, "lon": 7.20},
|
||||
{"name": "Saint-Valery-en-Caux", "country": "France", "lat": 49.87, "lon": 0.72},
|
||||
{"name": "Bude", "country": "UK", "lat": 50.83, "lon": -4.55},
|
||||
]},
|
||||
{"name": "MAREA", "status": "active", "rfs_year": 2018, "length_km": 6600, "capacity_tbps": 200,
|
||||
"owners": ["Microsoft", "Meta", "Telxius"], "landing_points": [
|
||||
{"name": "Virginia Beach", "country": "USA", "lat": 36.85, "lon": -75.98},
|
||||
{"name": "Bilbao", "country": "Spain", "lat": 43.26, "lon": -2.93},
|
||||
]},
|
||||
{"name": "Dunant", "status": "active", "rfs_year": 2021, "length_km": 6600, "capacity_tbps": 250,
|
||||
"owners": ["Google"], "landing_points": [
|
||||
{"name": "Virginia Beach", "country": "USA", "lat": 36.85, "lon": -75.98},
|
||||
{"name": "Saint-Hilaire-de-Riez", "country": "France", "lat": 46.72, "lon": -1.95},
|
||||
]},
|
||||
{"name": "Amitié", "status": "active", "rfs_year": 2022, "length_km": 6800, "capacity_tbps": 400,
|
||||
"owners": ["Google", "Meta", "Lumen"], "landing_points": [
|
||||
{"name": "Lynn", "country": "USA", "lat": 42.47, "lon": -70.95},
|
||||
{"name": "Bude", "country": "UK", "lat": 50.83, "lon": -4.55},
|
||||
{"name": "Le Porge", "country": "France", "lat": 44.87, "lon": -1.16},
|
||||
]},
|
||||
{"name": "AEC-1 (Anjana/East)", "status": "active", "rfs_year": 2002, "length_km": 7200, "capacity_tbps": 5.1,
|
||||
"owners": ["Telia", "Aqua Comms"], "landing_points": [
|
||||
{"name": "Shirley", "country": "USA", "lat": 40.80, "lon": -72.87},
|
||||
{"name": "Killala", "country": "Ireland", "lat": 54.21, "lon": -9.22},
|
||||
{"name": "Blaabjerg", "country": "Denmark", "lat": 55.60, "lon": 8.12},
|
||||
]},
|
||||
{"name": "HAVFRUE/AEC-2", "status": "active", "rfs_year": 2020, "length_km": 7860, "capacity_tbps": 108,
|
||||
"owners": ["Google", "Aqua Comms", "Bulk"], "landing_points": [
|
||||
{"name": "Wall Township", "country": "USA", "lat": 40.16, "lon": -74.07},
|
||||
{"name": "Blaabjerg", "country": "Denmark", "lat": 55.60, "lon": 8.12},
|
||||
{"name": "Kristiansand", "country": "Norway", "lat": 58.15, "lon": 8.00},
|
||||
{"name": "Killala", "country": "Ireland", "lat": 54.21, "lon": -9.22},
|
||||
]},
|
||||
{"name": "Grace Hopper", "status": "active", "rfs_year": 2022, "length_km": 6300, "capacity_tbps": 340,
|
||||
"owners": ["Google"], "landing_points": [
|
||||
{"name": "New York", "country": "USA", "lat": 40.57, "lon": -73.97},
|
||||
{"name": "Bude", "country": "UK", "lat": 50.83, "lon": -4.55},
|
||||
{"name": "Bilbao", "country": "Spain", "lat": 43.26, "lon": -2.93},
|
||||
]},
|
||||
# Transpacific
|
||||
{"name": "JUPITER", "status": "active", "rfs_year": 2020, "length_km": 14000, "capacity_tbps": 60,
|
||||
"owners": ["Google", "Meta", "PLDT", "SoftBank"], "landing_points": [
|
||||
{"name": "Virginia Beach", "country": "USA", "lat": 36.85, "lon": -75.98},
|
||||
{"name": "Daet", "country": "Philippines", "lat": 14.11, "lon": 122.96},
|
||||
{"name": "Maruyama", "country": "Japan", "lat": 33.48, "lon": 135.76},
|
||||
]},
|
||||
{"name": "FASTER", "status": "active", "rfs_year": 2016, "length_km": 11629, "capacity_tbps": 60,
|
||||
"owners": ["Google", "China Mobile", "KDDI", "SingTel"], "landing_points": [
|
||||
{"name": "Bandon", "country": "USA", "lat": 43.12, "lon": -124.41},
|
||||
{"name": "Chikura", "country": "Japan", "lat": 34.93, "lon": 139.95},
|
||||
{"name": "Shima", "country": "Japan", "lat": 34.33, "lon": 136.83},
|
||||
]},
|
||||
{"name": "Curie", "status": "active", "rfs_year": 2019, "length_km": 10476, "capacity_tbps": 72,
|
||||
"owners": ["Google"], "landing_points": [
|
||||
{"name": "Los Angeles", "country": "USA", "lat": 33.94, "lon": -118.45},
|
||||
{"name": "Valparaiso", "country": "Chile", "lat": -33.04, "lon": -71.63},
|
||||
]},
|
||||
{"name": "Firmina", "status": "active", "rfs_year": 2023, "length_km": 14000, "capacity_tbps": 340,
|
||||
"owners": ["Google"], "landing_points": [
|
||||
{"name": "Myrtle Beach", "country": "USA", "lat": 33.69, "lon": -78.89},
|
||||
{"name": "Praia Grande", "country": "Brazil", "lat": -24.01, "lon": -46.41},
|
||||
{"name": "Las Toninas", "country": "Argentina", "lat": -36.48, "lon": -56.70},
|
||||
]},
|
||||
{"name": "PLCN (Pacific Light Cable Network)", "status": "active", "rfs_year": 2023, "length_km": 12800, "capacity_tbps": 144,
|
||||
"owners": ["Google", "Meta"], "landing_points": [
|
||||
{"name": "El Segundo", "country": "USA", "lat": 33.92, "lon": -118.42},
|
||||
{"name": "Changi", "country": "Singapore", "lat": 1.35, "lon": 104.00},
|
||||
{"name": "Tanah Merah", "country": "Indonesia", "lat": -6.17, "lon": 106.97},
|
||||
]},
|
||||
# Asia-Europe (SEA-ME-WE family)
|
||||
{"name": "SEA-ME-WE 3", "status": "active", "rfs_year": 1999, "length_km": 39000, "capacity_tbps": 0.96,
|
||||
"owners": ["Consortium (76 telcos)"], "landing_points": [
|
||||
{"name": "Norden", "country": "Germany", "lat": 53.60, "lon": 7.20},
|
||||
{"name": "Penmarc'h", "country": "France", "lat": 47.80, "lon": -4.37},
|
||||
{"name": "Tetney", "country": "UK", "lat": 53.50, "lon": 0.02},
|
||||
{"name": "Suez", "country": "Egypt", "lat": 29.97, "lon": 32.55},
|
||||
{"name": "Mumbai", "country": "India", "lat": 18.93, "lon": 72.83},
|
||||
{"name": "Singapore", "country": "Singapore", "lat": 1.26, "lon": 103.82},
|
||||
{"name": "Shanghai", "country": "China", "lat": 31.05, "lon": 121.72},
|
||||
{"name": "Keoje", "country": "South Korea", "lat": 34.88, "lon": 128.70},
|
||||
{"name": "Okinawa", "country": "Japan", "lat": 26.34, "lon": 127.77},
|
||||
]},
|
||||
{"name": "SEA-ME-WE 5", "status": "active", "rfs_year": 2017, "length_km": 20000, "capacity_tbps": 24,
|
||||
"owners": ["Consortium (17 telcos)"], "landing_points": [
|
||||
{"name": "Marseille", "country": "France", "lat": 43.30, "lon": 5.37},
|
||||
{"name": "Catania", "country": "Italy", "lat": 37.50, "lon": 15.09},
|
||||
{"name": "Alexandria", "country": "Egypt", "lat": 31.20, "lon": 29.92},
|
||||
{"name": "Jeddah", "country": "Saudi Arabia", "lat": 21.49, "lon": 39.19},
|
||||
{"name": "Mumbai", "country": "India", "lat": 18.93, "lon": 72.83},
|
||||
{"name": "Singapore", "country": "Singapore", "lat": 1.26, "lon": 103.82},
|
||||
]},
|
||||
{"name": "SEA-ME-WE 6", "status": "construction", "rfs_year": 2025, "length_km": 19200, "capacity_tbps": 100,
|
||||
"owners": ["Consortium (12 telcos)"], "landing_points": [
|
||||
{"name": "Marseille", "country": "France", "lat": 43.30, "lon": 5.37},
|
||||
{"name": "Genoa", "country": "Italy", "lat": 44.41, "lon": 8.93},
|
||||
{"name": "Alexandria", "country": "Egypt", "lat": 31.20, "lon": 29.92},
|
||||
{"name": "Singapore", "country": "Singapore", "lat": 1.26, "lon": 103.82},
|
||||
]},
|
||||
# Africa
|
||||
{"name": "2Africa", "status": "active", "rfs_year": 2024, "length_km": 45000, "capacity_tbps": 180,
|
||||
"owners": ["Meta", "MTN", "Orange", "Vodafone", "China Mobile"], "landing_points": [
|
||||
{"name": "Genoa", "country": "Italy", "lat": 44.41, "lon": 8.93},
|
||||
{"name": "Barcelona", "country": "Spain", "lat": 41.39, "lon": 2.16},
|
||||
{"name": "Marseille", "country": "France", "lat": 43.30, "lon": 5.37},
|
||||
{"name": "Bude", "country": "UK", "lat": 50.83, "lon": -4.55},
|
||||
{"name": "Dakar", "country": "Senegal", "lat": 14.69, "lon": -17.47},
|
||||
{"name": "Lagos", "country": "Nigeria", "lat": 6.45, "lon": 3.39},
|
||||
{"name": "Cape Town", "country": "South Africa", "lat": -33.92, "lon": 18.42},
|
||||
{"name": "Maputo", "country": "Mozambique", "lat": -25.97, "lon": 32.57},
|
||||
{"name": "Mombasa", "country": "Kenya", "lat": -4.04, "lon": 39.67},
|
||||
{"name": "Djibouti", "country": "Djibouti", "lat": 11.55, "lon": 43.15},
|
||||
{"name": "Jeddah", "country": "Saudi Arabia", "lat": 21.49, "lon": 39.19},
|
||||
{"name": "Mumbai", "country": "India", "lat": 18.93, "lon": 72.83},
|
||||
]},
|
||||
{"name": "Equiano", "status": "active", "rfs_year": 2023, "length_km": 15000, "capacity_tbps": 144,
|
||||
"owners": ["Google"], "landing_points": [
|
||||
{"name": "Sesimbra", "country": "Portugal", "lat": 38.44, "lon": -9.10},
|
||||
{"name": "Lagos", "country": "Nigeria", "lat": 6.45, "lon": 3.39},
|
||||
{"name": "Lomé", "country": "Togo", "lat": 6.13, "lon": 1.22},
|
||||
{"name": "Swakopmund", "country": "Namibia", "lat": -22.68, "lon": 14.53},
|
||||
{"name": "Melkbosstrand", "country": "South Africa", "lat": -33.72, "lon": 18.44},
|
||||
]},
|
||||
# PEACE Cable
|
||||
{"name": "PEACE", "status": "active", "rfs_year": 2022, "length_km": 15000, "capacity_tbps": 96,
|
||||
"owners": ["PEACE Cable International"], "landing_points": [
|
||||
{"name": "Marseille", "country": "France", "lat": 43.30, "lon": 5.37},
|
||||
{"name": "Karachi", "country": "Pakistan", "lat": 24.86, "lon": 67.01},
|
||||
{"name": "Mombasa", "country": "Kenya", "lat": -4.04, "lon": 39.67},
|
||||
{"name": "Seychelles", "country": "Seychelles", "lat": -4.68, "lon": 55.49},
|
||||
{"name": "Singapore", "country": "Singapore", "lat": 1.26, "lon": 103.82},
|
||||
]},
|
||||
# Asia-Pacific
|
||||
{"name": "APG (Asia-Pacific Gateway)", "status": "active", "rfs_year": 2016, "length_km": 10400, "capacity_tbps": 54.8,
|
||||
"owners": ["Consortium (12 telcos)"], "landing_points": [
|
||||
{"name": "Chongming", "country": "China", "lat": 31.62, "lon": 121.73},
|
||||
{"name": "Hong Kong", "country": "China", "lat": 22.25, "lon": 114.17},
|
||||
{"name": "Taipei", "country": "Taiwan", "lat": 25.16, "lon": 121.74},
|
||||
{"name": "Maruyama", "country": "Japan", "lat": 33.48, "lon": 135.76},
|
||||
{"name": "Changi", "country": "Singapore", "lat": 1.35, "lon": 104.00},
|
||||
{"name": "Da Nang", "country": "Vietnam", "lat": 16.07, "lon": 108.22},
|
||||
{"name": "Kuantan", "country": "Malaysia", "lat": 3.80, "lon": 103.33},
|
||||
]},
|
||||
{"name": "SJC (Southeast Asia-Japan Cable)", "status": "active", "rfs_year": 2013, "length_km": 8900, "capacity_tbps": 28,
|
||||
"owners": ["Google", "Meta", "KDDI", "SingTel", "PLDT"], "landing_points": [
|
||||
{"name": "Maruyama", "country": "Japan", "lat": 33.48, "lon": 135.76},
|
||||
{"name": "Shantou", "country": "China", "lat": 23.35, "lon": 116.68},
|
||||
{"name": "Hong Kong", "country": "China", "lat": 22.25, "lon": 114.17},
|
||||
{"name": "Changi", "country": "Singapore", "lat": 1.35, "lon": 104.00},
|
||||
]},
|
||||
{"name": "SJC2", "status": "active", "rfs_year": 2022, "length_km": 10500, "capacity_tbps": 144,
|
||||
"owners": ["Meta", "Google", "SingTel", "PLDT", "Telin"], "landing_points": [
|
||||
{"name": "Shima", "country": "Japan", "lat": 34.33, "lon": 136.83},
|
||||
{"name": "Chikura", "country": "Japan", "lat": 34.93, "lon": 139.95},
|
||||
{"name": "Taiwan", "country": "Taiwan", "lat": 25.16, "lon": 121.74},
|
||||
{"name": "Changi", "country": "Singapore", "lat": 1.35, "lon": 104.00},
|
||||
{"name": "Manado", "country": "Indonesia", "lat": 1.49, "lon": 124.84},
|
||||
]},
|
||||
# Middle East
|
||||
{"name": "FLAG Europe-Asia (FEA)", "status": "active", "rfs_year": 1997, "length_km": 28000, "capacity_tbps": 10,
|
||||
"owners": ["Reliance Globalcom"], "landing_points": [
|
||||
{"name": "Porthcurno", "country": "UK", "lat": 50.04, "lon": -5.66},
|
||||
{"name": "Estepona", "country": "Spain", "lat": 36.43, "lon": -5.15},
|
||||
{"name": "Palermo", "country": "Italy", "lat": 38.12, "lon": 13.36},
|
||||
{"name": "Port Said", "country": "Egypt", "lat": 31.26, "lon": 32.30},
|
||||
{"name": "Mumbai", "country": "India", "lat": 18.93, "lon": 72.83},
|
||||
{"name": "Busan", "country": "South Korea", "lat": 35.10, "lon": 129.04},
|
||||
{"name": "Miura", "country": "Japan", "lat": 35.14, "lon": 139.62},
|
||||
]},
|
||||
{"name": "AAE-1", "status": "active", "rfs_year": 2017, "length_km": 25000, "capacity_tbps": 40,
|
||||
"owners": ["Consortium (19 telcos)"], "landing_points": [
|
||||
{"name": "Marseille", "country": "France", "lat": 43.30, "lon": 5.37},
|
||||
{"name": "Bari", "country": "Italy", "lat": 41.13, "lon": 16.87},
|
||||
{"name": "Alexandria", "country": "Egypt", "lat": 31.20, "lon": 29.92},
|
||||
{"name": "Djibouti", "country": "Djibouti", "lat": 11.55, "lon": 43.15},
|
||||
{"name": "Mumbai", "country": "India", "lat": 18.93, "lon": 72.83},
|
||||
{"name": "Singapore", "country": "Singapore", "lat": 1.26, "lon": 103.82},
|
||||
{"name": "Hong Kong", "country": "China", "lat": 22.25, "lon": 114.17},
|
||||
]},
|
||||
{"name": "FALCON", "status": "active", "rfs_year": 2006, "length_km": 11500, "capacity_tbps": 4.7,
|
||||
"owners": ["FLAG Telecom (Reliance)"], "landing_points": [
|
||||
{"name": "Mumbai", "country": "India", "lat": 18.93, "lon": 72.83},
|
||||
{"name": "Fujairah", "country": "UAE", "lat": 25.12, "lon": 56.35},
|
||||
{"name": "Muscat", "country": "Oman", "lat": 23.59, "lon": 58.54},
|
||||
{"name": "Jeddah", "country": "Saudi Arabia", "lat": 21.49, "lon": 39.19},
|
||||
{"name": "Suez", "country": "Egypt", "lat": 29.97, "lon": 32.55},
|
||||
]},
|
||||
# Arctic / North
|
||||
{"name": "Far North Fiber", "status": "construction", "rfs_year": 2027, "length_km": 16500, "capacity_tbps": 200,
|
||||
"owners": ["Far North Digital", "Cinia"], "landing_points": [
|
||||
{"name": "Tokyo", "country": "Japan", "lat": 35.65, "lon": 139.84},
|
||||
{"name": "Kirkenes", "country": "Norway", "lat": 69.73, "lon": 30.05},
|
||||
{"name": "Dublin", "country": "Ireland", "lat": 53.35, "lon": -6.26},
|
||||
{"name": "Nome", "country": "USA", "lat": 64.50, "lon": -165.41},
|
||||
]},
|
||||
# Australia
|
||||
{"name": "JGA-S (Japan-Guam-Australia South)", "status": "active", "rfs_year": 2020, "length_km": 7100, "capacity_tbps": 36,
|
||||
"owners": ["Google", "AARNet", "Indosat"], "landing_points": [
|
||||
{"name": "Shima", "country": "Japan", "lat": 34.33, "lon": 136.83},
|
||||
{"name": "Piti", "country": "Guam", "lat": 13.46, "lon": 144.70},
|
||||
{"name": "Sydney", "country": "Australia", "lat": -33.87, "lon": 151.21},
|
||||
]},
|
||||
{"name": "Indigo", "status": "active", "rfs_year": 2019, "length_km": 9600, "capacity_tbps": 36,
|
||||
"owners": ["Google", "AARNet", "Indosat", "SingTel", "SubPartners"], "landing_points": [
|
||||
{"name": "Perth", "country": "Australia", "lat": -31.95, "lon": 115.86},
|
||||
{"name": "Singapore", "country": "Singapore", "lat": 1.26, "lon": 103.82},
|
||||
{"name": "Jakarta", "country": "Indonesia", "lat": -6.13, "lon": 106.85},
|
||||
]},
|
||||
# South America
|
||||
{"name": "SACS (South Atlantic Cable System)", "status": "active", "rfs_year": 2018, "length_km": 6200, "capacity_tbps": 40,
|
||||
"owners": ["Angola Cables"], "landing_points": [
|
||||
{"name": "Luanda", "country": "Angola", "lat": -8.84, "lon": 13.23},
|
||||
{"name": "Fortaleza", "country": "Brazil", "lat": -3.72, "lon": -38.52},
|
||||
]},
|
||||
{"name": "EllaLink", "status": "active", "rfs_year": 2021, "length_km": 12000, "capacity_tbps": 72,
|
||||
"owners": ["EllaLink"], "landing_points": [
|
||||
{"name": "Sines", "country": "Portugal", "lat": 37.96, "lon": -8.87},
|
||||
{"name": "Marseille", "country": "France", "lat": 43.30, "lon": 5.37},
|
||||
{"name": "Fortaleza", "country": "Brazil", "lat": -3.72, "lon": -38.52},
|
||||
]},
|
||||
# India-specific
|
||||
{"name": "India-Asia-Xpress (IAX)", "status": "active", "rfs_year": 2024, "length_km": 7800, "capacity_tbps": 120,
|
||||
"owners": ["Reliance Jio", "Meta"], "landing_points": [
|
||||
{"name": "Mumbai", "country": "India", "lat": 18.93, "lon": 72.83},
|
||||
{"name": "Singapore", "country": "Singapore", "lat": 1.26, "lon": 103.82},
|
||||
{"name": "Kuantan", "country": "Malaysia", "lat": 3.80, "lon": 103.33},
|
||||
{"name": "Satun", "country": "Thailand", "lat": 6.62, "lon": 100.07},
|
||||
]},
|
||||
# North Pacific
|
||||
{"name": "NCP (New Cross Pacific)", "status": "active", "rfs_year": 2018, "length_km": 13600, "capacity_tbps": 80,
|
||||
"owners": ["Microsoft", "Meta", "Amazon", "SoftBank", "PLDT"], "landing_points": [
|
||||
{"name": "Hillsboro", "country": "USA", "lat": 45.53, "lon": -122.99},
|
||||
{"name": "Maruyama", "country": "Japan", "lat": 33.48, "lon": 135.76},
|
||||
{"name": "Chongming", "country": "China", "lat": 31.62, "lon": 121.73},
|
||||
{"name": "Taipei", "country": "Taiwan", "lat": 25.16, "lon": 121.74},
|
||||
]},
|
||||
{"name": "Unity", "status": "active", "rfs_year": 2010, "length_km": 10000, "capacity_tbps": 7.68,
|
||||
"owners": ["Google", "KDDI"], "landing_points": [
|
||||
{"name": "Chikura", "country": "Japan", "lat": 34.93, "lon": 139.95},
|
||||
{"name": "Los Angeles", "country": "USA", "lat": 33.94, "lon": -118.45},
|
||||
]},
|
||||
# Mediterranean
|
||||
{"name": "Blue & Raman", "status": "active", "rfs_year": 2024, "length_km": 16000, "capacity_tbps": 250,
|
||||
"owners": ["Google"], "landing_points": [
|
||||
{"name": "Genoa", "country": "Italy", "lat": 44.41, "lon": 8.93},
|
||||
{"name": "Haifa", "country": "Israel", "lat": 32.82, "lon": 34.98},
|
||||
{"name": "Mumbai", "country": "India", "lat": 18.93, "lon": 72.83},
|
||||
{"name": "Amman", "country": "Jordan", "lat": 31.95, "lon": 35.93},
|
||||
]},
|
||||
{"name": "Oman Australia Cable (OAC)", "status": "active", "rfs_year": 2022, "length_km": 9800, "capacity_tbps": 100,
|
||||
"owners": ["Oman Broadband", "SubPartners"], "landing_points": [
|
||||
{"name": "Barka", "country": "Oman", "lat": 23.68, "lon": 57.88},
|
||||
{"name": "Perth", "country": "Australia", "lat": -31.95, "lon": 115.86},
|
||||
]},
|
||||
]
|
||||
|
||||
|
||||
def query_cables(
|
||||
status: str | None = None,
|
||||
country: str | None = None,
|
||||
owner: str | None = None,
|
||||
min_capacity_tbps: float | None = None,
|
||||
) -> list[dict]:
|
||||
"""Filter undersea cables by status, landing country, owner, or min capacity."""
|
||||
results = []
|
||||
for cable in UNDERSEA_CABLES:
|
||||
if status and cable["status"] != status.lower():
|
||||
continue
|
||||
if min_capacity_tbps and cable["capacity_tbps"] < min_capacity_tbps:
|
||||
continue
|
||||
if owner:
|
||||
owner_lower = owner.lower()
|
||||
if not any(owner_lower in o.lower() for o in cable["owners"]):
|
||||
continue
|
||||
if country:
|
||||
country_lower = country.lower()
|
||||
if not any(country_lower in lp["country"].lower() for lp in cable["landing_points"]):
|
||||
continue
|
||||
results.append(cable)
|
||||
return results
|
||||
@@ -0,0 +1,91 @@
|
||||
"""AI datacenter clusters worldwide.
|
||||
|
||||
Pure data module — no I/O, no external dependencies.
|
||||
Sources: Data Center Map, Cloudscene, company announcements, press reports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
AI_DATACENTERS: list[dict] = [
|
||||
# United States — Top clusters
|
||||
{"name": "Ashburn / Loudoun County", "country": "USA", "iso3": "USA", "lat": 39.04, "lon": -77.49, "region": "Virginia", "power_mw": 4500, "operators": ["AWS", "Microsoft", "Google", "Meta", "Equinix", "Digital Realty"], "notes": "Data Center Alley — largest concentration globally, ~70% of world internet traffic"},
|
||||
{"name": "Dallas / Fort Worth", "country": "USA", "iso3": "USA", "lat": 32.90, "lon": -97.04, "region": "Texas", "power_mw": 2200, "operators": ["AWS", "Meta", "Google", "CyrusOne", "QTS"], "notes": "Second largest US cluster, low power costs"},
|
||||
{"name": "Phoenix / Mesa", "country": "USA", "iso3": "USA", "lat": 33.44, "lon": -111.94, "region": "Arizona", "power_mw": 1800, "operators": ["Microsoft", "Google", "Meta", "Apple"], "notes": "Rapid expansion, solar power access"},
|
||||
{"name": "Council Bluffs", "country": "USA", "iso3": "USA", "lat": 41.26, "lon": -95.86, "region": "Iowa", "power_mw": 1200, "operators": ["Google", "Meta"], "notes": "Google mega-campus, cheap wind power"},
|
||||
{"name": "The Dalles", "country": "USA", "iso3": "USA", "lat": 45.60, "lon": -121.18, "region": "Oregon", "power_mw": 900, "operators": ["Google"], "notes": "Google flagship, hydroelectric cooling"},
|
||||
{"name": "Quincy", "country": "USA", "iso3": "USA", "lat": 47.23, "lon": -119.85, "region": "Washington", "power_mw": 800, "operators": ["Microsoft", "Yahoo", "Dell"], "notes": "Columbia River hydro power"},
|
||||
{"name": "Prineville", "country": "USA", "iso3": "USA", "lat": 44.30, "lon": -120.73, "region": "Oregon", "power_mw": 600, "operators": ["Meta", "Apple"], "notes": "Meta's first custom DC"},
|
||||
{"name": "San Jose / Santa Clara", "country": "USA", "iso3": "USA", "lat": 37.35, "lon": -121.95, "region": "California", "power_mw": 1500, "operators": ["Equinix", "CoreSite", "NVIDIA", "Google"], "notes": "Silicon Valley interconnection hub"},
|
||||
{"name": "Chicago / Aurora", "country": "USA", "iso3": "USA", "lat": 41.76, "lon": -88.32, "region": "Illinois", "power_mw": 1100, "operators": ["AWS", "Microsoft", "Google", "Digital Realty"], "notes": "Midwest hub, CME/CBOE proximity"},
|
||||
{"name": "Atlanta / Douglas County", "country": "USA", "iso3": "USA", "lat": 33.70, "lon": -84.75, "region": "Georgia", "power_mw": 900, "operators": ["Google", "Microsoft", "QTS", "Switch"], "notes": "Southeast US hub"},
|
||||
{"name": "Salt Lake City / West Jordan", "country": "USA", "iso3": "USA", "lat": 40.61, "lon": -111.94, "region": "Utah", "power_mw": 600, "operators": ["Meta", "AWS", "C7"], "notes": "NSA Utah Data Center nearby"},
|
||||
{"name": "Reno / Sparks", "country": "USA", "iso3": "USA", "lat": 39.53, "lon": -119.81, "region": "Nevada", "power_mw": 500, "operators": ["Apple", "Switch", "Microsoft"], "notes": "Switch SuperNAP campus"},
|
||||
{"name": "New Albany", "country": "USA", "iso3": "USA", "lat": 40.08, "lon": -82.81, "region": "Ohio", "power_mw": 1400, "operators": ["Google", "AWS", "Meta", "Microsoft"], "notes": "Ohio Intel fab synergy, massive expansion"},
|
||||
{"name": "Papillion / Omaha", "country": "USA", "iso3": "USA", "lat": 41.15, "lon": -96.04, "region": "Nebraska", "power_mw": 500, "operators": ["Meta", "Google"], "notes": "Central US, wind/solar access"},
|
||||
# Europe
|
||||
{"name": "Amsterdam / Schiphol", "country": "Netherlands", "iso3": "NLD", "lat": 52.30, "lon": 4.76, "region": "North Holland", "power_mw": 800, "operators": ["Equinix", "Digital Realty", "Microsoft"], "notes": "AMS-IX — world's largest IXP, moratorium on new DCs"},
|
||||
{"name": "Dublin", "country": "Ireland", "iso3": "IRL", "lat": 53.35, "lon": -6.26, "region": "Leinster", "power_mw": 900, "operators": ["AWS", "Microsoft", "Google", "Meta"], "notes": "EU data sovereignty hub, tax advantages"},
|
||||
{"name": "Frankfurt", "country": "Germany", "iso3": "DEU", "lat": 50.11, "lon": 8.68, "region": "Hesse", "power_mw": 700, "operators": ["Equinix", "Digital Realty", "AWS", "Google"], "notes": "DE-CIX — largest IXP by members"},
|
||||
{"name": "London / Slough", "country": "UK", "iso3": "GBR", "lat": 51.51, "lon": -0.59, "region": "Berkshire", "power_mw": 900, "operators": ["Equinix", "Digital Realty", "AWS", "Google"], "notes": "LINX, financial sector hub"},
|
||||
{"name": "Paris / Île-de-France", "country": "France", "iso3": "FRA", "lat": 48.95, "lon": 2.40, "region": "Île-de-France", "power_mw": 500, "operators": ["Equinix", "Digital Realty", "OVHcloud"], "notes": "France-IX hub"},
|
||||
{"name": "Stockholm / Rosersberg", "country": "Sweden", "iso3": "SWE", "lat": 59.60, "lon": 17.88, "region": "Stockholm", "power_mw": 400, "operators": ["AWS", "Microsoft", "Ericsson"], "notes": "Nordic hub, cold climate cooling"},
|
||||
{"name": "Milan", "country": "Italy", "iso3": "ITA", "lat": 45.46, "lon": 9.19, "region": "Lombardy", "power_mw": 300, "operators": ["Equinix", "AWS", "Google"], "notes": "Southern Europe hub"},
|
||||
{"name": "Madrid", "country": "Spain", "iso3": "ESP", "lat": 40.42, "lon": -3.70, "region": "Community of Madrid", "power_mw": 350, "operators": ["AWS", "Microsoft", "Google"], "notes": "Iberian expansion"},
|
||||
{"name": "Luleå", "country": "Sweden", "iso3": "SWE", "lat": 65.58, "lon": 22.15, "region": "Norrbotten", "power_mw": 200, "operators": ["Meta"], "notes": "Arctic cooling, hydro power, first Meta EU DC"},
|
||||
{"name": "Hamina", "country": "Finland", "iso3": "FIN", "lat": 60.57, "lon": 27.20, "region": "Kymenlaakso", "power_mw": 200, "operators": ["Google"], "notes": "Seawater cooling from Baltic"},
|
||||
# Asia-Pacific
|
||||
{"name": "Singapore / Jurong", "country": "Singapore", "iso3": "SGP", "lat": 1.33, "lon": 103.74, "region": "West Region", "power_mw": 700, "operators": ["Equinix", "Digital Realty", "AWS", "Google"], "notes": "APAC hub, moratorium lifted 2022 with green requirements"},
|
||||
{"name": "Tokyo / Inzai", "country": "Japan", "iso3": "JPN", "lat": 35.83, "lon": 140.14, "region": "Chiba", "power_mw": 900, "operators": ["Equinix", "AWS", "Google", "NTT"], "notes": "Asia's largest market"},
|
||||
{"name": "Seoul / Gasan", "country": "South Korea", "iso3": "KOR", "lat": 37.48, "lon": 126.88, "region": "Seoul", "power_mw": 400, "operators": ["AWS", "Google", "Samsung SDS", "KT"], "notes": "Korean DC cluster, 5G synergy"},
|
||||
{"name": "Mumbai / Navi Mumbai", "country": "India", "iso3": "IND", "lat": 19.06, "lon": 73.01, "region": "Maharashtra", "power_mw": 600, "operators": ["AWS", "Microsoft", "Google", "Reliance Jio", "Adani"], "notes": "India's primary DC hub, submarine cable landing"},
|
||||
{"name": "Chennai / Ambattur", "country": "India", "iso3": "IND", "lat": 13.11, "lon": 80.15, "region": "Tamil Nadu", "power_mw": 350, "operators": ["AWS", "Microsoft", "NTT"], "notes": "Second India hub, cable landing site"},
|
||||
{"name": "Sydney / Western Sydney", "country": "Australia", "iso3": "AUS", "lat": -33.80, "lon": 150.90, "region": "NSW", "power_mw": 500, "operators": ["Equinix", "AWS", "Microsoft", "Google"], "notes": "Australia's primary hub"},
|
||||
{"name": "Hong Kong / Tseung Kwan O", "country": "China", "iso3": "HKG", "lat": 22.31, "lon": 114.26, "region": "New Territories", "power_mw": 400, "operators": ["Equinix", "SUNeVision", "NTT"], "notes": "Asia financial connectivity hub"},
|
||||
{"name": "Beijing / Zhongguancun", "country": "China", "iso3": "CHN", "lat": 39.98, "lon": 116.30, "region": "Beijing", "power_mw": 800, "operators": ["Alibaba", "Tencent", "Baidu", "ByteDance"], "notes": "China's AI research center"},
|
||||
{"name": "Shanghai / Lingang", "country": "China", "iso3": "CHN", "lat": 30.89, "lon": 121.93, "region": "Shanghai", "power_mw": 700, "operators": ["Alibaba", "Tencent", "AWS (via partners)"], "notes": "East China financial hub"},
|
||||
{"name": "Guizhou / Guiyang", "country": "China", "iso3": "CHN", "lat": 26.65, "lon": 106.63, "region": "Guizhou", "power_mw": 500, "operators": ["Alibaba", "Huawei", "Tencent", "Apple"], "notes": "Mountain cooling, Apple iCloud China"},
|
||||
{"name": "Zhangbei / Hebei", "country": "China", "iso3": "CHN", "lat": 41.15, "lon": 114.70, "region": "Hebei", "power_mw": 600, "operators": ["Alibaba"], "notes": "Cold climate DC cluster, near Beijing"},
|
||||
{"name": "Ulanqab / Inner Mongolia", "country": "China", "iso3": "CHN", "lat": 41.00, "lon": 113.13, "region": "Inner Mongolia", "power_mw": 500, "operators": ["Alibaba", "Huawei"], "notes": "Cold climate, wind/solar power"},
|
||||
{"name": "Jakarta / Cibitung", "country": "Indonesia", "iso3": "IDN", "lat": -6.27, "lon": 107.09, "region": "West Java", "power_mw": 300, "operators": ["AWS", "Google", "Telkom"], "notes": "Indonesia hub, fast growth"},
|
||||
{"name": "Johor Bahru", "country": "Malaysia", "iso3": "MYS", "lat": 1.49, "lon": 103.74, "region": "Johor", "power_mw": 500, "operators": ["Microsoft", "Google", "Amazon", "ByteDance"], "notes": "Singapore spillover, massive 2024-25 expansion"},
|
||||
# Middle East
|
||||
{"name": "Dubai / Jebel Ali", "country": "UAE", "iso3": "ARE", "lat": 24.99, "lon": 55.06, "region": "Dubai", "power_mw": 300, "operators": ["AWS", "Microsoft", "Oracle", "Equinix"], "notes": "MENA hub"},
|
||||
{"name": "Riyadh", "country": "Saudi Arabia", "iso3": "SAU", "lat": 24.71, "lon": 46.67, "region": "Riyadh Province", "power_mw": 300, "operators": ["AWS", "Google", "Oracle", "STC"], "notes": "Vision 2030 DC investment"},
|
||||
{"name": "Tel Aviv / Haifa", "country": "Israel", "iso3": "ISR", "lat": 32.07, "lon": 34.77, "region": "Tel Aviv", "power_mw": 200, "operators": ["AWS", "Google", "Microsoft", "Equinix"], "notes": "Israel tech hub, Blue/Raman cable landing"},
|
||||
# South America
|
||||
{"name": "São Paulo / Barueri", "country": "Brazil", "iso3": "BRA", "lat": -23.51, "lon": -46.88, "region": "São Paulo", "power_mw": 500, "operators": ["Equinix", "Digital Realty", "AWS", "Google"], "notes": "Latin America's largest DC market"},
|
||||
{"name": "Santiago", "country": "Chile", "iso3": "CHL", "lat": -33.45, "lon": -70.65, "region": "Metropolitana", "power_mw": 150, "operators": ["Google", "AWS", "Huawei"], "notes": "Pacific cable landing, Curie cable"},
|
||||
{"name": "Querétaro", "country": "Mexico", "iso3": "MEX", "lat": 20.59, "lon": -100.39, "region": "Querétaro", "power_mw": 200, "operators": ["Google", "AWS", "Equinix", "KIO Networks"], "notes": "Mexico DC hub"},
|
||||
# Africa
|
||||
{"name": "Johannesburg / Isando", "country": "South Africa", "iso3": "ZAF", "lat": -26.14, "lon": 28.20, "region": "Gauteng", "power_mw": 200, "operators": ["Teraco", "AWS", "Microsoft"], "notes": "Africa's primary DC hub, NAPAfrica IXP"},
|
||||
{"name": "Nairobi", "country": "Kenya", "iso3": "KEN", "lat": -1.29, "lon": 36.82, "region": "Nairobi", "power_mw": 80, "operators": ["AWS", "Microsoft", "Liquid Intelligent"], "notes": "East Africa hub"},
|
||||
{"name": "Lagos / Lekki", "country": "Nigeria", "iso3": "NGA", "lat": 6.43, "lon": 3.43, "region": "Lagos", "power_mw": 70, "operators": ["Rack Centre", "Africa Data Centres"], "notes": "West Africa's largest, 2Africa cable landing"},
|
||||
# Nordics / Specialty
|
||||
{"name": "Reykjavik / Fitjar", "country": "Iceland", "iso3": "ISL", "lat": 64.05, "lon": -21.95, "region": "Capital Region", "power_mw": 100, "operators": ["Verne Global", "atNorth"], "notes": "100% geothermal/hydro, natural cooling"},
|
||||
]
|
||||
|
||||
|
||||
def query_datacenters(
|
||||
country: str | None = None,
|
||||
operator: str | None = None,
|
||||
min_power_mw: int | None = None,
|
||||
region: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Filter AI datacenters by country, operator, minimum power, or region."""
|
||||
results = []
|
||||
for dc in AI_DATACENTERS:
|
||||
if country:
|
||||
c = country.lower()
|
||||
if c not in (dc["country"].lower(), dc["iso3"].lower()):
|
||||
continue
|
||||
if min_power_mw and dc["power_mw"] < min_power_mw:
|
||||
continue
|
||||
if operator:
|
||||
op_lower = operator.lower()
|
||||
if not any(op_lower in o.lower() for o in dc["operators"]):
|
||||
continue
|
||||
if region:
|
||||
if region.lower() not in dc["region"].lower():
|
||||
continue
|
||||
results.append(dc)
|
||||
return results
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Global stock exchanges dataset.
|
||||
|
||||
Pure data module — no I/O, no external dependencies.
|
||||
Sources: World Federation of Exchanges, GFCI, World Bank, press.
|
||||
92 exchanges organized by tier (mega, major, emerging, frontier).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
STOCK_EXCHANGES: list[dict] = [
|
||||
# Mega exchanges (>$3T market cap)
|
||||
{"name": "New York Stock Exchange", "acronym": "NYSE", "country": "USA", "iso3": "USA", "city": "New York", "lat": 40.71, "lon": -74.01, "tier": "mega", "market_cap_usd_t": 28.4, "index": "^DJI", "currency": "USD", "timezone": "America/New_York"},
|
||||
{"name": "NASDAQ", "acronym": "NASDAQ", "country": "USA", "iso3": "USA", "city": "New York", "lat": 40.76, "lon": -73.99, "tier": "mega", "market_cap_usd_t": 25.5, "index": "^IXIC", "currency": "USD", "timezone": "America/New_York"},
|
||||
{"name": "Shanghai Stock Exchange", "acronym": "SSE", "country": "China", "iso3": "CHN", "city": "Shanghai", "lat": 31.23, "lon": 121.47, "tier": "mega", "market_cap_usd_t": 6.9, "index": "000001.SS", "currency": "CNY", "timezone": "Asia/Shanghai"},
|
||||
{"name": "Japan Exchange Group", "acronym": "JPX", "country": "Japan", "iso3": "JPN", "city": "Tokyo", "lat": 35.68, "lon": 139.77, "tier": "mega", "market_cap_usd_t": 6.5, "index": "^N225", "currency": "JPY", "timezone": "Asia/Tokyo"},
|
||||
{"name": "Shenzhen Stock Exchange", "acronym": "SZSE", "country": "China", "iso3": "CHN", "city": "Shenzhen", "lat": 22.54, "lon": 114.06, "tier": "mega", "market_cap_usd_t": 4.9, "index": "399001.SZ", "currency": "CNY", "timezone": "Asia/Shanghai"},
|
||||
{"name": "Hong Kong Exchanges", "acronym": "HKEX", "country": "China", "iso3": "HKG", "city": "Hong Kong", "lat": 22.28, "lon": 114.16, "tier": "mega", "market_cap_usd_t": 4.6, "index": "^HSI", "currency": "HKD", "timezone": "Asia/Hong_Kong"},
|
||||
{"name": "National Stock Exchange of India", "acronym": "NSE", "country": "India", "iso3": "IND", "city": "Mumbai", "lat": 19.06, "lon": 72.86, "tier": "mega", "market_cap_usd_t": 4.3, "index": "^NSEI", "currency": "INR", "timezone": "Asia/Kolkata"},
|
||||
{"name": "London Stock Exchange", "acronym": "LSE", "country": "UK", "iso3": "GBR", "city": "London", "lat": 51.51, "lon": -0.09, "tier": "mega", "market_cap_usd_t": 3.4, "index": "^FTSE", "currency": "GBP", "timezone": "Europe/London"},
|
||||
{"name": "Euronext", "acronym": "ENX", "country": "Netherlands", "iso3": "NLD", "city": "Amsterdam", "lat": 52.37, "lon": 4.89, "tier": "mega", "market_cap_usd_t": 7.3, "index": "^AEX", "currency": "EUR", "timezone": "Europe/Amsterdam"},
|
||||
# Major exchanges ($500B-$3T)
|
||||
{"name": "Toronto Stock Exchange", "acronym": "TSX", "country": "Canada", "iso3": "CAN", "city": "Toronto", "lat": 43.65, "lon": -79.38, "tier": "major", "market_cap_usd_t": 2.8, "index": "^GSPTSE", "currency": "CAD", "timezone": "America/Toronto"},
|
||||
{"name": "Saudi Exchange (Tadawul)", "acronym": "TADAWUL", "country": "Saudi Arabia", "iso3": "SAU", "city": "Riyadh", "lat": 24.71, "lon": 46.67, "tier": "major", "market_cap_usd_t": 2.9, "index": "^TASI", "currency": "SAR", "timezone": "Asia/Riyadh"},
|
||||
{"name": "Deutsche Börse", "acronym": "XETRA", "country": "Germany", "iso3": "DEU", "city": "Frankfurt", "lat": 50.11, "lon": 8.68, "tier": "major", "market_cap_usd_t": 2.3, "index": "^GDAXI", "currency": "EUR", "timezone": "Europe/Berlin"},
|
||||
{"name": "SIX Swiss Exchange", "acronym": "SIX", "country": "Switzerland", "iso3": "CHE", "city": "Zurich", "lat": 47.37, "lon": 8.54, "tier": "major", "market_cap_usd_t": 1.9, "index": "^SSMI", "currency": "CHF", "timezone": "Europe/Zurich"},
|
||||
{"name": "Korea Exchange", "acronym": "KRX", "country": "South Korea", "iso3": "KOR", "city": "Seoul", "lat": 37.52, "lon": 126.93, "tier": "major", "market_cap_usd_t": 1.8, "index": "^KS11", "currency": "KRW", "timezone": "Asia/Seoul"},
|
||||
{"name": "Nasdaq Nordic (OMX)", "acronym": "OMX", "country": "Sweden", "iso3": "SWE", "city": "Stockholm", "lat": 59.33, "lon": 18.07, "tier": "major", "market_cap_usd_t": 1.7, "index": "^OMX", "currency": "SEK", "timezone": "Europe/Stockholm"},
|
||||
{"name": "Australian Securities Exchange", "acronym": "ASX", "country": "Australia", "iso3": "AUS", "city": "Sydney", "lat": -33.87, "lon": 151.21, "tier": "major", "market_cap_usd_t": 1.6, "index": "^AXJO", "currency": "AUD", "timezone": "Australia/Sydney"},
|
||||
{"name": "Taiwan Stock Exchange", "acronym": "TWSE", "country": "Taiwan", "iso3": "TWN", "city": "Taipei", "lat": 25.03, "lon": 121.52, "tier": "major", "market_cap_usd_t": 2.1, "index": "^TWII", "currency": "TWD", "timezone": "Asia/Taipei"},
|
||||
{"name": "Bombay Stock Exchange", "acronym": "BSE", "country": "India", "iso3": "IND", "city": "Mumbai", "lat": 18.93, "lon": 72.83, "tier": "major", "market_cap_usd_t": 4.1, "index": "^BSESN", "currency": "INR", "timezone": "Asia/Kolkata"},
|
||||
{"name": "Johannesburg Stock Exchange", "acronym": "JSE", "country": "South Africa", "iso3": "ZAF", "city": "Johannesburg", "lat": -26.20, "lon": 28.04, "tier": "major", "market_cap_usd_t": 1.1, "index": "^J203", "currency": "ZAR", "timezone": "Africa/Johannesburg"},
|
||||
{"name": "B3 (Brasil Bolsa Balcão)", "acronym": "B3", "country": "Brazil", "iso3": "BRA", "city": "São Paulo", "lat": -23.55, "lon": -46.63, "tier": "major", "market_cap_usd_t": 0.9, "index": "^BVSP", "currency": "BRL", "timezone": "America/Sao_Paulo"},
|
||||
{"name": "Borsa Italiana", "acronym": "BIT", "country": "Italy", "iso3": "ITA", "city": "Milan", "lat": 45.46, "lon": 9.19, "tier": "major", "market_cap_usd_t": 0.8, "index": "FTSEMIB.MI", "currency": "EUR", "timezone": "Europe/Rome"},
|
||||
{"name": "BME Spanish Exchanges", "acronym": "BME", "country": "Spain", "iso3": "ESP", "city": "Madrid", "lat": 40.42, "lon": -3.70, "tier": "major", "market_cap_usd_t": 0.7, "index": "^IBEX", "currency": "EUR", "timezone": "Europe/Madrid"},
|
||||
{"name": "Singapore Exchange", "acronym": "SGX", "country": "Singapore", "iso3": "SGP", "city": "Singapore", "lat": 1.28, "lon": 103.85, "tier": "major", "market_cap_usd_t": 0.6, "index": "^STI", "currency": "SGD", "timezone": "Asia/Singapore"},
|
||||
{"name": "Bolsa Mexicana de Valores", "acronym": "BMV", "country": "Mexico", "iso3": "MEX", "city": "Mexico City", "lat": 19.43, "lon": -99.13, "tier": "major", "market_cap_usd_t": 0.5, "index": "^MXX", "currency": "MXN", "timezone": "America/Mexico_City"},
|
||||
{"name": "Tel Aviv Stock Exchange", "acronym": "TASE", "country": "Israel", "iso3": "ISR", "city": "Tel Aviv", "lat": 32.07, "lon": 34.77, "tier": "major", "market_cap_usd_t": 0.3, "index": "^TA125", "currency": "ILS", "timezone": "Asia/Jerusalem"},
|
||||
# Emerging exchanges ($50B-$500B)
|
||||
{"name": "Indonesia Stock Exchange", "acronym": "IDX", "country": "Indonesia", "iso3": "IDN", "city": "Jakarta", "lat": -6.22, "lon": 106.85, "tier": "emerging", "market_cap_usd_t": 0.6, "index": "^JKSE", "currency": "IDR", "timezone": "Asia/Jakarta"},
|
||||
{"name": "Bursa Malaysia", "acronym": "BM", "country": "Malaysia", "iso3": "MYS", "city": "Kuala Lumpur", "lat": 3.15, "lon": 101.71, "tier": "emerging", "market_cap_usd_t": 0.4, "index": "^KLSE", "currency": "MYR", "timezone": "Asia/Kuala_Lumpur"},
|
||||
{"name": "Stock Exchange of Thailand", "acronym": "SET", "country": "Thailand", "iso3": "THA", "city": "Bangkok", "lat": 13.76, "lon": 100.50, "tier": "emerging", "market_cap_usd_t": 0.5, "index": "^SET", "currency": "THB", "timezone": "Asia/Bangkok"},
|
||||
{"name": "Philippine Stock Exchange", "acronym": "PSE", "country": "Philippines", "iso3": "PHL", "city": "Manila", "lat": 14.59, "lon": 120.98, "tier": "emerging", "market_cap_usd_t": 0.3, "index": "PSEI.PS", "currency": "PHP", "timezone": "Asia/Manila"},
|
||||
{"name": "Ho Chi Minh Stock Exchange", "acronym": "HOSE", "country": "Vietnam", "iso3": "VNM", "city": "Ho Chi Minh City", "lat": 10.77, "lon": 106.70, "tier": "emerging", "market_cap_usd_t": 0.2, "index": "^VNINDEX", "currency": "VND", "timezone": "Asia/Ho_Chi_Minh"},
|
||||
{"name": "Colombo Stock Exchange", "acronym": "CSE", "country": "Sri Lanka", "iso3": "LKA", "city": "Colombo", "lat": 6.93, "lon": 79.84, "tier": "emerging", "market_cap_usd_t": 0.02, "index": "^CSE", "currency": "LKR", "timezone": "Asia/Colombo"},
|
||||
{"name": "Abu Dhabi Securities Exchange", "acronym": "ADX", "country": "UAE", "iso3": "ARE", "city": "Abu Dhabi", "lat": 24.45, "lon": 54.65, "tier": "emerging", "market_cap_usd_t": 0.8, "index": "^ADI", "currency": "AED", "timezone": "Asia/Dubai"},
|
||||
{"name": "Dubai Financial Market", "acronym": "DFM", "country": "UAE", "iso3": "ARE", "city": "Dubai", "lat": 25.20, "lon": 55.27, "tier": "emerging", "market_cap_usd_t": 0.2, "index": "^DFMGI", "currency": "AED", "timezone": "Asia/Dubai"},
|
||||
{"name": "Qatar Stock Exchange", "acronym": "QSE", "country": "Qatar", "iso3": "QAT", "city": "Doha", "lat": 25.29, "lon": 51.53, "tier": "emerging", "market_cap_usd_t": 0.2, "index": "^QSI", "currency": "QAR", "timezone": "Asia/Qatar"},
|
||||
{"name": "Kuwait Stock Exchange", "acronym": "BK", "country": "Kuwait", "iso3": "KWT", "city": "Kuwait City", "lat": 29.38, "lon": 47.99, "tier": "emerging", "market_cap_usd_t": 0.1, "index": "^BKP", "currency": "KWD", "timezone": "Asia/Kuwait"},
|
||||
{"name": "Bahrain Bourse", "acronym": "BHB", "country": "Bahrain", "iso3": "BHR", "city": "Manama", "lat": 26.22, "lon": 50.59, "tier": "emerging", "market_cap_usd_t": 0.03, "index": "^BAX", "currency": "BHD", "timezone": "Asia/Bahrain"},
|
||||
{"name": "Muscat Securities Market", "acronym": "MSM", "country": "Oman", "iso3": "OMN", "city": "Muscat", "lat": 23.61, "lon": 58.59, "tier": "emerging", "market_cap_usd_t": 0.03, "index": "^MSI", "currency": "OMR", "timezone": "Asia/Muscat"},
|
||||
{"name": "Casablanca Stock Exchange", "acronym": "CSE", "country": "Morocco", "iso3": "MAR", "city": "Casablanca", "lat": 33.59, "lon": -7.62, "tier": "emerging", "market_cap_usd_t": 0.07, "index": "^MASI", "currency": "MAD", "timezone": "Africa/Casablanca"},
|
||||
{"name": "Egyptian Exchange", "acronym": "EGX", "country": "Egypt", "iso3": "EGY", "city": "Cairo", "lat": 30.04, "lon": 31.24, "tier": "emerging", "market_cap_usd_t": 0.04, "index": "^EGX30", "currency": "EGP", "timezone": "Africa/Cairo"},
|
||||
{"name": "Nairobi Securities Exchange", "acronym": "NSE", "country": "Kenya", "iso3": "KEN", "city": "Nairobi", "lat": -1.29, "lon": 36.82, "tier": "emerging", "market_cap_usd_t": 0.02, "index": "^NSE20", "currency": "KES", "timezone": "Africa/Nairobi"},
|
||||
{"name": "Nigerian Exchange", "acronym": "NGX", "country": "Nigeria", "iso3": "NGA", "city": "Lagos", "lat": 6.45, "lon": 3.40, "tier": "emerging", "market_cap_usd_t": 0.04, "index": "^NGSE", "currency": "NGN", "timezone": "Africa/Lagos"},
|
||||
{"name": "Santiago Stock Exchange", "acronym": "BCS", "country": "Chile", "iso3": "CHL", "city": "Santiago", "lat": -33.44, "lon": -70.66, "tier": "emerging", "market_cap_usd_t": 0.2, "index": "^IPSA", "currency": "CLP", "timezone": "America/Santiago"},
|
||||
{"name": "Buenos Aires Stock Exchange", "acronym": "BCBA", "country": "Argentina", "iso3": "ARG", "city": "Buenos Aires", "lat": -34.61, "lon": -58.37, "tier": "emerging", "market_cap_usd_t": 0.06, "index": "^MERV", "currency": "ARS", "timezone": "America/Argentina/Buenos_Aires"},
|
||||
{"name": "Lima Stock Exchange", "acronym": "BVL", "country": "Peru", "iso3": "PER", "city": "Lima", "lat": -12.05, "lon": -77.04, "tier": "emerging", "market_cap_usd_t": 0.08, "index": "^SPBLPGPT", "currency": "PEN", "timezone": "America/Lima"},
|
||||
{"name": "Colombia Stock Exchange", "acronym": "BVC", "country": "Colombia", "iso3": "COL", "city": "Bogotá", "lat": 4.71, "lon": -74.07, "tier": "emerging", "market_cap_usd_t": 0.07, "index": "^COLCAP", "currency": "COP", "timezone": "America/Bogota"},
|
||||
{"name": "Warsaw Stock Exchange", "acronym": "WSE", "country": "Poland", "iso3": "POL", "city": "Warsaw", "lat": 52.23, "lon": 21.01, "tier": "emerging", "market_cap_usd_t": 0.2, "index": "^WIG20", "currency": "PLN", "timezone": "Europe/Warsaw"},
|
||||
{"name": "Moscow Exchange", "acronym": "MOEX", "country": "Russia", "iso3": "RUS", "city": "Moscow", "lat": 55.76, "lon": 37.62, "tier": "emerging", "market_cap_usd_t": 0.6, "index": "IMOEX.ME", "currency": "RUB", "timezone": "Europe/Moscow"},
|
||||
{"name": "Istanbul Stock Exchange", "acronym": "BIST", "country": "Turkey", "iso3": "TUR", "city": "Istanbul", "lat": 41.01, "lon": 28.98, "tier": "emerging", "market_cap_usd_t": 0.3, "index": "^XU100", "currency": "TRY", "timezone": "Europe/Istanbul"},
|
||||
{"name": "Athens Stock Exchange", "acronym": "ATHEX", "country": "Greece", "iso3": "GRC", "city": "Athens", "lat": 37.98, "lon": 23.73, "tier": "emerging", "market_cap_usd_t": 0.08, "index": "^ATG", "currency": "EUR", "timezone": "Europe/Athens"},
|
||||
{"name": "Bucharest Stock Exchange", "acronym": "BVB", "country": "Romania", "iso3": "ROU", "city": "Bucharest", "lat": 44.43, "lon": 26.10, "tier": "emerging", "market_cap_usd_t": 0.06, "index": "^BET", "currency": "RON", "timezone": "Europe/Bucharest"},
|
||||
{"name": "Prague Stock Exchange", "acronym": "PSE", "country": "Czech Republic", "iso3": "CZE", "city": "Prague", "lat": 50.08, "lon": 14.43, "tier": "emerging", "market_cap_usd_t": 0.03, "index": "^PX", "currency": "CZK", "timezone": "Europe/Prague"},
|
||||
{"name": "Budapest Stock Exchange", "acronym": "BSE", "country": "Hungary", "iso3": "HUN", "city": "Budapest", "lat": 47.50, "lon": 19.04, "tier": "emerging", "market_cap_usd_t": 0.04, "index": "^BUX", "currency": "HUF", "timezone": "Europe/Budapest"},
|
||||
{"name": "Pakistan Stock Exchange", "acronym": "PSX", "country": "Pakistan", "iso3": "PAK", "city": "Karachi", "lat": 24.85, "lon": 67.01, "tier": "emerging", "market_cap_usd_t": 0.04, "index": "^KSE100", "currency": "PKR", "timezone": "Asia/Karachi"},
|
||||
{"name": "Dhaka Stock Exchange", "acronym": "DSE", "country": "Bangladesh", "iso3": "BGD", "city": "Dhaka", "lat": 23.73, "lon": 90.39, "tier": "emerging", "market_cap_usd_t": 0.05, "index": "^DSEX", "currency": "BDT", "timezone": "Asia/Dhaka"},
|
||||
# Frontier / smaller
|
||||
{"name": "Amman Stock Exchange", "acronym": "ASE", "country": "Jordan", "iso3": "JOR", "city": "Amman", "lat": 31.95, "lon": 35.93, "tier": "frontier", "market_cap_usd_t": 0.02, "index": "^AMGNRLX", "currency": "JOD", "timezone": "Asia/Amman"},
|
||||
{"name": "Tunis Stock Exchange", "acronym": "BVMT", "country": "Tunisia", "iso3": "TUN", "city": "Tunis", "lat": 36.80, "lon": 10.18, "tier": "frontier", "market_cap_usd_t": 0.01, "index": "^TUNINDEX", "currency": "TND", "timezone": "Africa/Tunis"},
|
||||
{"name": "Dar es Salaam Stock Exchange", "acronym": "DSE", "country": "Tanzania", "iso3": "TZA", "city": "Dar es Salaam", "lat": -6.79, "lon": 39.28, "tier": "frontier", "market_cap_usd_t": 0.01, "index": "^DSI", "currency": "TZS", "timezone": "Africa/Dar_es_Salaam"},
|
||||
{"name": "Uganda Securities Exchange", "acronym": "USE", "country": "Uganda", "iso3": "UGA", "city": "Kampala", "lat": 0.31, "lon": 32.58, "tier": "frontier", "market_cap_usd_t": 0.005, "index": "^ALSI", "currency": "UGX", "timezone": "Africa/Kampala"},
|
||||
{"name": "Rwanda Stock Exchange", "acronym": "RSE", "country": "Rwanda", "iso3": "RWA", "city": "Kigali", "lat": -1.94, "lon": 30.06, "tier": "frontier", "market_cap_usd_t": 0.003, "index": "^RSI", "currency": "RWF", "timezone": "Africa/Kigali"},
|
||||
{"name": "Ghana Stock Exchange", "acronym": "GSE", "country": "Ghana", "iso3": "GHA", "city": "Accra", "lat": 5.56, "lon": -0.19, "tier": "frontier", "market_cap_usd_t": 0.01, "index": "^GGSECI", "currency": "GHS", "timezone": "Africa/Accra"},
|
||||
{"name": "Zimbabwe Stock Exchange", "acronym": "ZSE", "country": "Zimbabwe", "iso3": "ZWE", "city": "Harare", "lat": -17.83, "lon": 31.05, "tier": "frontier", "market_cap_usd_t": 0.003, "index": "^ZSI", "currency": "ZWL", "timezone": "Africa/Harare"},
|
||||
{"name": "Botswana Stock Exchange", "acronym": "BSE", "country": "Botswana", "iso3": "BWA", "city": "Gaborone", "lat": -24.65, "lon": 25.91, "tier": "frontier", "market_cap_usd_t": 0.004, "index": "^DCI", "currency": "BWP", "timezone": "Africa/Gaborone"},
|
||||
{"name": "Lusaka Securities Exchange", "acronym": "LuSE", "country": "Zambia", "iso3": "ZMB", "city": "Lusaka", "lat": -15.39, "lon": 28.32, "tier": "frontier", "market_cap_usd_t": 0.005, "index": "^LASI", "currency": "ZMW", "timezone": "Africa/Lusaka"},
|
||||
{"name": "Mauritius Stock Exchange", "acronym": "SEM", "country": "Mauritius", "iso3": "MUS", "city": "Port Louis", "lat": -20.16, "lon": 57.50, "tier": "frontier", "market_cap_usd_t": 0.01, "index": "^SEMDEX", "currency": "MUR", "timezone": "Indian/Mauritius"},
|
||||
{"name": "Nepal Stock Exchange", "acronym": "NEPSE", "country": "Nepal", "iso3": "NPL", "city": "Kathmandu", "lat": 27.71, "lon": 85.32, "tier": "frontier", "market_cap_usd_t": 0.02, "index": "^NEPSE", "currency": "NPR", "timezone": "Asia/Kathmandu"},
|
||||
{"name": "Cambodia Securities Exchange", "acronym": "CSX", "country": "Cambodia", "iso3": "KHM", "city": "Phnom Penh", "lat": 11.56, "lon": 104.93, "tier": "frontier", "market_cap_usd_t": 0.003, "index": "^CSXI", "currency": "KHR", "timezone": "Asia/Phnom_Penh"},
|
||||
{"name": "Lao Securities Exchange", "acronym": "LSX", "country": "Laos", "iso3": "LAO", "city": "Vientiane", "lat": 17.97, "lon": 102.63, "tier": "frontier", "market_cap_usd_t": 0.001, "index": "^LSXI", "currency": "LAK", "timezone": "Asia/Vientiane"},
|
||||
{"name": "Mongolia Stock Exchange", "acronym": "MSE", "country": "Mongolia", "iso3": "MNG", "city": "Ulaanbaatar", "lat": 47.92, "lon": 106.91, "tier": "frontier", "market_cap_usd_t": 0.002, "index": "^MNT20", "currency": "MNT", "timezone": "Asia/Ulaanbaatar"},
|
||||
{"name": "Tehran Stock Exchange", "acronym": "TSE", "country": "Iran", "iso3": "IRN", "city": "Tehran", "lat": 35.69, "lon": 51.42, "tier": "emerging", "market_cap_usd_t": 0.2, "index": "^TEDPIX", "currency": "IRR", "timezone": "Asia/Tehran"},
|
||||
{"name": "Karachi Stock Exchange (merged into PSX)", "acronym": "KSE", "country": "Pakistan", "iso3": "PAK", "city": "Karachi", "lat": 24.85, "lon": 67.01, "tier": "emerging", "market_cap_usd_t": 0.0, "index": "", "currency": "PKR", "timezone": "Asia/Karachi"},
|
||||
{"name": "Colombo Stock Exchange", "acronym": "CSE", "country": "Sri Lanka", "iso3": "LKA", "city": "Colombo", "lat": 6.93, "lon": 79.85, "tier": "frontier", "market_cap_usd_t": 0.02, "index": "^ASPI", "currency": "LKR", "timezone": "Asia/Colombo"},
|
||||
{"name": "New Zealand Exchange", "acronym": "NZX", "country": "New Zealand", "iso3": "NZL", "city": "Wellington", "lat": -41.29, "lon": 174.78, "tier": "major", "market_cap_usd_t": 0.1, "index": "^NZ50", "currency": "NZD", "timezone": "Pacific/Auckland"},
|
||||
{"name": "Oslo Stock Exchange", "acronym": "OSE", "country": "Norway", "iso3": "NOR", "city": "Oslo", "lat": 59.91, "lon": 10.75, "tier": "major", "market_cap_usd_t": 0.3, "index": "^OBX", "currency": "NOK", "timezone": "Europe/Oslo"},
|
||||
{"name": "Copenhagen Stock Exchange", "acronym": "CSE", "country": "Denmark", "iso3": "DNK", "city": "Copenhagen", "lat": 55.68, "lon": 12.57, "tier": "major", "market_cap_usd_t": 0.7, "index": "^OMXC25", "currency": "DKK", "timezone": "Europe/Copenhagen"},
|
||||
{"name": "Helsinki Stock Exchange", "acronym": "OMXH", "country": "Finland", "iso3": "FIN", "city": "Helsinki", "lat": 60.17, "lon": 24.94, "tier": "major", "market_cap_usd_t": 0.3, "index": "^OMXH25", "currency": "EUR", "timezone": "Europe/Helsinki"},
|
||||
{"name": "Vienna Stock Exchange", "acronym": "VSE", "country": "Austria", "iso3": "AUT", "city": "Vienna", "lat": 48.21, "lon": 16.37, "tier": "emerging", "market_cap_usd_t": 0.1, "index": "^ATX", "currency": "EUR", "timezone": "Europe/Vienna"},
|
||||
{"name": "Lisbon Stock Exchange (Euronext)", "acronym": "ELX", "country": "Portugal", "iso3": "PRT", "city": "Lisbon", "lat": 38.72, "lon": -9.14, "tier": "emerging", "market_cap_usd_t": 0.08, "index": "^PSI20", "currency": "EUR", "timezone": "Europe/Lisbon"},
|
||||
{"name": "Brussels Stock Exchange (Euronext)", "acronym": "EBR", "country": "Belgium", "iso3": "BEL", "city": "Brussels", "lat": 50.85, "lon": 4.35, "tier": "major", "market_cap_usd_t": 0.3, "index": "^BFX", "currency": "EUR", "timezone": "Europe/Brussels"},
|
||||
{"name": "Irish Stock Exchange (Euronext)", "acronym": "ISE", "country": "Ireland", "iso3": "IRL", "city": "Dublin", "lat": 53.35, "lon": -6.26, "tier": "emerging", "market_cap_usd_t": 0.1, "index": "^ISEQ", "currency": "EUR", "timezone": "Europe/Dublin"},
|
||||
{"name": "Zagreb Stock Exchange", "acronym": "ZSE", "country": "Croatia", "iso3": "HRV", "city": "Zagreb", "lat": 45.81, "lon": 15.98, "tier": "frontier", "market_cap_usd_t": 0.03, "index": "^CRBEX", "currency": "EUR", "timezone": "Europe/Zagreb"},
|
||||
{"name": "Ljubljana Stock Exchange", "acronym": "LJSE", "country": "Slovenia", "iso3": "SVN", "city": "Ljubljana", "lat": 46.05, "lon": 14.51, "tier": "frontier", "market_cap_usd_t": 0.01, "index": "^SBITOP", "currency": "EUR", "timezone": "Europe/Ljubljana"},
|
||||
{"name": "Beirut Stock Exchange", "acronym": "BSE", "country": "Lebanon", "iso3": "LBN", "city": "Beirut", "lat": 33.89, "lon": 35.50, "tier": "frontier", "market_cap_usd_t": 0.003, "index": "^BLOM", "currency": "LBP", "timezone": "Asia/Beirut"},
|
||||
]
|
||||
|
||||
|
||||
# Country ISO3 → primary exchange index ticker mapping
|
||||
COUNTRY_INDEX_TICKERS: dict[str, str] = {
|
||||
ex["iso3"]: ex["index"]
|
||||
for ex in STOCK_EXCHANGES
|
||||
if ex["index"] and ex["tier"] in ("mega", "major")
|
||||
}
|
||||
# Override duplicates (keep largest by market cap)
|
||||
COUNTRY_INDEX_TICKERS.update({
|
||||
"USA": "^GSPC", # S&P 500 as default US index
|
||||
"CHN": "000001.SS",
|
||||
"IND": "^NSEI",
|
||||
"ARE": "^ADI",
|
||||
})
|
||||
|
||||
|
||||
def query_exchanges(
|
||||
tier: str | None = None,
|
||||
country: str | None = None,
|
||||
currency: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Filter stock exchanges by tier, country, or currency."""
|
||||
results = []
|
||||
for ex in STOCK_EXCHANGES:
|
||||
if tier and ex["tier"] != tier.lower():
|
||||
continue
|
||||
if country:
|
||||
c = country.lower()
|
||||
if c not in (ex["country"].lower(), ex["iso3"].lower()):
|
||||
continue
|
||||
if currency and ex["currency"].lower() != currency.lower():
|
||||
continue
|
||||
results.append(ex)
|
||||
return results
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Critical mineral deposits and strategic mineral locations.
|
||||
|
||||
Pure data module — no I/O, no external dependencies.
|
||||
Sources: USGS Mineral Commodity Summaries, IEA Critical Minerals,
|
||||
EU Critical Raw Materials Act, Australian Geoscience.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
CRITICAL_MINERALS: list[dict] = [
|
||||
# Lithium
|
||||
{"name": "Salar de Atacama", "country": "Chile", "iso3": "CHL", "lat": -23.50, "lon": -68.20, "mineral": "lithium", "type": "brine", "operator": "SQM / Albemarle", "annual_tonnes": 100000, "pct_global": 26, "notes": "World's largest lithium brine operation"},
|
||||
{"name": "Greenbushes", "country": "Australia", "iso3": "AUS", "lat": -33.85, "lon": 116.06, "mineral": "lithium", "type": "hard_rock", "operator": "Talison (Tianqi/Albemarle)", "annual_tonnes": 75000, "pct_global": 20, "notes": "Largest hard-rock lithium mine"},
|
||||
{"name": "Salar de Uyuni", "country": "Bolivia", "iso3": "BOL", "lat": -20.13, "lon": -67.49, "mineral": "lithium", "type": "brine", "operator": "YLB (state)", "annual_tonnes": 5000, "pct_global": 1, "notes": "Largest reserves globally (~21M tonnes), low extraction"},
|
||||
{"name": "Pilgangoora", "country": "Australia", "iso3": "AUS", "lat": -21.30, "lon": 119.04, "mineral": "lithium", "type": "hard_rock", "operator": "Pilbara Minerals", "annual_tonnes": 40000, "pct_global": 10, "notes": "Major spodumene producer, Pilbara region"},
|
||||
{"name": "Thacker Pass", "country": "USA", "iso3": "USA", "lat": 41.32, "lon": -117.65, "mineral": "lithium", "type": "clay", "operator": "Lithium Americas", "annual_tonnes": 0, "pct_global": 0, "notes": "Largest US lithium project, under construction"},
|
||||
# Cobalt
|
||||
{"name": "Katanga Province Mines", "country": "DR Congo", "iso3": "COD", "lat": -10.98, "lon": 26.02, "mineral": "cobalt", "type": "copper_cobalt", "operator": "Glencore / CMOC / Artisanal", "annual_tonnes": 130000, "pct_global": 73, "notes": "~73% of global cobalt, artisanal mining concerns"},
|
||||
{"name": "Murrin Murrin", "country": "Australia", "iso3": "AUS", "lat": -28.72, "lon": 121.87, "mineral": "cobalt", "type": "nickel_cobalt", "operator": "Glencore", "annual_tonnes": 3000, "pct_global": 2, "notes": "Nickel-cobalt laterite"},
|
||||
# Rare Earth Elements
|
||||
{"name": "Bayan Obo", "country": "China", "iso3": "CHN", "lat": 41.78, "lon": 109.97, "mineral": "rare_earths", "type": "open_pit", "operator": "China Northern Rare Earth", "annual_tonnes": 45000, "pct_global": 38, "notes": "World's largest REE deposit, Inner Mongolia"},
|
||||
{"name": "Mount Weld", "country": "Australia", "iso3": "AUS", "lat": -28.77, "lon": 122.55, "mineral": "rare_earths", "type": "open_pit", "operator": "Lynas Rare Earths", "annual_tonnes": 12000, "pct_global": 10, "notes": "Largest non-Chinese REE mine, processed in Malaysia"},
|
||||
{"name": "Mountain Pass", "country": "USA", "iso3": "USA", "lat": 35.48, "lon": -115.53, "mineral": "rare_earths", "type": "open_pit", "operator": "MP Materials", "annual_tonnes": 43000, "pct_global": 14, "notes": "Only US rare earth mine, processing expansion underway"},
|
||||
{"name": "Xunwu / Ganzhou", "country": "China", "iso3": "CHN", "lat": 24.95, "lon": 115.65, "mineral": "rare_earths", "type": "ion_adsorption", "operator": "Various Chinese SOEs", "annual_tonnes": 30000, "pct_global": 25, "notes": "Heavy rare earths (Dy, Tb), Jiangxi province"},
|
||||
# Nickel
|
||||
{"name": "Sorowako / Morowali", "country": "Indonesia", "iso3": "IDN", "lat": -2.54, "lon": 121.36, "mineral": "nickel", "type": "laterite", "operator": "Vale / Chinese consortia", "annual_tonnes": 1600000, "pct_global": 48, "notes": "Indonesia is #1 producer, massive HPAL expansion"},
|
||||
{"name": "Norilsk", "country": "Russia", "iso3": "RUS", "lat": 69.35, "lon": 88.20, "mineral": "nickel", "type": "sulfide", "operator": "Nornickel", "annual_tonnes": 200000, "pct_global": 6, "notes": "Major PGM co-product, Arctic"},
|
||||
# Copper
|
||||
{"name": "Escondida", "country": "Chile", "iso3": "CHL", "lat": -24.27, "lon": -69.07, "mineral": "copper", "type": "open_pit", "operator": "BHP / Rio Tinto / JECO", "annual_tonnes": 1100000, "pct_global": 5, "notes": "World's largest copper mine by output"},
|
||||
{"name": "Grasberg", "country": "Indonesia", "iso3": "IDN", "lat": -4.06, "lon": 137.11, "mineral": "copper", "type": "underground", "operator": "Freeport-McMoRan / INALUM", "annual_tonnes": 700000, "pct_global": 3, "notes": "Largest gold reserve, deep block cave"},
|
||||
{"name": "Kamoa-Kakula", "country": "DR Congo", "iso3": "COD", "lat": -10.77, "lon": 25.33, "mineral": "copper", "type": "underground", "operator": "Ivanhoe Mines / Zijin", "annual_tonnes": 400000, "pct_global": 2, "notes": "Newest mega-mine, highest grade discovered copper"},
|
||||
# Graphite
|
||||
{"name": "Heilongjiang Province", "country": "China", "iso3": "CHN", "lat": 47.35, "lon": 127.96, "mineral": "graphite", "type": "flake", "operator": "Various Chinese", "annual_tonnes": 900000, "pct_global": 65, "notes": "China dominates natural graphite production"},
|
||||
{"name": "Balama", "country": "Mozambique", "iso3": "MOZ", "lat": -13.35, "lon": 38.58, "mineral": "graphite", "type": "flake", "operator": "Syrah Resources", "annual_tonnes": 50000, "pct_global": 4, "notes": "Largest known graphite reserve, feeds Vidalia (US)"},
|
||||
# Manganese
|
||||
{"name": "Kalahari Manganese Field", "country": "South Africa", "iso3": "ZAF", "lat": -27.18, "lon": 22.98, "mineral": "manganese", "type": "open_pit", "operator": "South32 / Samancor", "annual_tonnes": 7200000, "pct_global": 37, "notes": "~80% of global reserves in Kalahari"},
|
||||
# Platinum Group Metals
|
||||
{"name": "Bushveld Complex", "country": "South Africa", "iso3": "ZAF", "lat": -25.00, "lon": 29.50, "mineral": "pgm", "type": "underground", "operator": "Anglo American / Impala / Sibanye", "annual_tonnes": 130, "pct_global": 72, "notes": "~72% of global platinum, major PGM source"},
|
||||
# Titanium
|
||||
{"name": "Richards Bay", "country": "South Africa", "iso3": "ZAF", "lat": -28.78, "lon": 32.04, "mineral": "titanium", "type": "mineral_sands", "operator": "Rio Tinto (RBM)", "annual_tonnes": 1000000, "pct_global": 11, "notes": "Ilmenite and rutile sands"},
|
||||
# Tungsten
|
||||
{"name": "Jiangxi Province Mines", "country": "China", "iso3": "CHN", "lat": 27.63, "lon": 115.97, "mineral": "tungsten", "type": "underground", "operator": "Various Chinese SOEs", "annual_tonnes": 60000, "pct_global": 82, "notes": "China produces >80% of global tungsten"},
|
||||
# Uranium
|
||||
{"name": "Cigar Lake", "country": "Canada", "iso3": "CAN", "lat": 58.01, "lon": -104.56, "mineral": "uranium", "type": "underground", "operator": "Cameco / Orano", "annual_tonnes": 6900, "pct_global": 13, "notes": "Highest grade uranium mine in world"},
|
||||
{"name": "Inkai", "country": "Kazakhstan", "iso3": "KAZ", "lat": 44.49, "lon": 66.09, "mineral": "uranium", "type": "isl", "operator": "Kazatomprom / Cameco", "annual_tonnes": 4000, "pct_global": 8, "notes": "Kazakhstan is #1 uranium producer (~43% global)"},
|
||||
# Tin
|
||||
{"name": "Bangka-Belitung Islands", "country": "Indonesia", "iso3": "IDN", "lat": -2.13, "lon": 106.11, "mineral": "tin", "type": "alluvial", "operator": "PT Timah / Artisanal", "annual_tonnes": 52000, "pct_global": 24, "notes": "Major tin source, environmental concerns"},
|
||||
# Gallium / Germanium (strategic, China-controlled)
|
||||
{"name": "Shanxi / Henan Alumina Refineries", "country": "China", "iso3": "CHN", "lat": 37.87, "lon": 112.55, "mineral": "gallium", "type": "byproduct", "operator": "Chalco / various", "annual_tonnes": 340, "pct_global": 98, "notes": "China controls ~98% of gallium, export restrictions 2023"},
|
||||
{"name": "Yunnan Germanium Refineries", "country": "China", "iso3": "CHN", "lat": 25.04, "lon": 102.68, "mineral": "germanium", "type": "byproduct", "operator": "Yunnan Germanium", "annual_tonnes": 100, "pct_global": 60, "notes": "China ~60% of germanium, export restrictions 2023"},
|
||||
]
|
||||
|
||||
|
||||
def query_minerals(
|
||||
mineral: str | None = None,
|
||||
country: str | None = None,
|
||||
mineral_type: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Filter critical mineral deposits."""
|
||||
results = []
|
||||
for m in CRITICAL_MINERALS:
|
||||
if mineral and m["mineral"] != mineral.lower():
|
||||
continue
|
||||
if country:
|
||||
c = country.lower()
|
||||
if c not in (m["country"].lower(), m["iso3"].lower()):
|
||||
continue
|
||||
if mineral_type and m["type"] != mineral_type.lower():
|
||||
continue
|
||||
if operator:
|
||||
if operator.lower() not in m["operator"].lower():
|
||||
continue
|
||||
results.append(m)
|
||||
return results
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Launch facilities and spaceports worldwide.
|
||||
|
||||
Pure data module — no I/O, no external dependencies.
|
||||
Sources: FAA, ESA, CSIS Aerospace, press reporting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
SPACEPORTS: list[dict] = [
|
||||
# United States
|
||||
{"name": "Cape Canaveral SFS / KSC", "country": "USA", "iso3": "USA", "lat": 28.56, "lon": -80.58, "operator": "USSF / NASA", "type": "orbital", "status": "active", "pads": 7, "notes": "Primary US orbital launch site, SpaceX LC-40, NASA LC-39A/B"},
|
||||
{"name": "Vandenberg SFB", "country": "USA", "iso3": "USA", "lat": 34.73, "lon": -120.57, "operator": "USSF", "type": "orbital", "status": "active", "pads": 4, "notes": "Polar/SSO launches, SpaceX SLC-4E"},
|
||||
{"name": "SpaceX Starbase (Boca Chica)", "country": "USA", "iso3": "USA", "lat": 25.99, "lon": -97.16, "operator": "SpaceX", "type": "orbital", "status": "active", "pads": 2, "notes": "Starship development and launch facility"},
|
||||
{"name": "Wallops Flight Facility", "country": "USA", "iso3": "USA", "lat": 37.83, "lon": -75.49, "operator": "NASA", "type": "orbital", "status": "active", "pads": 2, "notes": "Antares/Minotaur, ISS cargo"},
|
||||
{"name": "Kodiak Launch Complex", "country": "USA", "iso3": "USA", "lat": 57.44, "lon": -152.34, "operator": "Alaska Aerospace", "type": "orbital", "status": "active", "pads": 2, "notes": "Polar orbits, Astra launches"},
|
||||
{"name": "Mojave Air and Space Port", "country": "USA", "iso3": "USA", "lat": 35.06, "lon": -118.15, "operator": "Mojave Air & Space Port", "type": "suborbital", "status": "active", "pads": 0, "notes": "Horizontal launch, Virgin Orbit (defunct), test facility"},
|
||||
{"name": "Spaceport America", "country": "USA", "iso3": "USA", "lat": 32.99, "lon": -106.97, "operator": "New Mexico SAA", "type": "suborbital", "status": "active", "pads": 1, "notes": "Virgin Galactic SpaceShipTwo"},
|
||||
# Russia
|
||||
{"name": "Baikonur Cosmodrome", "country": "Kazakhstan", "iso3": "KAZ", "lat": 45.96, "lon": 63.31, "operator": "Roscosmos (leased)", "type": "orbital", "status": "active", "pads": 6, "notes": "World's first spaceport, Soyuz, Proton"},
|
||||
{"name": "Plesetsk Cosmodrome", "country": "Russia", "iso3": "RUS", "lat": 62.93, "lon": 40.58, "operator": "Russian MoD", "type": "orbital", "status": "active", "pads": 4, "notes": "Military launches, Angara, ICBM tests"},
|
||||
{"name": "Vostochny Cosmodrome", "country": "Russia", "iso3": "RUS", "lat": 51.88, "lon": 128.33, "operator": "Roscosmos", "type": "orbital", "status": "active", "pads": 2, "notes": "New Russian civilian spaceport, Amur region"},
|
||||
# China
|
||||
{"name": "Jiuquan Satellite Launch Center", "country": "China", "iso3": "CHN", "lat": 40.96, "lon": 100.30, "operator": "PLA SSF", "type": "orbital", "status": "active", "pads": 4, "notes": "First Chinese crewed launches, Shenzhou"},
|
||||
{"name": "Xichang Satellite Launch Center", "country": "China", "iso3": "CHN", "lat": 28.25, "lon": 102.03, "operator": "PLA SSF", "type": "orbital", "status": "active", "pads": 3, "notes": "GEO launches, BeiDou, Long March 3B"},
|
||||
{"name": "Taiyuan Satellite Launch Center", "country": "China", "iso3": "CHN", "lat": 38.85, "lon": 111.61, "operator": "PLA SSF", "type": "orbital", "status": "active", "pads": 2, "notes": "SSO/polar orbits"},
|
||||
{"name": "Wenchang Space Launch Site", "country": "China", "iso3": "CHN", "lat": 19.61, "lon": 110.95, "operator": "PLA SSF", "type": "orbital", "status": "active", "pads": 3, "notes": "Newest, largest Chinese vehicles, Long March 5/7"},
|
||||
{"name": "Haiyang Commercial Launch Site", "country": "China", "iso3": "CHN", "lat": 36.73, "lon": 121.13, "operator": "Commercial (CAS Space)", "type": "orbital", "status": "active", "pads": 2, "notes": "First Chinese commercial launch site, 2024"},
|
||||
# Europe
|
||||
{"name": "Guiana Space Centre (Kourou)", "country": "French Guiana", "iso3": "GUF", "lat": 5.24, "lon": -52.77, "operator": "ESA / CNES / Arianespace", "type": "orbital", "status": "active", "pads": 4, "notes": "Ariane 6, Vega-C, Soyuz (suspended), equatorial advantage"},
|
||||
{"name": "SaxaVord Spaceport", "country": "UK", "iso3": "GBR", "lat": 60.82, "lon": -0.86, "operator": "SaxaVord UK", "type": "orbital", "status": "construction", "pads": 3, "notes": "UK's first vertical launch site, Shetland Islands"},
|
||||
{"name": "Andøya Spaceport", "country": "Norway", "iso3": "NOR", "lat": 69.29, "lon": 16.02, "operator": "Andøya Space", "type": "orbital", "status": "active", "pads": 2, "notes": "Northernmost orbital-class spaceport, polar orbits"},
|
||||
# India
|
||||
{"name": "Satish Dhawan Space Centre (Sriharikota)", "country": "India", "iso3": "IND", "lat": 13.72, "lon": 80.23, "operator": "ISRO", "type": "orbital", "status": "active", "pads": 3, "notes": "Primary Indian launch site, GSLV, PSLV, LVM3, Gaganyaan"},
|
||||
{"name": "Kulasekarapattinam (Tamil Nadu)", "country": "India", "iso3": "IND", "lat": 8.56, "lon": 78.08, "operator": "ISRO", "type": "orbital", "status": "construction", "pads": 1, "notes": "Small satellite launch complex"},
|
||||
# Japan
|
||||
{"name": "Tanegashima Space Center", "country": "Japan", "iso3": "JPN", "lat": 30.40, "lon": 131.00, "operator": "JAXA", "type": "orbital", "status": "active", "pads": 2, "notes": "H-IIA/H3, southernmost Japanese island"},
|
||||
{"name": "Uchinoura Space Center", "country": "Japan", "iso3": "JPN", "lat": 31.25, "lon": 131.08, "operator": "JAXA", "type": "orbital", "status": "active", "pads": 2, "notes": "Epsilon rocket, sounding rockets"},
|
||||
# Others
|
||||
{"name": "Rocket Lab Launch Complex 1", "country": "New Zealand", "iso3": "NZL", "lat": -39.26, "lon": 177.86, "operator": "Rocket Lab", "type": "orbital", "status": "active", "pads": 2, "notes": "Electron launches, Mahia Peninsula"},
|
||||
{"name": "Semnan Space Center", "country": "Iran", "iso3": "IRN", "lat": 35.23, "lon": 53.92, "operator": "ISA / IRGC", "type": "orbital", "status": "active", "pads": 2, "notes": "Simorgh, Qased, dual-use ICBM concern"},
|
||||
{"name": "Sohae Satellite Launching Station", "country": "North Korea", "iso3": "PRK", "lat": 39.66, "lon": 124.71, "operator": "NADA", "type": "orbital", "status": "active", "pads": 1, "notes": "Unha/Chollima launches, ICBM test concern"},
|
||||
{"name": "Palmachim Airbase", "country": "Israel", "iso3": "ISR", "lat": 31.88, "lon": 34.69, "operator": "IAI / IMoD", "type": "orbital", "status": "active", "pads": 1, "notes": "Shavit launches (westward retrograde orbit)"},
|
||||
{"name": "Alcântara Launch Center", "country": "Brazil", "iso3": "BRA", "lat": -2.37, "lon": -44.40, "operator": "FAB / AEB", "type": "orbital", "status": "active", "pads": 2, "notes": "Near equator, US technology safeguards agreement"},
|
||||
]
|
||||
|
||||
|
||||
def query_spaceports(
|
||||
country: str | None = None,
|
||||
status: str | None = None,
|
||||
spaceport_type: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Filter spaceports by country, status, type, or operator."""
|
||||
results = []
|
||||
for sp in SPACEPORTS:
|
||||
if country:
|
||||
c = country.lower()
|
||||
if c not in (sp["country"].lower(), sp["iso3"].lower()):
|
||||
continue
|
||||
if status and sp["status"] != status.lower():
|
||||
continue
|
||||
if spaceport_type and sp["type"] != spaceport_type.lower():
|
||||
continue
|
||||
if operator:
|
||||
if operator.lower() not in sp["operator"].lower():
|
||||
continue
|
||||
results.append(sp)
|
||||
return results
|
||||
@@ -3,7 +3,7 @@
|
||||
World Intelligence MCP Server
|
||||
==============================
|
||||
|
||||
Real-time global intelligence across 23 domains:
|
||||
Real-time global intelligence across 30 domains:
|
||||
financial markets, economic indicators, earthquakes, wildfires,
|
||||
conflict, military flights, infrastructure, and more.
|
||||
|
||||
@@ -18,6 +18,9 @@ Phase 8: Service status monitoring, RSS expansion (80+ feeds, 14 categories) (+1
|
||||
Phase 9: Geospatial datasets — military bases, ports, pipelines, nuclear facilities (+4 = 60 tools).
|
||||
Phase 10: NLP intelligence — entity extraction, event classification, news clustering, keyword spikes (+4 = 64 tools).
|
||||
Phase 11: Strategic synthesis — strategic posture, world brief, fleet report, population exposure (+4 = 68 tools).
|
||||
Phase 12: Extended geospatial (cables, datacenters, spaceports, minerals, exchanges), country stocks,
|
||||
aircraft batch, Hacker News, GitHub trending, arXiv papers, USA spending,
|
||||
NASA EONET, GDACS disaster alerts (+14 = 82 tools).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -33,7 +36,7 @@ from mcp.types import Tool, TextContent
|
||||
from .cache import Cache
|
||||
from .circuit_breaker import CircuitBreaker
|
||||
from .fetcher import Fetcher
|
||||
from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, space_weather, ai_watch, health, sanctions, elections, shipping, social, nuclear, service_status, geospatial
|
||||
from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, space_weather, ai_watch, health, sanctions, elections, shipping, social, nuclear, service_status, geospatial, hacker_news, github_trending, arxiv_papers, usa_spending, environmental
|
||||
from .reports import generator as report_gen
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -713,10 +716,173 @@ TOOLS: list[Tool] = [
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- Extended Geospatial (5 tools) ---
|
||||
Tool(
|
||||
name="intel_undersea_cables",
|
||||
description="Query 30+ undersea fiber-optic cable routes with landing points, owners, capacity (Tbps), and length (km). Filterable by status, country, owner, min capacity.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string", "description": "Filter by status: active, planned, construction, decommissioned"},
|
||||
"country": {"type": "string", "description": "Filter by country in landing points"},
|
||||
"owner": {"type": "string", "description": "Filter by cable owner (Google, Meta, Microsoft, etc.)"},
|
||||
"min_capacity_tbps": {"type": "number", "description": "Minimum cable capacity in Tbps"},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="intel_ai_datacenters",
|
||||
description="Query 48+ AI datacenter clusters worldwide with power capacity (MW), operators, and locations. Covers hyperscalers and sovereign AI.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"country": {"type": "string", "description": "Filter by country name or ISO-3 code"},
|
||||
"operator": {"type": "string", "description": "Filter by operator (AWS, Google, Microsoft, Meta, etc.)"},
|
||||
"min_power_mw": {"type": "integer", "description": "Minimum power capacity in MW"},
|
||||
"region": {"type": "string", "description": "Filter by region (North America, Europe, Asia-Pacific, etc.)"},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="intel_spaceports",
|
||||
description="Query 27+ launch facilities and spaceports worldwide. Filterable by country, status, type (orbital/suborbital), and operator.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"country": {"type": "string", "description": "Filter by country name or ISO-3 code"},
|
||||
"status": {"type": "string", "description": "Filter by status: active, limited, planned, decommissioned"},
|
||||
"spaceport_type": {"type": "string", "description": "Filter by type: orbital, suborbital"},
|
||||
"operator": {"type": "string", "description": "Filter by operator (SpaceX, NASA, CNSA, Roscosmos, etc.)"},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="intel_critical_minerals",
|
||||
description="Query 28+ critical mineral deposits worldwide: lithium, cobalt, rare earths, nickel, copper, graphite, manganese, PGM, tungsten, uranium, tin, gallium, germanium.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mineral": {"type": "string", "description": "Filter by mineral (lithium, cobalt, rare_earths, nickel, copper, graphite, manganese, platinum_group, tungsten, uranium, tin, gallium, germanium)"},
|
||||
"country": {"type": "string", "description": "Filter by country name or ISO-3 code"},
|
||||
"mineral_type": {"type": "string", "description": "Filter by type: battery, electronic, structural, energy, industrial, strategic"},
|
||||
"operator": {"type": "string", "description": "Filter by operator"},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="intel_stock_exchanges",
|
||||
description="Query 80+ stock exchanges across 4 tiers (mega >$3T, major, emerging, frontier) with market cap, index tickers, currencies, timezones.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tier": {"type": "string", "description": "Filter by tier: mega, major, emerging, frontier"},
|
||||
"country": {"type": "string", "description": "Filter by country name or ISO-3 code"},
|
||||
"currency": {"type": "string", "description": "Filter by currency (USD, EUR, GBP, JPY, CNY, etc.)"},
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- Markets Extended (1 tool) ---
|
||||
Tool(
|
||||
name="intel_country_stocks",
|
||||
description="Get real-time stock index quote for any country by ISO-3 code. Maps country to its primary exchange index ticker and fetches via Yahoo Finance.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"country": {"type": "string", "description": "ISO-3 country code (USA, GBR, JPN, CHN, DEU, etc.)", "default": "USA"},
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- Military Extended (1 tool) ---
|
||||
Tool(
|
||||
name="intel_aircraft_batch",
|
||||
description="Batch lookup of aircraft details by ICAO24 hex codes (max 20). Returns registration, type, operator from hexdb.io.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"icao24_list": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of ICAO24 hex addresses (max 20)",
|
||||
},
|
||||
},
|
||||
"required": ["icao24_list"],
|
||||
},
|
||||
),
|
||||
# --- Tech & Science (3 tools) ---
|
||||
Tool(
|
||||
name="intel_hacker_news",
|
||||
description="Get top stories from Hacker News (Firebase API). Returns title, score, URL, author, comment count. Optional: limit (default 30).",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Number of stories (default 30, max 100)", "default": 30},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="intel_trending_repos",
|
||||
description="Get trending GitHub repositories (recently created, most starred). Optional: language filter, time window, limit.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {"type": "string", "description": "Programming language filter (python, rust, typescript, etc.)"},
|
||||
"since_days": {"type": "integer", "description": "Look back N days for new repos (default 7)", "default": 7},
|
||||
"limit": {"type": "integer", "description": "Number of repos (default 25)", "default": 25},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="intel_arxiv_papers",
|
||||
description="Search recent arXiv papers in AI/ML (cs.AI, cs.LG, cs.CL). Optional custom query. Returns title, authors, abstract, categories, PDF link.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "arXiv search query (default: cs.AI OR cs.LG OR cs.CL)"},
|
||||
"limit": {"type": "integer", "description": "Number of papers (default 25)", "default": 25},
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- Government (1 tool) ---
|
||||
Tool(
|
||||
name="intel_usa_spending",
|
||||
description="Federal agency spending data from USAspending.gov. Shows top agencies by budget for current fiscal year. Optional: agency filter.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agency": {"type": "string", "description": "Filter by agency name substring"},
|
||||
"limit": {"type": "integer", "description": "Number of agencies (default 25)", "default": 25},
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- Environmental (2 tools) ---
|
||||
Tool(
|
||||
name="intel_environmental_events",
|
||||
description="Natural events from NASA EONET: wildfires, severe storms, volcanoes, floods, icebergs, drought. Includes geolocation and source links.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {"type": "integer", "description": "Look back N days (default 30)", "default": 30},
|
||||
"category": {"type": "string", "description": "Filter by category: wildfires, severeStorms, volcanoes, floods, earthquakes, drought, seaLakeIce"},
|
||||
"limit": {"type": "integer", "description": "Max events (default 50)", "default": 50},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="intel_disaster_alerts",
|
||||
description="Global disaster alerts from GDACS (UN): earthquakes, floods, cyclones, droughts, wildfires. Severity levels (green/orange/red) with affected populations.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"alert_level": {"type": "string", "description": "Filter by level: green, orange, red"},
|
||||
"event_type": {"type": "string", "description": "Filter by type: EQ, FL, TC, DR, WF, VO"},
|
||||
"limit": {"type": "integer", "description": "Max alerts (default 30)", "default": 30},
|
||||
},
|
||||
},
|
||||
),
|
||||
# --- System (1 tool) ---
|
||||
Tool(
|
||||
name="intel_status",
|
||||
description="Get data source health, circuit breaker status, and cache statistics.",
|
||||
description="Get data source health, circuit breaker status, cache freshness, and statistics.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
]
|
||||
@@ -998,6 +1164,97 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
|
||||
event_types=arguments.get("event_types"),
|
||||
)
|
||||
|
||||
# Extended Geospatial
|
||||
case "intel_undersea_cables":
|
||||
return await geospatial.fetch_undersea_cables(
|
||||
status=arguments.get("status"),
|
||||
country=arguments.get("country"),
|
||||
owner=arguments.get("owner"),
|
||||
min_capacity_tbps=arguments.get("min_capacity_tbps"),
|
||||
)
|
||||
case "intel_ai_datacenters":
|
||||
return await geospatial.fetch_ai_datacenters(
|
||||
country=arguments.get("country"),
|
||||
operator=arguments.get("operator"),
|
||||
min_power_mw=arguments.get("min_power_mw"),
|
||||
region=arguments.get("region"),
|
||||
)
|
||||
case "intel_spaceports":
|
||||
return await geospatial.fetch_spaceports(
|
||||
country=arguments.get("country"),
|
||||
status=arguments.get("status"),
|
||||
spaceport_type=arguments.get("spaceport_type"),
|
||||
operator=arguments.get("operator"),
|
||||
)
|
||||
case "intel_critical_minerals":
|
||||
return await geospatial.fetch_critical_minerals(
|
||||
mineral=arguments.get("mineral"),
|
||||
country=arguments.get("country"),
|
||||
mineral_type=arguments.get("mineral_type"),
|
||||
operator=arguments.get("operator"),
|
||||
)
|
||||
case "intel_stock_exchanges":
|
||||
return await geospatial.fetch_stock_exchanges(
|
||||
tier=arguments.get("tier"),
|
||||
country=arguments.get("country"),
|
||||
currency=arguments.get("currency"),
|
||||
)
|
||||
|
||||
# Markets Extended
|
||||
case "intel_country_stocks":
|
||||
return await markets.fetch_country_stocks(
|
||||
fetcher, country=arguments.get("country", "USA"),
|
||||
)
|
||||
|
||||
# Military Extended
|
||||
case "intel_aircraft_batch":
|
||||
return await military.fetch_aircraft_details_batch(
|
||||
fetcher, icao24_list=arguments["icao24_list"],
|
||||
)
|
||||
|
||||
# Tech & Science
|
||||
case "intel_hacker_news":
|
||||
return await hacker_news.fetch_hacker_news(
|
||||
fetcher, limit=arguments.get("limit", 30),
|
||||
)
|
||||
case "intel_trending_repos":
|
||||
return await github_trending.fetch_trending_repos(
|
||||
fetcher,
|
||||
language=arguments.get("language"),
|
||||
since_days=arguments.get("since_days", 7),
|
||||
limit=arguments.get("limit", 25),
|
||||
)
|
||||
case "intel_arxiv_papers":
|
||||
return await arxiv_papers.fetch_arxiv_papers(
|
||||
fetcher,
|
||||
query=arguments.get("query"),
|
||||
limit=arguments.get("limit", 25),
|
||||
)
|
||||
|
||||
# Government
|
||||
case "intel_usa_spending":
|
||||
return await usa_spending.fetch_usa_spending(
|
||||
fetcher,
|
||||
agency=arguments.get("agency"),
|
||||
limit=arguments.get("limit", 25),
|
||||
)
|
||||
|
||||
# Environmental
|
||||
case "intel_environmental_events":
|
||||
return await environmental.fetch_environmental_events(
|
||||
fetcher,
|
||||
days=arguments.get("days", 30),
|
||||
category=arguments.get("category"),
|
||||
limit=arguments.get("limit", 50),
|
||||
)
|
||||
case "intel_disaster_alerts":
|
||||
return await environmental.fetch_disaster_alerts(
|
||||
fetcher,
|
||||
alert_level=arguments.get("alert_level"),
|
||||
event_type=arguments.get("event_type"),
|
||||
limit=arguments.get("limit", 30),
|
||||
)
|
||||
|
||||
# NLP Intelligence
|
||||
case "intel_extract_entities":
|
||||
from .analysis.entities import fetch_entity_extraction
|
||||
@@ -1026,6 +1283,7 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
|
||||
return {
|
||||
"circuit_breakers": breaker.status(),
|
||||
"cache": cache.stats(),
|
||||
"cache_freshness": cache.freshness(),
|
||||
"sources": {
|
||||
"markets": ["yahoo-finance", "coingecko", "alternative-me", "mempool"],
|
||||
"economic": ["eia", "fred", "world-bank"],
|
||||
@@ -1050,9 +1308,12 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
|
||||
"social": ["reddit-public"],
|
||||
"nuclear": ["usgs-nuclear-monitor"],
|
||||
"service_status": ["aws", "azure", "gcp", "cloudflare", "github"],
|
||||
"geospatial": ["static-datasets (bases, ports, pipelines, nuclear)"],
|
||||
"geospatial": ["static-datasets (bases, ports, pipelines, nuclear, cables, datacenters, spaceports, minerals, exchanges)"],
|
||||
"nlp": ["regex-ner", "keyword-classifier", "jaccard-clustering", "keyword-spike-detector"],
|
||||
"synthesis": ["strategic-posture", "world-brief", "fleet-report", "population-exposure"],
|
||||
"tech": ["hackernews", "github", "arxiv"],
|
||||
"government": ["usaspending-gov"],
|
||||
"environmental": ["eonet", "gdacs"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""arXiv recent papers source for world-intel-mcp.
|
||||
|
||||
Uses the arXiv API (no key required, rate limit ~3 req/s).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..fetcher import Fetcher
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.sources.arxiv_papers")
|
||||
|
||||
_ARXIV_API_URL = "http://export.arxiv.org/api/query"
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_arxiv_xml(xml_text: str) -> list[dict]:
|
||||
"""Parse arXiv Atom XML response into paper dicts.
|
||||
|
||||
Simple regex-based parsing to avoid lxml dependency.
|
||||
"""
|
||||
papers = []
|
||||
entries = re.findall(r"<entry>(.*?)</entry>", xml_text, re.DOTALL)
|
||||
|
||||
for entry in entries:
|
||||
title_match = re.search(r"<title>(.*?)</title>", entry, re.DOTALL)
|
||||
summary_match = re.search(r"<summary>(.*?)</summary>", entry, re.DOTALL)
|
||||
id_match = re.search(r"<id>(.*?)</id>", entry)
|
||||
published_match = re.search(r"<published>(.*?)</published>", entry)
|
||||
updated_match = re.search(r"<updated>(.*?)</updated>", entry)
|
||||
|
||||
# Extract authors
|
||||
authors = re.findall(r"<author>\s*<name>(.*?)</name>", entry)
|
||||
|
||||
# Extract categories
|
||||
categories = re.findall(r'<category[^>]*term="([^"]*)"', entry)
|
||||
|
||||
# Extract PDF link
|
||||
pdf_match = re.search(r'<link[^>]*title="pdf"[^>]*href="([^"]*)"', entry)
|
||||
|
||||
title = title_match.group(1).strip() if title_match else ""
|
||||
title = re.sub(r"\s+", " ", title) # collapse whitespace
|
||||
|
||||
summary = summary_match.group(1).strip() if summary_match else ""
|
||||
summary = re.sub(r"\s+", " ", summary)
|
||||
if len(summary) > 300:
|
||||
summary = summary[:300] + "..."
|
||||
|
||||
arxiv_id = id_match.group(1) if id_match else ""
|
||||
# Extract short ID from URL
|
||||
short_id = arxiv_id.split("/abs/")[-1] if "/abs/" in arxiv_id else arxiv_id
|
||||
|
||||
papers.append({
|
||||
"id": short_id,
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"authors": authors[:5],
|
||||
"categories": categories[:5],
|
||||
"published": published_match.group(1) if published_match else None,
|
||||
"updated": updated_match.group(1) if updated_match else None,
|
||||
"pdf_url": pdf_match.group(1) if pdf_match else None,
|
||||
"url": arxiv_id,
|
||||
})
|
||||
|
||||
return papers
|
||||
|
||||
|
||||
async def fetch_arxiv_papers(
|
||||
fetcher: Fetcher,
|
||||
query: str = "cat:cs.AI OR cat:cs.LG OR cat:cs.CL",
|
||||
limit: int = 25,
|
||||
sort_by: str = "submittedDate",
|
||||
) -> dict:
|
||||
"""Fetch recent papers from arXiv.
|
||||
|
||||
Args:
|
||||
fetcher: Shared HTTP fetcher.
|
||||
query: arXiv search query (default: AI/ML/NLP categories).
|
||||
limit: Number of papers (max 50).
|
||||
sort_by: Sort field — "submittedDate", "relevance", or "lastUpdatedDate".
|
||||
|
||||
Returns:
|
||||
Dict with papers[], count, source, timestamp.
|
||||
"""
|
||||
limit = min(limit, 50)
|
||||
|
||||
safe_query = re.sub(r"[^a-zA-Z0-9:._\s|()]", "", query)[:128]
|
||||
|
||||
xml_text = await fetcher.get_xml(
|
||||
_ARXIV_API_URL,
|
||||
source="arxiv",
|
||||
cache_key=f"arxiv:{safe_query}:{sort_by}:{limit}",
|
||||
cache_ttl=900,
|
||||
timeout=20.0,
|
||||
)
|
||||
|
||||
# get_xml doesn't support params, so construct URL manually
|
||||
# Actually, we need to use get_text with params
|
||||
if xml_text is None:
|
||||
# Try with full URL including params
|
||||
url = f"{_ARXIV_API_URL}?search_query={query}&sortBy={sort_by}&sortOrder=descending&max_results={limit}"
|
||||
xml_text = await fetcher.get_text(
|
||||
url,
|
||||
source="arxiv",
|
||||
cache_key=f"arxiv:{safe_query}:{sort_by}:{limit}",
|
||||
cache_ttl=900,
|
||||
timeout=20.0,
|
||||
)
|
||||
|
||||
if xml_text is None:
|
||||
return {"papers": [], "count": 0, "source": "arxiv", "timestamp": _utc_now_iso()}
|
||||
|
||||
papers = _parse_arxiv_xml(xml_text)
|
||||
|
||||
return {
|
||||
"papers": papers[:limit],
|
||||
"count": len(papers[:limit]),
|
||||
"query": query,
|
||||
"source": "arxiv",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Environmental events — NASA EONET and GDACS disaster alerts.
|
||||
|
||||
No API keys required. Public REST APIs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from ..fetcher import Fetcher
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.sources.environmental")
|
||||
|
||||
_EONET_EVENTS_URL = "https://eonet.gsfc.nasa.gov/api/v3/events"
|
||||
_GDACS_EVENTS_URL = "https://www.gdacs.org/gdacsapi/api/events/geteventlist/SEARCH"
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
async def fetch_environmental_events(
|
||||
fetcher: Fetcher,
|
||||
days: int = 30,
|
||||
category: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
"""Fetch active environmental events from NASA EONET.
|
||||
|
||||
Categories: wildfires, severeStorms, volcanoes, seaLakeIce, earthquakes,
|
||||
floods, landslides, drought, dustHaze, snow, tempExtremes, waterColor,
|
||||
manmade.
|
||||
|
||||
Args:
|
||||
fetcher: Shared HTTP fetcher.
|
||||
days: Lookback period in days.
|
||||
category: Optional category filter.
|
||||
limit: Max events to return.
|
||||
|
||||
Returns:
|
||||
Dict with events[], count, source, timestamp.
|
||||
"""
|
||||
params: dict[str, str] = {
|
||||
"days": str(days),
|
||||
"limit": str(min(limit, 100)),
|
||||
"status": "open",
|
||||
}
|
||||
if category:
|
||||
params["category"] = category
|
||||
|
||||
data = await fetcher.get_json(
|
||||
_EONET_EVENTS_URL,
|
||||
source="eonet",
|
||||
cache_key=f"eonet:events:{days}:{category or 'all'}",
|
||||
cache_ttl=600,
|
||||
params=params,
|
||||
)
|
||||
|
||||
if data is None or not isinstance(data, dict):
|
||||
return {"events": [], "count": 0, "source": "eonet", "timestamp": _utc_now_iso()}
|
||||
|
||||
events = []
|
||||
for event in data.get("events", []):
|
||||
categories = [c.get("title", "") for c in event.get("categories", [])]
|
||||
|
||||
# Get latest geometry point
|
||||
geometries = event.get("geometry", [])
|
||||
lat, lon = None, None
|
||||
if geometries:
|
||||
latest = geometries[-1]
|
||||
coords = latest.get("coordinates", [])
|
||||
if len(coords) >= 2:
|
||||
lon, lat = coords[0], coords[1]
|
||||
|
||||
events.append({
|
||||
"id": event.get("id"),
|
||||
"title": event.get("title"),
|
||||
"categories": categories,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"date": geometries[-1].get("date") if geometries else None,
|
||||
"sources": [s.get("url") for s in event.get("sources", [])],
|
||||
"closed": event.get("closed"),
|
||||
})
|
||||
|
||||
return {
|
||||
"events": events[:limit],
|
||||
"count": len(events[:limit]),
|
||||
"source": "eonet",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_disaster_alerts(
|
||||
fetcher: Fetcher,
|
||||
alert_level: str | None = None,
|
||||
event_type: str | None = None,
|
||||
limit: int = 30,
|
||||
) -> dict:
|
||||
"""Fetch global disaster alerts from GDACS.
|
||||
|
||||
Alert levels: Green, Orange, Red.
|
||||
Event types: EQ (earthquake), TC (tropical cyclone), FL (flood),
|
||||
VO (volcano), DR (drought), WF (wildfire).
|
||||
|
||||
Args:
|
||||
fetcher: Shared HTTP fetcher.
|
||||
alert_level: Filter by alert level (Green, Orange, Red).
|
||||
event_type: Filter by event type code.
|
||||
limit: Max alerts to return.
|
||||
|
||||
Returns:
|
||||
Dict with alerts[], count, source, timestamp.
|
||||
"""
|
||||
# GDACS API returns GeoJSON
|
||||
params: dict[str, str] = {
|
||||
"fromDate": (datetime.now(timezone.utc) - timedelta(days=30)).strftime("%Y-%m-%d"),
|
||||
"toDate": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||
"alertlevel": alert_level or "",
|
||||
"eventType": event_type or "",
|
||||
}
|
||||
# Remove empty params
|
||||
params = {k: v for k, v in params.items() if v}
|
||||
|
||||
data = await fetcher.get_json(
|
||||
_GDACS_EVENTS_URL,
|
||||
source="gdacs",
|
||||
cache_key=f"gdacs:alerts:{alert_level or 'all'}:{event_type or 'all'}",
|
||||
cache_ttl=600,
|
||||
params=params,
|
||||
)
|
||||
|
||||
if data is None or not isinstance(data, dict):
|
||||
return {"alerts": [], "count": 0, "source": "gdacs", "timestamp": _utc_now_iso()}
|
||||
|
||||
alerts = []
|
||||
features = data.get("features", [])
|
||||
for feature in features[:limit]:
|
||||
props = feature.get("properties", {})
|
||||
geometry = feature.get("geometry", {})
|
||||
coords = geometry.get("coordinates", [])
|
||||
lat = coords[1] if len(coords) >= 2 else None
|
||||
lon = coords[0] if len(coords) >= 2 else None
|
||||
|
||||
alerts.append({
|
||||
"event_id": props.get("eventid"),
|
||||
"event_type": props.get("eventtype"),
|
||||
"name": props.get("name") or props.get("eventname"),
|
||||
"alert_level": props.get("alertlevel"),
|
||||
"alert_score": props.get("alertscore"),
|
||||
"severity": props.get("severity", {}).get("severity_value") if isinstance(props.get("severity"), dict) else props.get("severity"),
|
||||
"country": props.get("country"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"from_date": props.get("fromdate"),
|
||||
"to_date": props.get("todate"),
|
||||
"url": props.get("url", {}).get("report") if isinstance(props.get("url"), dict) else None,
|
||||
"population_affected": props.get("population", {}).get("value") if isinstance(props.get("population"), dict) else None,
|
||||
})
|
||||
|
||||
return {
|
||||
"alerts": alerts,
|
||||
"count": len(alerts),
|
||||
"source": "gdacs",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
@@ -17,6 +17,11 @@ from ..config.geospatial import (
|
||||
query_pipelines,
|
||||
query_nuclear,
|
||||
)
|
||||
from ..config.cables import UNDERSEA_CABLES, query_cables
|
||||
from ..config.datacenters import AI_DATACENTERS, query_datacenters
|
||||
from ..config.spaceports import SPACEPORTS, query_spaceports
|
||||
from ..config.minerals import CRITICAL_MINERALS, query_minerals
|
||||
from ..config.exchanges import STOCK_EXCHANGES, query_exchanges
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
@@ -167,3 +172,126 @@ async def fetch_nuclear_facilities(
|
||||
"source": "static-geospatial",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_undersea_cables(
|
||||
status: str | None = None,
|
||||
country: str | None = None,
|
||||
owner: str | None = None,
|
||||
min_capacity_tbps: float | None = None,
|
||||
) -> dict:
|
||||
"""Query undersea cable routes with landing points."""
|
||||
cables = query_cables(status=status, country=country, owner=owner, min_capacity_tbps=min_capacity_tbps)
|
||||
total_length = sum(c["length_km"] for c in cables)
|
||||
total_capacity = sum(c["capacity_tbps"] for c in cables)
|
||||
by_status: dict[str, int] = {}
|
||||
for c in cables:
|
||||
by_status[c["status"]] = by_status.get(c["status"], 0) + 1
|
||||
return {
|
||||
"cables": cables,
|
||||
"count": len(cables),
|
||||
"total_in_database": len(UNDERSEA_CABLES),
|
||||
"total_length_km": total_length,
|
||||
"total_capacity_tbps": total_capacity,
|
||||
"by_status": by_status,
|
||||
"filters": {"status": status, "country": country, "owner": owner, "min_capacity_tbps": min_capacity_tbps},
|
||||
"source": "static-geospatial",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_ai_datacenters(
|
||||
country: str | None = None,
|
||||
operator: str | None = None,
|
||||
min_power_mw: int | None = None,
|
||||
region: str | None = None,
|
||||
) -> dict:
|
||||
"""Query AI datacenter clusters worldwide."""
|
||||
dcs = query_datacenters(country=country, operator=operator, min_power_mw=min_power_mw, region=region)
|
||||
total_power = sum(d["power_mw"] for d in dcs)
|
||||
by_country: dict[str, int] = {}
|
||||
for d in dcs:
|
||||
by_country[d["country"]] = by_country.get(d["country"], 0) + 1
|
||||
return {
|
||||
"datacenters": dcs,
|
||||
"count": len(dcs),
|
||||
"total_in_database": len(AI_DATACENTERS),
|
||||
"total_power_mw": total_power,
|
||||
"by_country": by_country,
|
||||
"filters": {"country": country, "operator": operator, "min_power_mw": min_power_mw, "region": region},
|
||||
"source": "static-geospatial",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_spaceports(
|
||||
country: str | None = None,
|
||||
status: str | None = None,
|
||||
spaceport_type: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> dict:
|
||||
"""Query launch facilities and spaceports worldwide."""
|
||||
sps = query_spaceports(country=country, status=status, spaceport_type=spaceport_type, operator=operator)
|
||||
by_country: dict[str, int] = {}
|
||||
by_type: dict[str, int] = {}
|
||||
for s in sps:
|
||||
by_country[s["country"]] = by_country.get(s["country"], 0) + 1
|
||||
by_type[s["type"]] = by_type.get(s["type"], 0) + 1
|
||||
return {
|
||||
"spaceports": sps,
|
||||
"count": len(sps),
|
||||
"total_in_database": len(SPACEPORTS),
|
||||
"by_country": by_country,
|
||||
"by_type": by_type,
|
||||
"filters": {"country": country, "status": status, "spaceport_type": spaceport_type, "operator": operator},
|
||||
"source": "static-geospatial",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_critical_minerals(
|
||||
mineral: str | None = None,
|
||||
country: str | None = None,
|
||||
mineral_type: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> dict:
|
||||
"""Query critical mineral deposits worldwide."""
|
||||
deposits = query_minerals(mineral=mineral, country=country, mineral_type=mineral_type, operator=operator)
|
||||
by_mineral: dict[str, int] = {}
|
||||
by_country: dict[str, int] = {}
|
||||
for d in deposits:
|
||||
by_mineral[d["mineral"]] = by_mineral.get(d["mineral"], 0) + 1
|
||||
by_country[d["country"]] = by_country.get(d["country"], 0) + 1
|
||||
return {
|
||||
"deposits": deposits,
|
||||
"count": len(deposits),
|
||||
"total_in_database": len(CRITICAL_MINERALS),
|
||||
"by_mineral": by_mineral,
|
||||
"by_country": by_country,
|
||||
"filters": {"mineral": mineral, "country": country, "mineral_type": mineral_type, "operator": operator},
|
||||
"source": "static-geospatial",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_stock_exchanges(
|
||||
tier: str | None = None,
|
||||
country: str | None = None,
|
||||
currency: str | None = None,
|
||||
) -> dict:
|
||||
"""Query global stock exchanges (92 exchanges, 4 tiers)."""
|
||||
exs = query_exchanges(tier=tier, country=country, currency=currency)
|
||||
total_mcap = sum(e["market_cap_usd_t"] for e in exs)
|
||||
by_tier: dict[str, int] = {}
|
||||
for e in exs:
|
||||
by_tier[e["tier"]] = by_tier.get(e["tier"], 0) + 1
|
||||
return {
|
||||
"exchanges": exs,
|
||||
"count": len(exs),
|
||||
"total_in_database": len(STOCK_EXCHANGES),
|
||||
"total_market_cap_usd_t": round(total_mcap, 2),
|
||||
"by_tier": by_tier,
|
||||
"filters": {"tier": tier, "country": country, "currency": currency},
|
||||
"source": "static-geospatial",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""GitHub trending repositories source for world-intel-mcp.
|
||||
|
||||
Uses the GitHub search API (no auth required, 10 req/min rate limit).
|
||||
Approximates "trending" by searching recently created repos with high stars.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from ..fetcher import Fetcher
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.sources.github_trending")
|
||||
|
||||
_GH_SEARCH_URL = "https://api.github.com/search/repositories"
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
async def fetch_trending_repos(
|
||||
fetcher: Fetcher,
|
||||
language: str | None = None,
|
||||
since_days: int = 7,
|
||||
limit: int = 25,
|
||||
) -> dict:
|
||||
"""Fetch trending GitHub repositories by recent star activity.
|
||||
|
||||
Args:
|
||||
fetcher: Shared HTTP fetcher.
|
||||
language: Optional language filter (e.g., "python", "rust").
|
||||
since_days: Look back period in days (default 7).
|
||||
limit: Number of repos to return (max 50).
|
||||
|
||||
Returns:
|
||||
Dict with repos[], count, source, timestamp.
|
||||
"""
|
||||
limit = min(limit, 50)
|
||||
since_date = (datetime.now(timezone.utc) - timedelta(days=since_days)).strftime("%Y-%m-%d")
|
||||
|
||||
query = f"created:>{since_date} stars:>10"
|
||||
if language:
|
||||
query += f" language:{language}"
|
||||
|
||||
data = await fetcher.get_json(
|
||||
_GH_SEARCH_URL,
|
||||
source="github",
|
||||
cache_key=f"github:trending:{language or 'all'}:{since_days}",
|
||||
cache_ttl=600,
|
||||
params={
|
||||
"q": query,
|
||||
"sort": "stars",
|
||||
"order": "desc",
|
||||
"per_page": str(limit),
|
||||
},
|
||||
headers={"Accept": "application/vnd.github.v3+json"},
|
||||
)
|
||||
|
||||
if data is None or not isinstance(data, dict):
|
||||
return {"repos": [], "count": 0, "source": "github", "timestamp": _utc_now_iso()}
|
||||
|
||||
repos = []
|
||||
for item in data.get("items", [])[:limit]:
|
||||
repos.append({
|
||||
"name": item.get("full_name"),
|
||||
"description": (item.get("description") or "")[:200],
|
||||
"url": item.get("html_url"),
|
||||
"stars": item.get("stargazers_count", 0),
|
||||
"forks": item.get("forks_count", 0),
|
||||
"language": item.get("language"),
|
||||
"created_at": item.get("created_at"),
|
||||
"topics": item.get("topics", [])[:5],
|
||||
})
|
||||
|
||||
return {
|
||||
"repos": repos,
|
||||
"count": len(repos),
|
||||
"total_matching": data.get("total_count", 0),
|
||||
"query": query,
|
||||
"source": "github",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Hacker News top stories source for world-intel-mcp.
|
||||
|
||||
Uses the official HN Firebase API (no API key required).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..fetcher import Fetcher
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.sources.hacker_news")
|
||||
|
||||
_HN_TOP_URL = "https://hacker-news.firebaseio.com/v0/topstories.json"
|
||||
_HN_ITEM_URL = "https://hacker-news.firebaseio.com/v0/item/{item_id}.json"
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
async def fetch_hacker_news(
|
||||
fetcher: Fetcher,
|
||||
limit: int = 30,
|
||||
) -> dict:
|
||||
"""Fetch top Hacker News stories.
|
||||
|
||||
Args:
|
||||
fetcher: Shared HTTP fetcher.
|
||||
limit: Number of top stories to return (max 50).
|
||||
|
||||
Returns:
|
||||
Dict with stories[], count, source, timestamp.
|
||||
"""
|
||||
limit = min(limit, 50)
|
||||
|
||||
top_ids = await fetcher.get_json(
|
||||
_HN_TOP_URL,
|
||||
source="hackernews",
|
||||
cache_key="hn:top_ids",
|
||||
cache_ttl=300,
|
||||
)
|
||||
|
||||
if top_ids is None or not isinstance(top_ids, list):
|
||||
return {"stories": [], "count": 0, "source": "hackernews", "timestamp": _utc_now_iso()}
|
||||
|
||||
top_ids = top_ids[:limit]
|
||||
|
||||
async def _fetch_item(item_id: int) -> dict | None:
|
||||
url = _HN_ITEM_URL.format(item_id=item_id)
|
||||
data = await fetcher.get_json(
|
||||
url,
|
||||
source="hackernews",
|
||||
cache_key=f"hn:item:{item_id}",
|
||||
cache_ttl=600,
|
||||
)
|
||||
if data is None or not isinstance(data, dict):
|
||||
return None
|
||||
return {
|
||||
"id": data.get("id"),
|
||||
"title": data.get("title", ""),
|
||||
"url": data.get("url", ""),
|
||||
"score": data.get("score", 0),
|
||||
"by": data.get("by", ""),
|
||||
"descendants": data.get("descendants", 0),
|
||||
"time": data.get("time"),
|
||||
"type": data.get("type", "story"),
|
||||
}
|
||||
|
||||
tasks = [_fetch_item(item_id) for item_id in top_ids]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
stories = [s for s in results if s is not None]
|
||||
stories.sort(key=lambda s: s["score"], reverse=True)
|
||||
|
||||
return {
|
||||
"stories": stories,
|
||||
"count": len(stories),
|
||||
"source": "hackernews",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
@@ -394,6 +394,58 @@ async def _fetch_btc_dominance(fetcher: Fetcher) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_country_stocks(
|
||||
fetcher: Fetcher,
|
||||
country: str = "USA",
|
||||
) -> dict:
|
||||
"""Fetch the primary stock index for a given country.
|
||||
|
||||
Uses the COUNTRY_INDEX_TICKERS mapping from config/exchanges.py.
|
||||
|
||||
Args:
|
||||
fetcher: Shared HTTP fetcher.
|
||||
country: ISO-3 country code (e.g., "USA", "GBR", "JPN", "DEU").
|
||||
|
||||
Returns:
|
||||
Dict with index quote, country, exchange info, source, timestamp.
|
||||
"""
|
||||
from ..config.exchanges import COUNTRY_INDEX_TICKERS, STOCK_EXCHANGES
|
||||
|
||||
country_upper = country.upper()
|
||||
ticker = COUNTRY_INDEX_TICKERS.get(country_upper)
|
||||
if ticker is None:
|
||||
return {
|
||||
"error": f"No index ticker mapped for country '{country_upper}'",
|
||||
"available_countries": sorted(COUNTRY_INDEX_TICKERS.keys()),
|
||||
"source": "yahoo-finance",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
quote = await _fetch_yahoo_quote(fetcher, ticker, f"markets:country:{country_upper}", 300)
|
||||
|
||||
# Find exchange info
|
||||
exchange_info = None
|
||||
for ex in STOCK_EXCHANGES:
|
||||
if ex.get("index") == ticker:
|
||||
exchange_info = {
|
||||
"name": ex["name"],
|
||||
"acronym": ex["acronym"],
|
||||
"city": ex["city"],
|
||||
"currency": ex["currency"],
|
||||
"tier": ex["tier"],
|
||||
}
|
||||
break
|
||||
|
||||
return {
|
||||
"country": country_upper,
|
||||
"ticker": ticker,
|
||||
"quote": quote,
|
||||
"exchange": exchange_info,
|
||||
"source": "yahoo-finance",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_macro_signals(fetcher: Fetcher) -> dict:
|
||||
"""Aggregate 7 macro signals into a single dashboard payload.
|
||||
|
||||
|
||||
@@ -337,6 +337,39 @@ async def fetch_theater_posture(fetcher: Fetcher) -> dict:
|
||||
return response
|
||||
|
||||
|
||||
async def fetch_aircraft_details_batch(fetcher: Fetcher, icao24_list: list[str]) -> dict:
|
||||
"""Look up multiple aircraft by ICAO24 hex codes in parallel.
|
||||
|
||||
Args:
|
||||
fetcher: Shared HTTP fetcher.
|
||||
icao24_list: List of ICAO24 hex addresses (max 20).
|
||||
|
||||
Returns:
|
||||
Dict with aircraft[] details, count, source, timestamp.
|
||||
"""
|
||||
icao24_list = icao24_list[:20] # cap at 20
|
||||
|
||||
async def _lookup(icao: str) -> dict | None:
|
||||
result = await fetch_aircraft_details(fetcher, icao)
|
||||
ac = result.get("aircraft", {})
|
||||
if not ac:
|
||||
return None
|
||||
ac["icao24"] = icao
|
||||
return ac
|
||||
|
||||
tasks = [_lookup(icao) for icao in icao24_list]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
aircraft = [a for a in results if a is not None]
|
||||
return {
|
||||
"aircraft": aircraft,
|
||||
"count": len(aircraft),
|
||||
"requested": len(icao24_list),
|
||||
"source": "hexdb",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_aircraft_details(fetcher: Fetcher, icao24: str) -> dict:
|
||||
"""Look up detailed aircraft information from hexdb.io (free, no API key).
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
|
||||
"latin_america": [
|
||||
("MercoPress", "https://en.mercopress.com/rss"),
|
||||
("Dialogo Americas", "https://dialogo-americas.com/feed/"),
|
||||
("Americas Quarterly", "https://www.americasquarterly.org/feed/"),
|
||||
("Buenos Aires Times", "https://www.batimes.com.ar/feed"),
|
||||
("Tico Times", "https://ticotimes.net/feed"),
|
||||
],
|
||||
"energy": [
|
||||
("Oil Price", "https://oilprice.com/rss/main"),
|
||||
@@ -284,6 +287,7 @@ async def fetch_news_feed(
|
||||
source=f"rss:{safe_name}",
|
||||
cache_key=f"news:rss:{safe_name}",
|
||||
cache_ttl=300,
|
||||
timeout=8.0,
|
||||
)
|
||||
|
||||
if xml_text is None:
|
||||
@@ -308,15 +312,25 @@ async def fetch_news_feed(
|
||||
|
||||
return items
|
||||
|
||||
# Fetch all feeds within each category in parallel
|
||||
for cat, feeds in categories_to_fetch.items():
|
||||
tasks = [
|
||||
_fetch_single_feed(feed_name, url, cat)
|
||||
for feed_name, url in feeds
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
for items in results:
|
||||
all_items.extend(items)
|
||||
async def _safe_fetch(feed_name: str, url: str, cat: str) -> list[dict]:
|
||||
"""Wrap single feed fetch with a hard timeout."""
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
_fetch_single_feed(feed_name, url, cat), timeout=12.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("RSS feed %s timed out", feed_name)
|
||||
return []
|
||||
|
||||
# Fetch ALL feeds across ALL categories in one parallel batch
|
||||
all_tasks = [
|
||||
_safe_fetch(feed_name, url, cat)
|
||||
for cat, feeds in categories_to_fetch.items()
|
||||
for feed_name, url in feeds
|
||||
]
|
||||
results = await asyncio.gather(*all_tasks)
|
||||
for items in results:
|
||||
all_items.extend(items)
|
||||
|
||||
# Sort by published date descending (entries without dates go last)
|
||||
all_items.sort(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""US Federal spending data from USAspending.gov API.
|
||||
|
||||
No API key required. Public REST API.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..fetcher import Fetcher
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.sources.usa_spending")
|
||||
|
||||
_SPENDING_BASE = "https://api.usaspending.gov/api/v2"
|
||||
_SPENDING_OVERVIEW_URL = f"{_SPENDING_BASE}/agency/list/"
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
async def fetch_usa_spending(
|
||||
fetcher: Fetcher,
|
||||
agency: str | None = None,
|
||||
limit: int = 25,
|
||||
) -> dict:
|
||||
"""Fetch federal agency spending overview from USAspending.gov.
|
||||
|
||||
Args:
|
||||
fetcher: Shared HTTP fetcher.
|
||||
agency: Optional agency name filter.
|
||||
limit: Number of agencies to return.
|
||||
|
||||
Returns:
|
||||
Dict with agencies[], total_budget, source, timestamp.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
fiscal_year = now.year if now.month >= 10 else now.year - 1
|
||||
|
||||
data = await fetcher.get_json(
|
||||
_SPENDING_OVERVIEW_URL,
|
||||
source="usaspending",
|
||||
cache_key=f"spending:agencies:{fiscal_year}",
|
||||
cache_ttl=3600,
|
||||
params={"sort": "obligated_amount", "order": "desc"},
|
||||
)
|
||||
|
||||
if data is None or not isinstance(data, dict):
|
||||
return {"agencies": [], "count": 0, "fiscal_year": fiscal_year, "source": "usaspending", "timestamp": _utc_now_iso()}
|
||||
|
||||
agencies = []
|
||||
for item in data.get("results", []):
|
||||
name = item.get("agency_name", "")
|
||||
if agency and agency.lower() not in name.lower():
|
||||
continue
|
||||
agencies.append({
|
||||
"name": name,
|
||||
"abbreviation": item.get("abbreviation", ""),
|
||||
"obligated_amount": item.get("obligated_amount"),
|
||||
"budget_authority": item.get("budget_authority_amount"),
|
||||
"percentage_of_total": item.get("percentage_of_total_budget_authority"),
|
||||
})
|
||||
|
||||
agencies = agencies[:limit]
|
||||
total = sum(a["obligated_amount"] or 0 for a in agencies)
|
||||
|
||||
return {
|
||||
"agencies": agencies,
|
||||
"count": len(agencies),
|
||||
"total_obligated": total,
|
||||
"fiscal_year": fiscal_year,
|
||||
"source": "usaspending",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
@@ -587,3 +587,322 @@ async def test_fetch_ucdp_events(fetcher: Fetcher) -> None:
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["country"] == "Ukraine"
|
||||
assert result["total_fatalities_best"] == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hacker News
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_hacker_news(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.hacker_news import fetch_hacker_news
|
||||
|
||||
respx.get("https://hacker-news.firebaseio.com/v0/topstories.json").mock(
|
||||
return_value=httpx.Response(200, json=[101, 102])
|
||||
)
|
||||
respx.get("https://hacker-news.firebaseio.com/v0/item/101.json").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"id": 101, "title": "Show HN: AI Tool", "url": "https://example.com",
|
||||
"score": 200, "by": "user1", "time": 1700000000, "descendants": 50,
|
||||
})
|
||||
)
|
||||
respx.get("https://hacker-news.firebaseio.com/v0/item/102.json").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"id": 102, "title": "Rust 2.0", "url": "https://example.com/rust",
|
||||
"score": 150, "by": "user2", "time": 1700001000, "descendants": 30,
|
||||
})
|
||||
)
|
||||
|
||||
result = await fetch_hacker_news(fetcher, limit=2)
|
||||
assert result["source"] == "hackernews"
|
||||
assert result["count"] == 2
|
||||
assert result["stories"][0]["score"] >= result["stories"][1]["score"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHub Trending
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_trending_repos(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.github_trending import fetch_trending_repos
|
||||
|
||||
respx.get(url__regex=r".*api\.github\.com/search/repositories.*").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"total_count": 1,
|
||||
"items": [{
|
||||
"full_name": "user/cool-repo",
|
||||
"description": "A cool tool",
|
||||
"html_url": "https://github.com/user/cool-repo",
|
||||
"stargazers_count": 500,
|
||||
"forks_count": 20,
|
||||
"language": "Python",
|
||||
"created_at": "2026-02-20T00:00:00Z",
|
||||
"topics": ["ai", "ml"],
|
||||
}],
|
||||
})
|
||||
)
|
||||
|
||||
result = await fetch_trending_repos(fetcher, limit=5)
|
||||
assert result["source"] == "github"
|
||||
assert result["count"] == 1
|
||||
assert result["repos"][0]["name"] == "user/cool-repo"
|
||||
assert result["repos"][0]["stars"] == 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# arXiv Papers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_arxiv_papers(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.arxiv_papers import fetch_arxiv_papers
|
||||
|
||||
arxiv_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/2602.12345v1</id>
|
||||
<title>Attention Is Still All You Need</title>
|
||||
<summary>We show transformers continue to dominate.</summary>
|
||||
<author><name>Jane Doe</name></author>
|
||||
<author><name>John Smith</name></author>
|
||||
<published>2026-02-20T00:00:00Z</published>
|
||||
<link href="http://arxiv.org/abs/2602.12345v1" rel="alternate"/>
|
||||
<link href="http://arxiv.org/pdf/2602.12345v1" title="pdf" rel="related"/>
|
||||
<category term="cs.AI"/>
|
||||
<category term="cs.LG"/>
|
||||
</entry>
|
||||
</feed>"""
|
||||
|
||||
respx.get(url__regex=r".*export\.arxiv\.org/api/query.*").mock(
|
||||
return_value=httpx.Response(200, text=arxiv_xml)
|
||||
)
|
||||
|
||||
result = await fetch_arxiv_papers(fetcher, limit=5)
|
||||
assert result["source"] == "arxiv"
|
||||
assert result["count"] == 1
|
||||
assert "Attention" in result["papers"][0]["title"]
|
||||
assert len(result["papers"][0]["authors"]) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# USA Spending
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_usa_spending(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.usa_spending import fetch_usa_spending
|
||||
|
||||
respx.get(url__regex=r".*api\.usaspending\.gov.*").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"results": [{
|
||||
"agency_name": "Department of Defense",
|
||||
"abbreviation": "DOD",
|
||||
"current_total_budget_authority_amount": 850000000000,
|
||||
"obligated_amount": 700000000000,
|
||||
"outlay_amount": 650000000000,
|
||||
"agency_id": 97,
|
||||
}],
|
||||
"page_metadata": {"total": 1},
|
||||
})
|
||||
)
|
||||
|
||||
result = await fetch_usa_spending(fetcher, limit=5)
|
||||
assert result["source"] == "usaspending"
|
||||
assert result["count"] == 1
|
||||
assert result["agencies"][0]["name"] == "Department of Defense"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environmental Events (NASA EONET)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_environmental_events(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.environmental import fetch_environmental_events
|
||||
|
||||
respx.get(url__regex=r".*eonet\.gsfc\.nasa\.gov.*").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"events": [{
|
||||
"id": "EONET_1234",
|
||||
"title": "Wildfire in California",
|
||||
"categories": [{"id": "wildfires", "title": "Wildfires"}],
|
||||
"sources": [{"id": "InciWeb", "url": "https://inciweb.example.com"}],
|
||||
"geometry": [{"date": "2026-02-20T00:00:00Z", "coordinates": [-119.5, 34.5]}],
|
||||
}],
|
||||
})
|
||||
)
|
||||
|
||||
result = await fetch_environmental_events(fetcher, days=7)
|
||||
assert result["source"] == "eonet"
|
||||
assert result["count"] == 1
|
||||
assert "Wildfire" in result["events"][0]["title"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disaster Alerts (GDACS)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_disaster_alerts(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.environmental import fetch_disaster_alerts
|
||||
|
||||
gdacs_geojson = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"eventtype": "EQ",
|
||||
"eventname": "M6.5 Earthquake",
|
||||
"alertlevel": "orange",
|
||||
"alertscore": 2.5,
|
||||
"severity": {"value": 6.5, "unit": "M"},
|
||||
"country": "Turkey",
|
||||
"fromdate": "2026-02-20T12:00:00Z",
|
||||
"todate": "2026-02-20T12:05:00Z",
|
||||
"url": {"report": "https://gdacs.example.com/report"},
|
||||
"population": {"value": 500000},
|
||||
},
|
||||
"geometry": {"type": "Point", "coordinates": [29.0, 38.5]},
|
||||
}],
|
||||
}
|
||||
|
||||
respx.get(url__regex=r".*gdacs\.org.*").mock(
|
||||
return_value=httpx.Response(200, json=gdacs_geojson)
|
||||
)
|
||||
|
||||
result = await fetch_disaster_alerts(fetcher)
|
||||
assert result["source"] == "gdacs"
|
||||
assert result["count"] == 1
|
||||
assert result["alerts"][0]["event_type"] == "EQ"
|
||||
assert result["alerts"][0]["alert_level"] == "orange"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Geospatial — Extended datasets (static, no HTTP mock needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_undersea_cables() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_undersea_cables
|
||||
|
||||
result = await fetch_undersea_cables()
|
||||
assert result["count"] > 0
|
||||
assert result["source"] == "static-geospatial"
|
||||
assert "total_length_km" in result
|
||||
assert "total_capacity_tbps" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_ai_datacenters() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_ai_datacenters
|
||||
|
||||
result = await fetch_ai_datacenters(country="USA")
|
||||
assert result["count"] > 0
|
||||
assert result["source"] == "static-geospatial"
|
||||
assert "total_power_mw" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_spaceports() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_spaceports
|
||||
|
||||
result = await fetch_spaceports()
|
||||
assert result["count"] > 0
|
||||
assert result["source"] == "static-geospatial"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_critical_minerals() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_critical_minerals
|
||||
|
||||
result = await fetch_critical_minerals(mineral="lithium")
|
||||
assert result["count"] > 0
|
||||
assert all(d["mineral"] == "lithium" for d in result["deposits"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_stock_exchanges() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_stock_exchanges
|
||||
|
||||
result = await fetch_stock_exchanges(tier="mega")
|
||||
assert result["count"] > 0
|
||||
assert all(e["tier"] == "mega" for e in result["exchanges"])
|
||||
assert "total_market_cap_usd_t" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Country Stocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_country_stocks(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.markets import fetch_country_stocks
|
||||
|
||||
chart_response = {
|
||||
"chart": {
|
||||
"result": [{
|
||||
"meta": {
|
||||
"symbol": "^GSPC",
|
||||
"regularMarketPrice": 5200.0,
|
||||
"regularMarketChangePercent": 0.75,
|
||||
"currency": "USD",
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
respx.get(url__regex=r".*query1\.finance\.yahoo\.com.*").mock(
|
||||
return_value=httpx.Response(200, json=chart_response)
|
||||
)
|
||||
|
||||
result = await fetch_country_stocks(fetcher, country="USA")
|
||||
assert result["source"] == "yahoo-finance"
|
||||
assert result["country"] == "USA"
|
||||
assert "quote" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aircraft Batch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_aircraft_details_batch(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.military import fetch_aircraft_details_batch
|
||||
|
||||
respx.get(url__regex=r".*hexdb\.io/api/v1/aircraft/ae1234.*").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"Registration": "12-3456",
|
||||
"Type": "C-17A",
|
||||
"Operator": "USAF",
|
||||
})
|
||||
)
|
||||
respx.get(url__regex=r".*hexdb\.io/api/v1/aircraft/ae5678.*").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"Registration": "78-9012",
|
||||
"Type": "KC-135R",
|
||||
"Operator": "USAF",
|
||||
})
|
||||
)
|
||||
|
||||
result = await fetch_aircraft_details_batch(fetcher, icao24_list=["ae1234", "ae5678"])
|
||||
assert result["source"] == "hexdb"
|
||||
assert result["count"] == 2
|
||||
assert result["requested"] == 2
|
||||
|
||||
Reference in New Issue
Block a user