diff --git a/ROADMAP.md b/ROADMAP.md index 06d2899..c93fab1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -85,7 +85,7 @@ ### News & Information (4 tools) | Tool | WM Equivalent | Status | |------|---------------|--------| -| `intel_news_feed` | 90+ RSS feeds, 4-tier sources | :white_check_mark: | +| `intel_news_feed` | 100 RSS feeds, 4-tier sources | :white_check_mark: | | `intel_trending_keywords` | trending-keywords service | :white_check_mark: | | `intel_gdelt_search` | `search-gdelt-documents` | :white_check_mark: | | `intel_ai_releases` | AI model/paper tracker | :white_check_mark: | @@ -186,26 +186,28 @@ ## 3. RSS Feed Coverage -Expanded to **90+ feeds** across **16 categories** with 4-tier source ranking (wire/major/specialty/aggregator) and propaganda risk labels. +Expanded to **100 feeds** across **18 categories** with 4-tier source ranking (wire/major/specialty/aggregator) and propaganda risk labels. | Category | Count | Status | |----------|-------|--------| -| Wire services | 8+ | :white_check_mark: | -| Politics/World | 15+ | :white_check_mark: | -| Middle East | 8+ | :white_check_mark: | -| Defense/Military | 8+ | :white_check_mark: | -| Think Tanks | 10+ | :white_check_mark: | -| Government (US) | 6+ | :white_check_mark: | -| Crisis/Intl Orgs | 4+ | :white_check_mark: | -| Africa regional | 4+ | :white_check_mark: | -| Asia regional | 6+ | :white_check_mark: | -| Energy | 4+ | :white_check_mark: | -| Cyber/Infosec | 6+ | :white_check_mark: | -| Health (WHO/ProMED) | 3+ | :white_check_mark: | -| Space weather | 2+ | :white_check_mark: | -| AI/ML releases | 3+ | :white_check_mark: | -| Latin America | 8+ | :white_check_mark: | -| Multilingual (ES/FR/DE) | 7+ | :white_check_mark: | +| Geopolitics | 9 | :white_check_mark: | +| Security/Cyber | 8 | :white_check_mark: | +| Technology | 6 | :white_check_mark: | +| Finance | 6 | :white_check_mark: | +| Defense/Military | 6 | :white_check_mark: | +| Science | 6 | :white_check_mark: | +| Think Tanks | 7 | :white_check_mark: | +| Middle East | 4 | :white_check_mark: | +| Asia-Pacific | 5 | :white_check_mark: | +| Africa | 5 | :white_check_mark: | +| Latin America | 8 | :white_check_mark: | +| Multilingual (ES/FR/DE) | 7 | :white_check_mark: | +| Energy | 5 | :white_check_mark: | +| Government | 4 | :white_check_mark: | +| Crisis/Intl Orgs | 4 | :white_check_mark: | +| Europe | 4 | :white_check_mark: | +| South Asia | 3 | :white_check_mark: | +| Health | 3 | :white_check_mark: | --- @@ -271,7 +273,7 @@ Composite strategic posture assessment from 9 weighted domains (military, politi ### Phase 11: Extended Data & Geospatial (+12 = 80 tools) `intel_country_stocks`, `intel_aircraft_batch`, `intel_hacker_news`, `intel_trending_repos`, `intel_arxiv_papers`, `intel_usa_spending`, `intel_environmental_events`, `intel_disaster_alerts`, `intel_undersea_cables`, `intel_ai_datacenters`, `intel_spaceports`, `intel_critical_minerals`, `intel_stock_exchanges`, `intel_usni_fleet` -Static datasets completed: 34 undersea cables, 48 AI datacenters, 27 spaceports, 27 critical mineral deposits, 82 stock exchanges. USNI Fleet Tracker for Navy disposition. RSS feeds expanded to 90+ across 16 categories (added Latin America 8+, multilingual ES/FR/DE 7+). Data freshness monitoring added to dashboard. Static HTML report generation removed (live dashboard replaces). +Static datasets completed: 34 undersea cables, 48 AI datacenters, 27 spaceports, 27 critical mineral deposits, 82 stock exchanges. USNI Fleet Tracker for Navy disposition. Data freshness monitoring added to dashboard. Static HTML report generation removed (live dashboard replaces). ### Phase 12: Financial Intelligence & Geospatial Expansion (+5 = 84 tools) `intel_btc_technicals`, `intel_central_bank_rates`, `intel_trade_routes`, `intel_cloud_regions`, `intel_financial_centers` @@ -287,7 +289,7 @@ BTC technical analysis with SMA-50/200, Mayer Multiple, golden/death cross signa | Data source tools | 84 | 42 | **200%** | | Analysis engines | 19 | 15 | **127%** | | Static datasets | 18 | 12 | **150%** | -| RSS feeds | 90+ | 150+ | 60% | +| RSS feeds | 100 | 150+ | **67%** | | Strategic synthesis | Posture + brief + fleet + exposure + USNI | Dashboard-only | **Exceeds** | -**Bottom line**: 84 tools across 30+ domains, exactly 2x WorldMonitor benchmark in tool count, 27% more analysis engines, and 50% more static datasets. All phases 1-12 complete. Live Starlette dashboard with 39 SSE streams, 14 map layers (with trade route markers), and data freshness monitoring. +**Bottom line**: 84 tools across 30+ domains, exactly 2x WorldMonitor benchmark in tool count, 27% more analysis engines, and 50% more static datasets. 100 RSS feeds across 18 categories. All phases 1-12 complete. Live Starlette dashboard with 39 SSE streams, 14 map layers (with trade route markers), and data freshness monitoring. diff --git a/src/world_intel_mcp/cli.py b/src/world_intel_mcp/cli.py index 20bec9f..8769056 100644 --- a/src/world_intel_mcp/cli.py +++ b/src/world_intel_mcp/cli.py @@ -18,7 +18,15 @@ from rich import box 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 +from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, shipping, social, nuclear, space_weather, ai_watch, elections, health, sanctions +from .sources.central_banks import fetch_central_bank_rates +from .sources.usni_fleet import fetch_usni_fleet +from .sources.hacker_news import fetch_hacker_news +from .sources.github_trending import fetch_trending_repos +from .sources.arxiv_papers import fetch_arxiv_papers +from .sources.usa_spending import fetch_usa_spending +from .sources.environmental import fetch_environmental_events, fetch_disaster_alerts +from .sources import geospatial console = Console() @@ -518,12 +526,14 @@ def climate_cmd(ctx: click.Context) -> None: @main.command(name="news") @click.option("--category", "-c", default=None, - type=click.Choice(["geopolitics", "security", "technology", "finance", "military", "science"]), + type=click.Choice(["geopolitics", "security", "technology", "finance", "military", "science", + "think_tanks", "middle_east", "asia_pacific", "africa", "latin_america", + "multilingual", "energy", "government", "crisis", "europe", "south_asia", "health"]), help="Category filter") @click.option("--limit", "-n", default=30, help="Max items") @click.pass_context def news_cmd(ctx: click.Context, category: str | None, limit: int) -> None: - """Intelligence news from 20+ RSS feeds.""" + """Intelligence news from 100 RSS feeds across 18 categories.""" f = _get_fetcher() data = _run(news.fetch_news_feed(f, category=category, limit=limit)) @@ -858,6 +868,433 @@ def instability(ctx: click.Context, country_code: str | None) -> None: console.print(table) +# --------------------------------------------------------------------------- +# Finance (additional) +# --------------------------------------------------------------------------- + +@main.command() +@click.pass_context +def btc(ctx: click.Context) -> None: + """Bitcoin technical indicators (SMA, Mayer, cross signals).""" + f = _get_fetcher() + data = _run(markets.fetch_btc_technicals(f)) + + if ctx.obj.get("json") or "error" in data: + _print_json(data) + return + + console.print(f"[bold]BTC Technicals[/bold] price: ${data.get('price', 0):,.2f}\n") + table = Table(box=box.SIMPLE_HEAVY) + table.add_column("Indicator", style="bold") + table.add_column("Value", justify="right") + + table.add_row("SMA-50", f"${data.get('sma_50', 0):,.2f}") + table.add_row("SMA-200", f"${data.get('sma_200', 0):,.2f}" if data.get('sma_200') else "N/A") + table.add_row("Mayer Multiple", f"{data.get('mayer_multiple', 0):.4f}" if data.get('mayer_multiple') else "N/A") + cross = data.get('cross_signal', 'neutral') + c_style = "green" if cross == "golden_cross" else "red" if cross == "death_cross" else "" + table.add_row("Cross Signal", f"[{c_style}]{cross}[/{c_style}]" if c_style else cross) + table.add_row("ATH Distance", f"{data.get('ath_distance_pct', 0):.1f}%") + table.add_row("7d Change", f"{data.get('change_7d_pct', 0):+.2f}%" if data.get('change_7d_pct') is not None else "N/A") + table.add_row("30d Change", f"{data.get('change_30d_pct', 0):+.2f}%" if data.get('change_30d_pct') is not None else "N/A") + console.print(table) + + +@main.command(name="central-banks") +@click.pass_context +def central_banks_cmd(ctx: click.Context) -> None: + """Central bank policy rates (15 banks).""" + f = _get_fetcher() + data = _run(fetch_central_bank_rates(f)) + + if ctx.obj.get("json"): + _print_json(data) + return + + fred_tag = "[green]FRED[/green]" if data.get("fred_available") else "[yellow]curated[/yellow]" + console.print(f"[bold]{data.get('count', 0)} Central Banks[/bold] ({fred_tag})\n") + + table = Table(box=box.SIMPLE_HEAVY) + table.add_column("Bank", style="bold") + table.add_column("Country") + table.add_column("Rate %", justify="right") + table.add_column("As Of") + + for r in data.get("rates", []): + rate = r.get("rate", 0) + style = "red bold" if rate >= 10 else "yellow" if rate >= 5 else "green" if rate < 1 else "" + rate_str = f"[{style}]{rate:.2f}[/{style}]" if style else f"{rate:.2f}" + table.add_row(r.get("bank", ""), r.get("country", ""), rate_str, r.get("as_of", "")) + console.print(table) + + +@main.command(name="shipping") +@click.pass_context +def shipping_cmd(ctx: click.Context) -> None: + """Shipping index (BDI, tanker, container ETFs).""" + f = _get_fetcher() + data = _run(shipping.fetch_shipping_index(f)) + + if ctx.obj.get("json"): + _print_json(data) + return + + console.print(f"[bold]Shipping Stress: {data.get('assessment', '?')}[/bold] " + f"(score: {data.get('stress_score', 0):.0f}/100)\n") + + table = Table(box=box.SIMPLE_HEAVY) + table.add_column("Symbol", style="bold") + table.add_column("Price", justify="right") + table.add_column("Change %", justify="right") + + for q in data.get("quotes", []): + chg = q.get("change_pct") or 0 + style = "green" if chg >= 0 else "red" + table.add_row(q.get("symbol", ""), f"${q.get('price', 0):,.2f}", f"[{style}]{chg:+.2f}%[/{style}]") + console.print(table) + + +# --------------------------------------------------------------------------- +# Social & Health +# --------------------------------------------------------------------------- + +@main.command(name="social") +@click.pass_context +def social_cmd(ctx: click.Context) -> None: + """Reddit social signals (worldnews, geopolitics).""" + f = _get_fetcher() + data = _run(social.fetch_social_signals(f)) + + if ctx.obj.get("json"): + _print_json(data) + return + + metrics = data.get("velocity_metrics", {}) + console.print(f"[bold]Social Signals[/bold] — {metrics.get('total_posts', 0)} posts, " + f"{metrics.get('high_engagement_count', 0)} high engagement\n") + + for post in data.get("top_posts", [])[:15]: + score = post.get("score", 0) + style = "bold" if score >= 1000 else "" + title = post.get("title", "")[:80] + console.print(f" [{style}]{score:>5}[/{style}] {title}" if style else f" {score:>5} {title}") + + +@main.command(name="disease") +@click.pass_context +def disease_cmd(ctx: click.Context) -> None: + """Disease outbreaks (WHO/ProMED/CIDRAP).""" + f = _get_fetcher() + data = _run(health.fetch_disease_outbreaks(f)) + + if ctx.obj.get("json"): + _print_json(data) + return + + console.print(f"[bold]{data.get('count', 0)} outbreak reports[/bold] " + f"([red]{data.get('high_concern_count', 0)} high concern[/red])\n") + + for item in data.get("items", [])[:20]: + hc = "[red]HC[/red] " if item.get("is_high_concern") else " " + title = item.get("title", "")[:80] + feed = item.get("feed_name", "") + console.print(f" {hc}{title} [dim]({feed})[/dim]") + + +@main.command(name="elections") +@click.option("--country", "-c", default=None, help="Country filter (ISO-3)") +@click.pass_context +def elections_cmd(ctx: click.Context, country: str | None) -> None: + """Election calendar with risk scoring.""" + f = _get_fetcher() + data = _run(elections.fetch_election_calendar(f, country=country)) + + if ctx.obj.get("json"): + _print_json(data) + return + + table = Table(title="Election Calendar", box=box.SIMPLE_HEAVY) + table.add_column("Date", style="bold") + table.add_column("Country") + table.add_column("Type") + table.add_column("Days", justify="right") + table.add_column("Risk", justify="right") + + for e in data.get("elections", []): + risk = e.get("risk_score", 0) + r_style = "red bold" if risk >= 4 else "yellow" if risk >= 2 else "" + r_str = f"[{r_style}]{risk:.1f}[/{r_style}]" if r_style else f"{risk:.1f}" + table.add_row(e.get("date", ""), e.get("country", ""), e.get("type", ""), str(e.get("days_until", "")), r_str) + console.print(table) + + +# --------------------------------------------------------------------------- +# Specialist +# --------------------------------------------------------------------------- + +@main.command(name="nuclear") +@click.option("--hours", "-h", default=72, help="Lookback hours") +@click.pass_context +def nuclear_cmd(ctx: click.Context, hours: int) -> None: + """Nuclear test site seismic monitor.""" + f = _get_fetcher() + data = _run(nuclear.fetch_nuclear_monitor(f, hours=hours)) + + if ctx.obj.get("json"): + _print_json(data) + return + + console.print(f"[bold]{data.get('total_flagged_events', 0)} flagged events[/bold] " + f"([red]{data.get('critical_flags', 0)} critical[/red]) in last {hours}h\n") + + for site in data.get("sites", []): + n = site.get("name", "") + events = site.get("events", []) + count = len(events) + style = "red bold" if count > 0 else "dim" + console.print(f" [{style}]{n}: {count} events[/{style}]") + + +@main.command(name="space") +@click.pass_context +def space_cmd(ctx: click.Context) -> None: + """Space weather (NOAA/SWPC).""" + f = _get_fetcher() + data = _run(space_weather.fetch_space_weather(f)) + + if ctx.obj.get("json"): + _print_json(data) + return + + table = Table(title="Space Weather", box=box.SIMPLE_HEAVY) + table.add_column("Metric", style="bold") + table.add_column("Value", justify="right") + + for key in ("k_index", "solar_wind_speed_km_s", "solar_wind_density", "bz_gsm_nt", "flux_10_7"): + val = data.get(key) + if val is not None: + table.add_row(key.replace("_", " ").title(), str(val)) + console.print(table) + + +@main.command(name="sanctions") +@click.argument("query") +@click.option("--country", "-c", default=None, help="Country filter") +@click.pass_context +def sanctions_cmd(ctx: click.Context, query: str, country: str | None) -> None: + """Search OFAC SDN sanctions list.""" + f = _get_fetcher() + data = _run(sanctions.fetch_sanctions_search(f, query=query, country=country)) + + if ctx.obj.get("json"): + _print_json(data) + return + + console.print(f"[bold]{data.get('count', 0)} matches[/bold] for '{query}' " + f"(from {data.get('total_entities', 0)} total)\n") + + for m in data.get("matches", [])[:20]: + etype = m.get("entity_type", "") + name = m.get("name", "") + programs = ", ".join(m.get("programs", [])[:3]) + console.print(f" [{etype}] [bold]{name}[/bold] ({programs})") + + +@main.command(name="ai-watch") +@click.pass_context +def ai_watch_cmd(ctx: click.Context) -> None: + """AI model and paper releases tracker.""" + f = _get_fetcher() + data = _run(ai_watch.fetch_ai_watch(f)) + + if ctx.obj.get("json"): + _print_json(data) + return + + console.print(f"[bold]AI Watch[/bold] — {data.get('total_items', 0)} items\n") + for item in data.get("items", [])[:20]: + title = item.get("title", "")[:80] + src = item.get("source", "") + console.print(f" [{src}] {title}") + + +# --------------------------------------------------------------------------- +# Navy +# --------------------------------------------------------------------------- + +@main.command(name="fleet") +@click.pass_context +def fleet_cmd(ctx: click.Context) -> None: + """USNI Fleet Tracker (Navy disposition).""" + f = _get_fetcher() + data = _run(fetch_usni_fleet(f)) + + if ctx.obj.get("json") or "error" in data: + _print_json(data) + return + + console.print(f"[bold]{data.get('report_title', 'Fleet Report')}[/bold]\n") + + totals = data.get("force_totals", {}) + if totals.get("battle_force"): + bf = totals["battle_force"] + console.print(f" Battle Force: {bf.get('total', 0)} ships ({bf.get('uss', 0)} USS, {bf.get('usns', 0)} USNS)") + if totals.get("deployed"): + dp = totals["deployed"] + console.print(f" Deployed: {dp.get('total', 0)} ({dp.get('uss', 0)} USS, {dp.get('usns', 0)} USNS)") + + ships = data.get("ships", []) + if ships: + console.print(f"\n [bold]{len(ships)} ships identified[/bold]") + table = Table(box=box.SIMPLE_HEAVY) + table.add_column("Ship", style="bold") + table.add_column("Hull") + table.add_column("Type") + table.add_column("Region") + for s in ships[:20]: + table.add_row(s.get("name", ""), s.get("hull_number", ""), s.get("type", ""), s.get("region", "")) + console.print(table) + + +# --------------------------------------------------------------------------- +# Tech & Science +# --------------------------------------------------------------------------- + +@main.command(name="hn") +@click.option("--limit", "-n", default=20, help="Number of stories") +@click.pass_context +def hn_cmd(ctx: click.Context, limit: int) -> None: + """Top Hacker News stories.""" + f = _get_fetcher() + data = _run(fetch_hacker_news(f, limit=limit)) + + if ctx.obj.get("json"): + _print_json(data) + return + + for s in data.get("stories", []): + score = s.get("score", 0) + title = s.get("title", "")[:80] + console.print(f" {score:>5} {title}") + + +@main.command(name="gh-trending") +@click.option("--limit", "-n", default=15, help="Number of repos") +@click.pass_context +def gh_trending_cmd(ctx: click.Context, limit: int) -> None: + """Trending GitHub repositories.""" + f = _get_fetcher() + data = _run(fetch_trending_repos(f, limit=limit)) + + if ctx.obj.get("json"): + _print_json(data) + return + + for r in data.get("repos", []): + stars = r.get("stars", 0) + name = r.get("name", "") + lang = r.get("language") or "" + desc = (r.get("description") or "")[:60] + console.print(f" {stars:>6} [bold]{name}[/bold] [{lang}] {desc}") + + +@main.command(name="arxiv") +@click.option("--query", "-q", default="cs.AI", help="arXiv category or query") +@click.option("--limit", "-n", default=10, help="Number of papers") +@click.pass_context +def arxiv_cmd(ctx: click.Context, query: str, limit: int) -> None: + """Recent arXiv papers.""" + f = _get_fetcher() + data = _run(fetch_arxiv_papers(f, query=query, limit=limit)) + + if ctx.obj.get("json"): + _print_json(data) + return + + for p in data.get("papers", []): + title = p.get("title", "")[:80] + authors = ", ".join(p.get("authors", [])[:3]) + console.print(f" [bold]{title}[/bold]") + console.print(f" {authors}") + + +@main.command(name="spending") +@click.option("--limit", "-n", default=15, help="Top N agencies") +@click.pass_context +def spending_cmd(ctx: click.Context, limit: int) -> None: + """US federal spending (USAspending.gov).""" + f = _get_fetcher() + data = _run(fetch_usa_spending(f, limit=limit)) + + if ctx.obj.get("json"): + _print_json(data) + return + + table = Table(title="Federal Agencies by Budget", box=box.SIMPLE_HEAVY) + table.add_column("Agency", style="bold") + table.add_column("Budget Auth", justify="right") + table.add_column("Obligated", justify="right") + + for a in data.get("agencies", []): + budget = a.get("budget_authority", 0) or 0 + obligated = a.get("obligated", 0) or 0 + table.add_row(a.get("name", ""), f"${budget/1e9:,.1f}B", f"${obligated/1e9:,.1f}B") + console.print(table) + + +# --------------------------------------------------------------------------- +# Geospatial (static datasets) +# --------------------------------------------------------------------------- + +@main.command(name="bases") +@click.option("--operator", "-o", default=None, help="Operator country (USA, RUS, CHN)") +@click.option("--country", "-c", default=None, help="Host country") +@click.pass_context +def bases_cmd(ctx: click.Context, operator: str | None, country: str | None) -> None: + """Military bases worldwide (70 bases).""" + data = _run(geospatial.fetch_military_bases(operator=operator, country=country)) + + if ctx.obj.get("json"): + _print_json(data) + return + + console.print(f"[bold]{data.get('count', 0)} bases[/bold] (of {data.get('total_in_database', 0)})\n") + table = Table(box=box.SIMPLE_HEAVY) + table.add_column("Name", style="bold") + table.add_column("Operator") + table.add_column("Country") + table.add_column("Type") + table.add_column("Branch") + + for b in data.get("bases", [])[:30]: + table.add_row(b.get("name", ""), b.get("operator", ""), b.get("country", ""), b.get("type", ""), b.get("branch", "")) + console.print(table) + + +@main.command(name="exchanges") +@click.option("--tier", "-t", default=None, type=click.Choice(["mega", "major", "mid", "small"])) +@click.option("--country", "-c", default=None, help="Country filter") +@click.pass_context +def exchanges_cmd(ctx: click.Context, tier: str | None, country: str | None) -> None: + """Global stock exchanges (82 exchanges).""" + data = _run(geospatial.fetch_stock_exchanges(tier=tier, country=country)) + + if ctx.obj.get("json"): + _print_json(data) + return + + console.print(f"[bold]{data.get('count', 0)} exchanges[/bold] " + f"(${data.get('total_market_cap_usd_t', 0):.1f}T total market cap)\n") + + table = Table(box=box.SIMPLE_HEAVY) + table.add_column("Exchange", style="bold") + table.add_column("Country") + table.add_column("Tier") + table.add_column("Market Cap ($T)", justify="right") + + for e in data.get("exchanges", [])[:30]: + table.add_row(e.get("name", ""), e.get("country", ""), e.get("tier", ""), f"{e.get('market_cap_usd_t', 0):.2f}") + console.print(table) # --------------------------------------------------------------------------- diff --git a/src/world_intel_mcp/dashboard/index.html b/src/world_intel_mcp/dashboard/index.html index b4dc97a..5028849 100644 --- a/src/world_intel_mcp/dashboard/index.html +++ b/src/world_intel_mcp/dashboard/index.html @@ -1290,24 +1290,143 @@ document.addEventListener('click', function(e) { } }); +// ════════════ KEYBOARD SHORTCUTS ════════════ +var _helpOverlay = null; +function _buildHelpOverlay() { + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;z-index:20000;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;'; + var box = document.createElement('div'); + box.style.cssText = 'background:#1a1a2e;border:1px solid #333;border-radius:8px;padding:24px 32px;max-width:520px;width:90%;color:#e0e0e0;font-family:monospace;font-size:13px;'; + // Header + var hdr = document.createElement('div'); + hdr.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;'; + var title = document.createElement('span'); + title.style.cssText = 'font-size:16px;font-weight:bold;color:#00d4ff;'; + title.textContent = 'Keyboard Shortcuts'; + var closeBtn = document.createElement('span'); + closeBtn.style.cssText = 'cursor:pointer;color:#888;font-size:18px;'; + closeBtn.textContent = '\u2715'; + closeBtn.addEventListener('click', function() { overlay.remove(); _helpOverlay = null; }); + hdr.appendChild(title); + hdr.appendChild(closeBtn); + box.appendChild(hdr); + // Table + var tbl = document.createElement('table'); + tbl.style.cssText = 'width:100%;border-collapse:collapse;'; + var rows = [ + ['__section', 'Navigation'], + ['?', 'Toggle this help'], + ['/', 'Focus search'], + ['Esc', 'Close panels / blur search'], + ['d', 'Toggle drawer'], + ['l', 'Toggle layer panel'], + ['r', 'Reset globe view'], + ['+ / -', 'Zoom in / out'], + ['__section', 'Map Layers (1-9, 0)'], + ['1', 'Earthquakes'], ['2', 'Military flights'], ['3', 'Conflict zones'], + ['4', 'Wildfires'], ['5', 'Signal convergence'], ['6', 'Nuclear monitor'], + ['7', 'Infrastructure'], ['8', 'Population exposure'], ['9', 'Air traffic'], + ['0', 'News geolocation'], + ['__section', 'Actions'], + ['a', 'Toggle all layers on/off'], + ['f', 'Toggle fullscreen'], + ]; + rows.forEach(function(r) { + var tr = document.createElement('tr'); + if (r[0] === '__section') { + var td = document.createElement('td'); + td.colSpan = 2; + td.style.cssText = 'padding:10px 0 6px;color:#888;border-bottom:1px solid #333;'; + td.textContent = r[1]; + tr.appendChild(td); + } else { + var kd = document.createElement('td'); + kd.style.cssText = 'padding:4px 0;'; + var kbd = document.createElement('kbd'); + kbd.style.cssText = 'background:#333;padding:2px 6px;border-radius:3px;'; + kbd.textContent = r[0]; + kd.appendChild(kbd); + var vd = document.createElement('td'); + vd.textContent = r[1]; + tr.appendChild(kd); + tr.appendChild(vd); + } + tbl.appendChild(tr); + }); + box.appendChild(tbl); + overlay.appendChild(box); + overlay.addEventListener('click', function(ev) { if (ev.target === overlay) { overlay.remove(); _helpOverlay = null; } }); + return overlay; +} +function _toggleHelp() { + if (_helpOverlay) { _helpOverlay.remove(); _helpOverlay = null; return; } + _helpOverlay = _buildHelpOverlay(); + document.body.appendChild(_helpOverlay); +} + +var _layerKeys = ['quakes','military','conflict','fires','convergence','nuclear','infra','exposure','airtraffic','news']; +function _toggleLayerByIndex(idx) { + var key = _layerKeys[idx]; + if (!key || !mapLayers[key]) return; + var row = document.querySelector('.layer-row[data-layer="' + key + '"]'); + if (!row) return; + var toggle = row.querySelector('.toggle'); + toggle.classList.toggle('on'); + layerState[key] = toggle.classList.contains('on'); + if (layerState[key]) { mapLayers[key].addTo(map); } else { map.removeLayer(mapLayers[key]); } + try { localStorage.setItem('phoenix-layers', JSON.stringify(layerState)); } catch(e) {} +} + +var _allLayersOn = true; +function _toggleAllLayers() { + _allLayersOn = !_allLayersOn; + _layerKeys.forEach(function(key) { + if (!mapLayers[key]) return; + layerState[key] = _allLayersOn; + if (_allLayersOn) { mapLayers[key].addTo(map); } else { map.removeLayer(mapLayers[key]); } + var row = document.querySelector('.layer-row[data-layer="' + key + '"]'); + if (row) { var t = row.querySelector('.toggle'); if (t) { if (_allLayersOn) t.classList.add('on'); else t.classList.remove('on'); } } + }); + try { localStorage.setItem('phoenix-layers', JSON.stringify(layerState)); } catch(e) {} +} + document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { + if (_helpOverlay) { _helpOverlay.remove(); _helpOverlay = null; return; } closeDetail(); if (!$('#drawer').classList.contains('closed')) { drawerOpen = false; $('#drawer').classList.add('closed'); - $('#drawerToggle').innerHTML = '◀'; + $('#drawerToggle').textContent = '\u25C0'; } $('#searchInput').blur(); + return; } if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; - if (e.key === 'd' || e.key === 'D') { + if (e.key === '?') { _toggleHelp(); return; } + if (e.key === '/') { e.preventDefault(); $('#searchInput').focus(); return; } + if (e.key === 'd') { drawerOpen = !drawerOpen; $('#drawer').classList.toggle('closed', !drawerOpen); - $('#drawerToggle').innerHTML = drawerOpen ? '▶' : '◀'; + $('#drawerToggle').textContent = drawerOpen ? '\u25B6' : '\u25C0'; + return; } - if (e.key === 'r' || e.key === 'R') { map.setView([20, 0], 2.5); } - if (e.key === '/') { e.preventDefault(); $('#searchInput').focus(); } + if (e.key === 'l') { + var lp = $('#layerPanel'); + if (lp) lp.classList.toggle('collapsed'); + return; + } + if (e.key === 'r') { map.setView([20, 0], 2.5); return; } + if (e.key === '=' || e.key === '+') { map.zoomIn(); return; } + if (e.key === '-') { map.zoomOut(); return; } + if (e.key === 'f') { + if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(function(){}); } + else { document.exitFullscreen(); } + return; + } + if (e.key === 'a') { _toggleAllLayers(); return; } + if (e.key >= '1' && e.key <= '9') { _toggleLayerByIndex(parseInt(e.key) - 1); return; } + if (e.key === '0') { _toggleLayerByIndex(9); return; } }); // ════════════ MAP MARKER MANAGEMENT ════════════ diff --git a/src/world_intel_mcp/sources/news.py b/src/world_intel_mcp/sources/news.py index 3ab24ff..ad99219 100644 --- a/src/world_intel_mcp/sources/news.py +++ b/src/world_intel_mcp/sources/news.py @@ -34,6 +34,8 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = { ("The Guardian World", "https://www.theguardian.com/world/rss"), ("DW News", "https://rss.dw.com/rss/en/top_news/rss-en-top"), ("France24", "https://www.france24.com/en/rss"), + ("NPR World", "https://feeds.npr.org/1004/rss.xml"), + ("VOA News", "https://www.voanews.com/api/zyrttemnuq"), ], "security": [ ("BleepingComputer", "https://www.bleepingcomputer.com/feed/"), @@ -42,6 +44,8 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = { ("Schneier on Security", "https://www.schneier.com/feed/atom/"), ("Dark Reading", "https://www.darkreading.com/rss.xml"), ("CISA Alerts", "https://www.cisa.gov/cybersecurity-advisories/all.xml"), + ("The Record", "https://therecord.media/feed"), + ("Security Week", "https://www.securityweek.com/feed/"), ], "technology": [ ("Ars Technica", "https://feeds.arstechnica.com/arstechnica/index"), @@ -49,6 +53,7 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = { ("The Verge", "https://www.theverge.com/rss/index.xml"), ("Wired", "https://www.wired.com/feed/rss"), ("MIT Tech Review", "https://www.technologyreview.com/feed/"), + ("The Register", "https://www.theregister.com/headlines.atom"), ], "finance": [ ("CNBC", "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=100003114"), @@ -71,11 +76,17 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = { ("Science", "https://www.science.org/action/showFeed?type=etoc&feed=rss&jc=science"), ("Phys.org", "https://phys.org/rss-feed/"), ("New Scientist", "https://www.newscientist.com/feed/home/"), + ("Scientific American", "https://rss.sciam.com/ScientificAmerican-Global"), + ("SpaceNews", "https://spacenews.com/feed/"), ], "think_tanks": [ ("RAND", "https://www.rand.org/blog.xml"), ("Brookings", "https://www.brookings.edu/feed/"), ("Carnegie", "https://carnegieendowment.org/rss/solr.xml"), + ("CFR", "https://www.cfr.org/rss/all"), + ("CSIS", "https://www.csis.org/rss/all"), + ("Atlantic Council", "https://www.atlanticcouncil.org/feed/"), + ("Chatham House", "https://www.chathamhouse.org/rss/all"), ], "middle_east": [ ("Middle East Eye", "https://www.middleeasteye.net/rss"), @@ -92,6 +103,10 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = { ], "africa": [ ("allAfrica", "https://allafrica.com/tools/headlines/rdf/latest/headlines.rdf"), + ("The Africa Report", "https://www.theafricareport.com/feed/"), + ("African Arguments", "https://africanarguments.org/feed/"), + ("ISS Africa", "https://issafrica.org/iss-today/feed"), + ("Daily Maverick", "https://www.dailymaverick.co.za/article/feed/"), ], "latin_america": [ ("MercoPress", "https://en.mercopress.com/rss"), @@ -116,15 +131,36 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = { ("Oil Price", "https://oilprice.com/rss/main"), ("Rigzone", "https://www.rigzone.com/news/rss/rigzone_latest.aspx"), ("Utility Dive", "https://www.utilitydive.com/feeds/news/"), + ("Carbon Brief", "https://www.carbonbrief.org/feed/"), + ("CleanTechnica", "https://cleantechnica.com/feed/"), ], "government": [ ("State Dept", "https://www.state.gov/rss-feed/press-releases/feed/"), ("DoD News", "https://www.defense.gov/DesktopModules/ArticleCS/RSS.ashx?ContentType=1&Site=945&max=10"), ("UN News", "https://news.un.org/feed/subscribe/en/news/all/rss.xml"), + ("White House", "https://www.whitehouse.gov/feed/"), ], "crisis": [ ("ReliefWeb", "https://reliefweb.int/updates/rss.xml"), ("ICG", "https://www.crisisgroup.org/rss.xml"), + ("Amnesty Intl", "https://www.amnesty.org/en/feed/"), + ("HRW", "https://www.hrw.org/rss/news_releases"), + ], + "europe": [ + ("EurActiv", "https://www.euractiv.com/feed/"), + ("Politico EU", "https://www.politico.eu/feed/"), + ("EU Observer", "https://euobserver.com/rss"), + ("DW Europe", "https://rss.dw.com/rss/en/eu/rss-en-eu"), + ], + "south_asia": [ + ("NDTV", "https://feeds.feedburner.com/ndtvnews-top-stories"), + ("Dawn Pakistan", "https://www.dawn.com/feeds/home"), + ("Scroll India", "https://scroll.in/feed"), + ], + "health": [ + ("STAT News", "https://www.statnews.com/feed/"), + ("WHO News", "https://www.who.int/rss-feeds/news-english.xml"), + ("Medical Xpress", "https://medicalxpress.com/rss-feed/"), ], } @@ -176,6 +212,37 @@ SOURCE_TIERS: dict[str, str] = { "Nikkei Asia": "major", "The National UAE": "major", "Zero Hedge": "aggregator", + # Phase 15 additions + "NPR World": "major", + "VOA News": "government", + "The Record": "specialty", + "Security Week": "specialty", + "Scientific American": "major", + "SpaceNews": "specialty", + "CFR": "think_tank", + "CSIS": "think_tank", + "Atlantic Council": "think_tank", + "Chatham House": "think_tank", + "The Africa Report": "specialty", + "African Arguments": "specialty", + "ISS Africa": "think_tank", + "Daily Maverick": "major", + "Carbon Brief": "specialty", + "CleanTechnica": "specialty", + "EurActiv": "specialty", + "Politico EU": "major", + "EU Observer": "specialty", + "DW Europe": "major", + "NDTV": "major", + "Dawn Pakistan": "major", + "Scroll India": "specialty", + "STAT News": "specialty", + "WHO News": "intl_org", + "Medical Xpress": "specialty", + "Amnesty Intl": "intl_org", + "HRW": "intl_org", + "The Register": "specialty", + "White House": "government", } _STOPWORDS: set[str] = { diff --git a/src/world_intel_mcp/tests/test_sources.py b/src/world_intel_mcp/tests/test_sources.py index 08a7206..df04874 100644 --- a/src/world_intel_mcp/tests/test_sources.py +++ b/src/world_intel_mcp/tests/test_sources.py @@ -906,3 +906,280 @@ async def test_fetch_aircraft_details_batch(fetcher: Fetcher) -> None: assert result["source"] == "hexdb" assert result["count"] == 2 assert result["requested"] == 2 + + +# --------------------------------------------------------------------------- +# BTC Technicals (CoinGecko) +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_btc_technicals(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.markets import fetch_btc_technicals + + # Generate 201 daily price points (enough for SMA-200) + prices = [[1700000000 + i * 86400, 90000 + i * 10] for i in range(201)] + + respx.get("https://api.coingecko.com/api/v3/coins/bitcoin/market_chart").mock( + return_value=httpx.Response(200, json={"prices": prices}) + ) + + result = await fetch_btc_technicals(fetcher) + assert result["source"] == "coingecko" + assert result["price"] == prices[-1][1] + assert result["sma_50"] > 0 + assert result["sma_200"] > 0 + assert result["mayer_multiple"] > 0 + assert result["cross_signal"] in ("golden_cross", "death_cross", "neutral") + assert result["ath_distance_pct"] <= 0 # Current price <= ATH + assert result["data_points"] == 201 + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_btc_technicals_insufficient_data(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.markets import fetch_btc_technicals + + # Only 10 data points — not enough for SMA-50 + prices = [[1700000000 + i * 86400, 90000 + i * 10] for i in range(10)] + + respx.get("https://api.coingecko.com/api/v3/coins/bitcoin/market_chart").mock( + return_value=httpx.Response(200, json={"prices": prices}) + ) + + result = await fetch_btc_technicals(fetcher) + assert "error" in result + assert result["source"] == "coingecko" + + +# --------------------------------------------------------------------------- +# Central Bank Rates +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_central_bank_rates_no_fred(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.central_banks import fetch_central_bank_rates + + import os + os.environ.pop("FRED_API_KEY", None) + + result = await fetch_central_bank_rates(fetcher) + assert result["source"] == "multi" + assert result["fred_available"] is False + # Should have all 15 banks (12 curated + 3 FRED-fallback curated) + assert result["count"] == 15 + # Sorted by rate descending — CBRT (45%) should be first + assert result["rates"][0]["bank"] == "Central Bank of Turkey" + assert result["rates"][0]["rate"] == 45.00 + # All should be curated source + assert all(r["source"] == "curated" for r in result["rates"]) + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_central_bank_rates_with_fred(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.central_banks import fetch_central_bank_rates + + import os + os.environ["FRED_API_KEY"] = "test_key_123" + + fred_response = { + "observations": [ + {"date": "2026-02-25", "value": "4.33"} + ] + } + + respx.get("https://api.stlouisfed.org/fred/series/observations").mock( + return_value=httpx.Response(200, json=fred_response) + ) + + try: + result = await fetch_central_bank_rates(fetcher) + assert result["source"] == "multi" + assert result["fred_available"] is True + assert result["count"] == 15 + # At least some should be from FRED + fred_sources = [r for r in result["rates"] if r["source"] == "fred"] + assert len(fred_sources) >= 1 + finally: + os.environ.pop("FRED_API_KEY", None) + + +# --------------------------------------------------------------------------- +# USNI Fleet Tracker +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_usni_fleet(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.usni_fleet import fetch_usni_fleet + + rss_xml = """ + + + USNI News Fleet Tracker + + USNI News Fleet and Marine Tracker: Feb. 24, 2026 + https://news.usni.org/2026/02/24/fleet-tracker + Mon, 24 Feb 2026 14:00:00 GMT + + 298 ships (237 USS, 61 USNS). 100 deployed (67 USS, 33 USNS). 72 underway (55 deployed, 17 local). + Carrier Strike Group 3 is currently conducting routine operations in the Western Pacific theater of operations, focused on maintaining freedom of navigation and regional security. + In the Philippine Sea, USS Abraham Lincoln (CVN-72) is conducting routine flight operations with embarked Carrier Air Wing Nine as part of a scheduled deployment to the Western Pacific region. + Meanwhile in the Mediterranean Sea near the coast of southern Europe, USS Spruance (DDG-111) is operating independently as part of standing NATO maritime forces conducting presence operations. + In the Persian Gulf near the Strait of Hormuz, Expeditionary Strike Group 7 continues its deployment supporting maritime security operations in the region. + USS Bataan (LHD-5) continues operations in the Red Sea supporting regional stability efforts and conducting routine training exercises with coalition partners. + + + + """ + + respx.get("https://news.usni.org/category/fleet-tracker/feed").mock( + return_value=httpx.Response(200, text=rss_xml) + ) + + result = await fetch_usni_fleet(fetcher) + assert result["source"] == "usni-fleet-tracker" + assert result["ship_count"] >= 3 # CVN-72, DDG-111, LHD-5 + assert result["report_title"] == "USNI News Fleet and Marine Tracker: Feb. 24, 2026" + + # Check ships were extracted + hull_numbers = [s["hull_number"] for s in result["ships"]] + assert "CVN-72" in hull_numbers + assert "DDG-111" in hull_numbers + assert "LHD-5" in hull_numbers + + # Check strike groups + sg_names = [sg["name"] for sg in result["strike_groups"]] + assert "CSG-3" in sg_names + + # Check force totals extracted + assert result["force_totals"]["battle_force"]["total"] == 298 + assert result["force_totals"]["deployed"]["total"] == 100 + assert result["force_totals"]["underway"]["total"] == 72 + + # Check region classification (±200 char context windows may overlap in short text, + # but at least some ships should get classified to a known COCOM region) + regions = set(s["region"] for s in result["ships"]) + assert len(regions - {"UNKNOWN"}) > 0 # At least one classified + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_usni_fleet_empty_feed(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.usni_fleet import fetch_usni_fleet + + rss_xml = """ + Empty""" + + respx.get("https://news.usni.org/category/fleet-tracker/feed").mock( + return_value=httpx.Response(200, text=rss_xml) + ) + + result = await fetch_usni_fleet(fetcher) + assert result["source"] == "usni-fleet-tracker" + assert "error" in result + assert result["ship_count"] == 0 + + +# --------------------------------------------------------------------------- +# Trade Routes (static — no HTTP mock needed) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fetch_trade_routes() -> None: + from world_intel_mcp.sources.geospatial import fetch_trade_routes + + result = await fetch_trade_routes() + assert result["source"] == "static-geospatial" + assert result["count"] > 0 + assert result["total_oil_flow_mbd"] > 0 + assert "by_type" in result + assert "chokepoint" in result["by_type"] + + +@pytest.mark.asyncio +async def test_fetch_trade_routes_filter_type() -> None: + from world_intel_mcp.sources.geospatial import fetch_trade_routes + + result = await fetch_trade_routes(route_type="canal") + assert result["source"] == "static-geospatial" + assert result["count"] > 0 + assert all(r["type"] == "canal" for r in result["routes"]) + + +@pytest.mark.asyncio +async def test_fetch_trade_routes_filter_country() -> None: + from world_intel_mcp.sources.geospatial import fetch_trade_routes + + result = await fetch_trade_routes(country="EGY") + assert result["source"] == "static-geospatial" + assert result["count"] > 0 + assert all("EGY" in r["countries"] for r in result["routes"]) + + +# --------------------------------------------------------------------------- +# Cloud Regions (static — no HTTP mock needed) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fetch_cloud_regions() -> None: + from world_intel_mcp.sources.geospatial import fetch_cloud_regions + + result = await fetch_cloud_regions() + assert result["source"] == "static-geospatial" + assert result["count"] > 0 + assert "by_provider" in result + assert "AWS" in result["by_provider"] + + +@pytest.mark.asyncio +async def test_fetch_cloud_regions_filter_provider() -> None: + from world_intel_mcp.sources.geospatial import fetch_cloud_regions + + result = await fetch_cloud_regions(provider="GCP") + assert result["source"] == "static-geospatial" + assert result["count"] > 0 + assert all(r["provider"] == "GCP" for r in result["regions"]) + + +# --------------------------------------------------------------------------- +# Financial Centers (static — no HTTP mock needed) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fetch_financial_centers() -> None: + from world_intel_mcp.sources.geospatial import fetch_financial_centers + + result = await fetch_financial_centers() + assert result["source"] == "static-geospatial" + assert result["count"] > 0 + assert "by_country" in result + + +@pytest.mark.asyncio +async def test_fetch_financial_centers_filter_rank() -> None: + from world_intel_mcp.sources.geospatial import fetch_financial_centers + + result = await fetch_financial_centers(min_rank=5) + assert result["source"] == "static-geospatial" + assert result["count"] > 0 + assert result["count"] <= 5 + assert all(fc["gfci_rank"] <= 5 for fc in result["centers"]) + + +@pytest.mark.asyncio +async def test_fetch_financial_centers_filter_country() -> None: + from world_intel_mcp.sources.geospatial import fetch_financial_centers + + result = await fetch_financial_centers(country="USA") + assert result["source"] == "static-geospatial" + assert result["count"] > 0 + assert all(fc["iso3"] == "USA" for fc in result["centers"])