New MCP server providing real-time global intelligence: financial markets (Yahoo Finance, CoinGecko), economic indicators (FRED, EIA, World Bank), conflict tracking (ACLED, UCDP, HDX), military flights (OpenSky), infrastructure monitoring (Cloudflare Radar, NGA cable health), maritime warnings (NGA), climate anomalies (Open-Meteo), news aggregation (RSS, GDELT), prediction markets (Polymarket), displacement data (UNHCR), aviation delays (FAA), cyber threats (Feodo, CISA KEV, SANS, URLhaus), country intelligence briefs (Ollama LLM), and HTML report generation (Jinja2 + Chart.js). Includes: SQLite TTL cache, per-source circuit breakers, async HTTP fetcher with retry/rate-limiting, Click CLI with 27 commands, 4 analysis modules (instability scoring, geo-convergence, signal aggregation, news clustering), 4 HTML report templates, and 28 unit tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
170 lines
5.2 KiB
Python
170 lines
5.2 KiB
Python
"""UNHCR displacement data source for world-intel-mcp.
|
|
|
|
Provides refugee and displacement population statistics from the UNHCR
|
|
Population Statistics API. No API key required.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from ..fetcher import Fetcher
|
|
|
|
logger = logging.getLogger("world-intel-mcp.sources.displacement")
|
|
|
|
_UNHCR_POPULATION_URL = "https://api.unhcr.org/population/v1/population/"
|
|
|
|
|
|
async def fetch_displacement_summary(
|
|
fetcher: Fetcher,
|
|
year: int | None = None,
|
|
) -> dict:
|
|
"""Fetch refugee/displacement population statistics from the UNHCR API.
|
|
|
|
Aggregates displacement data by country of origin and computes global
|
|
totals across refugees, asylum seekers, IDPs, stateless persons, and
|
|
others of concern.
|
|
|
|
Args:
|
|
fetcher: Shared HTTP fetcher with caching and circuit breaking.
|
|
year: Reporting year to query. Defaults to the previous calendar
|
|
year since UNHCR data typically lags by one year.
|
|
|
|
Returns:
|
|
Dict with top-30 countries by displacement (sorted descending),
|
|
global totals, year, source, and timestamp.
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# Default to previous year since UNHCR data lags
|
|
if year is None:
|
|
year = now.year - 1
|
|
else:
|
|
# Ensure year is an int even if passed as string
|
|
year = int(year)
|
|
|
|
params = {
|
|
"limit": 100,
|
|
"yearFrom": year,
|
|
"yearTo": year,
|
|
"cf_type": "ISO",
|
|
}
|
|
|
|
data = await fetcher.get_json(
|
|
url=_UNHCR_POPULATION_URL,
|
|
source="unhcr",
|
|
cache_key=f"displacement:unhcr:{year}",
|
|
cache_ttl=43200,
|
|
params=params,
|
|
)
|
|
|
|
if data is None:
|
|
logger.warning("UNHCR API returned no data")
|
|
return {
|
|
"by_origin": [],
|
|
"global_totals": {
|
|
"total_refugees": 0,
|
|
"total_asylum_seekers": 0,
|
|
"total_idps": 0,
|
|
"total_stateless": 0,
|
|
"total_ooc": 0,
|
|
"grand_total": 0,
|
|
},
|
|
"year": year,
|
|
"count": 0,
|
|
"source": "unhcr",
|
|
"timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
}
|
|
|
|
items = data.get("items", [])
|
|
|
|
# Aggregate by country of origin (coo_name)
|
|
by_country: dict[str, dict[str, int]] = {}
|
|
|
|
for item in items:
|
|
coo_name = item.get("coo_name") or "Unknown"
|
|
|
|
refugees = _safe_int(item.get("refugees"))
|
|
asylum_seekers = _safe_int(item.get("asylum_seekers"))
|
|
idps = _safe_int(item.get("idps"))
|
|
stateless = _safe_int(item.get("stateless"))
|
|
ooc = _safe_int(item.get("ooc"))
|
|
|
|
if coo_name not in by_country:
|
|
by_country[coo_name] = {
|
|
"refugees": 0,
|
|
"asylum_seekers": 0,
|
|
"idps": 0,
|
|
"stateless": 0,
|
|
"ooc": 0,
|
|
}
|
|
|
|
entry = by_country[coo_name]
|
|
entry["refugees"] += refugees
|
|
entry["asylum_seekers"] += asylum_seekers
|
|
entry["idps"] += idps
|
|
entry["stateless"] += stateless
|
|
entry["ooc"] += ooc
|
|
|
|
# Build sorted list with total_displaced
|
|
by_origin = []
|
|
for country, totals in by_country.items():
|
|
total_displaced = (
|
|
totals["refugees"]
|
|
+ totals["asylum_seekers"]
|
|
+ totals["idps"]
|
|
+ totals["stateless"]
|
|
+ totals["ooc"]
|
|
)
|
|
by_origin.append({
|
|
"country": country,
|
|
"refugees": totals["refugees"],
|
|
"asylum_seekers": totals["asylum_seekers"],
|
|
"internally_displaced": totals["idps"],
|
|
"stateless": totals["stateless"],
|
|
"others_of_concern": totals["ooc"],
|
|
"total_displaced": total_displaced,
|
|
})
|
|
|
|
# Sort by total_displaced descending, take top 30
|
|
by_origin.sort(key=lambda e: e["total_displaced"], reverse=True)
|
|
by_origin = by_origin[:30]
|
|
|
|
# Compute global totals across all countries (not just top 30)
|
|
global_refugees = sum(t["refugees"] for t in by_country.values())
|
|
global_asylum_seekers = sum(t["asylum_seekers"] for t in by_country.values())
|
|
global_idps = sum(t["idps"] for t in by_country.values())
|
|
global_stateless = sum(t["stateless"] for t in by_country.values())
|
|
global_ooc = sum(t["ooc"] for t in by_country.values())
|
|
|
|
return {
|
|
"by_origin": by_origin,
|
|
"global_totals": {
|
|
"total_refugees": global_refugees,
|
|
"total_asylum_seekers": global_asylum_seekers,
|
|
"total_idps": global_idps,
|
|
"total_stateless": global_stateless,
|
|
"total_ooc": global_ooc,
|
|
"grand_total": (
|
|
global_refugees
|
|
+ global_asylum_seekers
|
|
+ global_idps
|
|
+ global_stateless
|
|
+ global_ooc
|
|
),
|
|
},
|
|
"year": year,
|
|
"count": len(by_origin),
|
|
"source": "unhcr",
|
|
"timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
}
|
|
|
|
|
|
def _safe_int(value: int | float | str | None) -> int:
|
|
"""Convert a value to int, returning 0 for None or unparseable values."""
|
|
if value is None:
|
|
return 0
|
|
try:
|
|
return int(value)
|
|
except (ValueError, TypeError):
|
|
return 0
|