feat: phase 15 — 100 RSS feeds, 45 tests, 44 CLI commands, keyboard shortcuts

- Tests: 14 new tests for Phase 13-14 tools (BTC technicals, central
  banks, USNI fleet, trade routes, cloud regions, financial centers)
- RSS feeds: 70→100 across 18 categories (added Europe, South Asia,
  Health, expanded Africa, think tanks, science, energy, security)
- Dashboard: 16 keyboard shortcuts with help overlay (?), layer toggles
  (1-9,0), zoom (+/-), fullscreen (f), toggle all (a), layer panel (l)
- CLI: 25→44 commands covering all major tool domains (btc, central-banks,
  shipping, social, disease, elections, nuclear, space, sanctions,
  ai-watch, fleet, hn, gh-trending, arxiv, spending, bases, exchanges)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc Shade
2026-02-26 17:40:49 -05:00
co-authored by Claude Opus 4.6
parent 4dda867968
commit a5a1e207a7
5 changed files with 931 additions and 29 deletions
+440 -3
View File
@@ -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)
# ---------------------------------------------------------------------------
+124 -5
View File
@@ -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 = '&#x25C0;';
$('#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 ? '&#x25B6;' : '&#x25C0;';
$('#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 ════════════
+67
View File
@@ -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] = {
+277
View File
@@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>USNI News Fleet Tracker</title>
<item>
<title>USNI News Fleet and Marine Tracker: Feb. 24, 2026</title>
<link>https://news.usni.org/2026/02/24/fleet-tracker</link>
<pubDate>Mon, 24 Feb 2026 14:00:00 GMT</pubDate>
<description>
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.
</description>
</item>
</channel>
</rss>"""
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 = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>Empty</title></channel></rss>"""
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"])