Initial commit
This commit is contained in:
@@ -0,0 +1,875 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434/api/generate"
|
||||
MODEL_NAME = "llama3.2"
|
||||
|
||||
# CLI backend: "claude" uses Claude Code CLI (no API key needed if logged in),
|
||||
# "gemini" uses Gemini CLI (free with Google account), "ollama" = local llama3.2
|
||||
# Auto-detect: prefers claude > gemini > ollama
|
||||
def _detect_cli_backend() -> str:
|
||||
for tool in ("gemini", "claude"):
|
||||
if shutil.which(tool):
|
||||
return tool
|
||||
return "ollama"
|
||||
|
||||
_CLI_BACKEND = _detect_cli_backend()
|
||||
|
||||
|
||||
async def _query_cli(prompt: str) -> dict | None:
|
||||
"""Call claude or gemini CLI as subprocess, parse JSON from response."""
|
||||
try:
|
||||
cmd = [_CLI_BACKEND, "-p", prompt]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30.0)
|
||||
raw = stdout.decode().strip()
|
||||
if not raw:
|
||||
return None
|
||||
# Strip markdown code fences if present
|
||||
if "```json" in raw:
|
||||
raw = raw.split("```json", 1)[1].split("```", 1)[0].strip()
|
||||
elif "```" in raw:
|
||||
raw = raw.split("```", 1)[1].split("```", 1)[0].strip()
|
||||
# Extract first JSON object if there's surrounding text
|
||||
m = re.search(r'\{.*\}', raw, re.DOTALL)
|
||||
if m:
|
||||
raw = m.group(0)
|
||||
parsed = json.loads(raw)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception as exc:
|
||||
print(f"[AI] CLI query failed ({_CLI_BACKEND}): {exc}")
|
||||
return None
|
||||
THREAT_LEVELS = ("LOW", "GUARDED", "ELEVATED", "HIGH", "SEVERE")
|
||||
|
||||
KEYWORD_SCORES = {
|
||||
"war": 4,
|
||||
"missile": 4,
|
||||
"strike": 4,
|
||||
"attack": 4,
|
||||
"explosion": 3,
|
||||
"drone": 3,
|
||||
"nuclear": 5,
|
||||
"cyber": 3,
|
||||
"hijack": 4,
|
||||
"sanction": 2,
|
||||
"military": 3,
|
||||
"troops": 3,
|
||||
"border": 2,
|
||||
"earthquake": 2,
|
||||
"tsunami": 4,
|
||||
}
|
||||
|
||||
# Hotspot regions with bounding boxes for spatial movement analysis
|
||||
# bbox = (lat_min, lon_min, lat_max, lon_max)
|
||||
HOTSPOT_REGIONS = {
|
||||
"ukraine": {
|
||||
"name": "Ukraine / Black Sea", "lat": 48.38, "lon": 31.17,
|
||||
"bbox": (44, 25, 52, 40),
|
||||
"keywords": ["ukraine", "kyiv", "kharkiv", "crimea", "donbas", "odesa", "bakhmut", "zaporizhzhia"],
|
||||
"baseline": "Escalation risk around frontline",
|
||||
},
|
||||
"russia": {
|
||||
"name": "Western Russia", "lat": 55.76, "lon": 37.62,
|
||||
"bbox": (52, 30, 62, 48),
|
||||
"keywords": ["russia", "moscow", "kursk"],
|
||||
"baseline": "Military posture shifts",
|
||||
},
|
||||
"israel": {
|
||||
"name": "Israel / Palestine", "lat": 31.05, "lon": 34.85,
|
||||
"bbox": (29, 33, 33.5, 36),
|
||||
"keywords": ["israel", "gaza", "west bank", "tel aviv", "jerusalem", "rafah", "idf", "hamas", "hezbollah"],
|
||||
"baseline": "Cross-border retaliation risk",
|
||||
},
|
||||
"iran": {
|
||||
"name": "Iran", "lat": 32.43, "lon": 53.69,
|
||||
"bbox": (25, 44, 40, 63),
|
||||
"keywords": ["iran", "tehran", "irgc"],
|
||||
"baseline": "Strategic force posture shift",
|
||||
},
|
||||
"syria_lebanon": {
|
||||
"name": "Syria / Lebanon", "lat": 34.80, "lon": 37.00,
|
||||
"bbox": (32, 35, 37.5, 42),
|
||||
"keywords": ["syria", "damascus", "lebanon", "aleppo"],
|
||||
"baseline": "Proxy activity risk",
|
||||
},
|
||||
"taiwan": {
|
||||
"name": "Taiwan Strait", "lat": 23.70, "lon": 120.96,
|
||||
"bbox": (21, 117, 27, 124),
|
||||
"keywords": ["taiwan", "taipei", "taiwan strait"],
|
||||
"baseline": "Maritime and airspace pressure",
|
||||
},
|
||||
"south_china_sea": {
|
||||
"name": "South China Sea", "lat": 12.0, "lon": 113.0,
|
||||
"bbox": (5, 107, 22, 120),
|
||||
"keywords": ["south china sea", "spratly", "paracel"],
|
||||
"baseline": "Naval maneuvering risk",
|
||||
},
|
||||
"korea": {
|
||||
"name": "Korean Peninsula", "lat": 37.57, "lon": 126.98,
|
||||
"bbox": (33, 124, 43, 131),
|
||||
"keywords": ["korea", "pyongyang", "dprk", "north korea", "south korea", "seoul"],
|
||||
"baseline": "Missile test / military response risk",
|
||||
},
|
||||
"red_sea": {
|
||||
"name": "Red Sea / Bab-el-Mandeb", "lat": 15.0, "lon": 42.0,
|
||||
"bbox": (10, 38, 22, 46),
|
||||
"keywords": ["red sea", "houthi", "yemen", "bab-el-mandeb", "aden"],
|
||||
"baseline": "Shipping disruption risk",
|
||||
},
|
||||
"hormuz": {
|
||||
"name": "Strait of Hormuz", "lat": 26.58, "lon": 56.25,
|
||||
"bbox": (24, 54, 28, 58),
|
||||
"keywords": ["hormuz", "persian gulf"],
|
||||
"baseline": "Energy transit disruption risk",
|
||||
},
|
||||
"suez": {
|
||||
"name": "Suez Canal", "lat": 29.93, "lon": 32.55,
|
||||
"bbox": (28, 31, 32, 34),
|
||||
"keywords": ["suez"],
|
||||
"baseline": "Maritime chokepoint risk",
|
||||
},
|
||||
"sahel": {
|
||||
"name": "Sahel / West Africa", "lat": 14.0, "lon": 0.0,
|
||||
"bbox": (8, -10, 20, 15),
|
||||
"keywords": ["mali", "niger", "burkina faso", "sahel", "nigeria"],
|
||||
"baseline": "Insurgency / instability risk",
|
||||
},
|
||||
}
|
||||
|
||||
# ── Region definitions for grouping events into theaters ──
|
||||
REGION_DEFS: dict[str, list[str]] = {
|
||||
"Eastern Europe": [
|
||||
"Ukraine", "Russia", "Crimea", "Donbas", "Donbass", "Kharkiv", "Kyiv",
|
||||
"Kiev", "Odesa", "Mariupol", "Bakhmut", "Zaporizhzhia", "Kherson",
|
||||
"Moldova", "Belarus", "Avdiivka",
|
||||
],
|
||||
"Middle East": [
|
||||
"Israel", "Gaza", "Palestine", "Lebanon", "Hezbollah", "Syria",
|
||||
"Damascus", "Iran", "Tehran", "Iraq", "Baghdad", "Jordan",
|
||||
"West Bank", "Rafah", "Jenin", "Nablus", "Jerusalem", "Tel Aviv",
|
||||
],
|
||||
"Red Sea & Horn of Africa": [
|
||||
"Red Sea", "Yemen", "Houthi", "Somalia", "Sudan", "Ethiopia",
|
||||
"Bab-el-Mandeb", "Suez",
|
||||
],
|
||||
"East Asia": [
|
||||
"China", "Taiwan", "Taipei", "North Korea", "Pyongyang",
|
||||
"South Korea", "Japan", "South China Sea", "Taiwan Strait",
|
||||
"East China Sea",
|
||||
],
|
||||
"South Asia": [
|
||||
"India", "Pakistan", "Afghanistan", "Kashmir", "Myanmar", "Kabul",
|
||||
],
|
||||
"Sub-Saharan Africa": [
|
||||
"Mali", "Niger", "Nigeria", "Congo", "Sahel", "Burkina Faso",
|
||||
"Mozambique", "Libya",
|
||||
],
|
||||
"Europe / NATO": [
|
||||
"NATO", "Poland", "Finland", "Sweden", "Norway", "Baltic",
|
||||
"Germany", "France", "UK", "Serbia", "Kosovo", "Georgia", "Romania",
|
||||
],
|
||||
}
|
||||
|
||||
_WEIGHT_ORDER = {"CRITICAL": 0, "HIGH": 1, "ELEVATED": 2, "GUARDED": 3, "ACTIVE": 4}
|
||||
|
||||
|
||||
def _match_region(title: str) -> str | None:
|
||||
"""Match a headline to a geopolitical region."""
|
||||
tl = title.lower()
|
||||
for region, keywords in REGION_DEFS.items():
|
||||
for kw in keywords:
|
||||
if kw.lower() in tl:
|
||||
return region
|
||||
return None
|
||||
|
||||
|
||||
def _regional_briefs(news_items: list, conflicts: list) -> list[dict]:
|
||||
"""Group events by region and produce per-region threat assessments."""
|
||||
region_events: dict[str, list] = {}
|
||||
|
||||
for item in (conflicts or []):
|
||||
region = _match_region(item.get("title", ""))
|
||||
if region:
|
||||
region_events.setdefault(region, []).append(item)
|
||||
|
||||
for item in (news_items or [])[:30]:
|
||||
region = _match_region(item.get("title", ""))
|
||||
if region:
|
||||
region_events.setdefault(region, []).append(
|
||||
{"title": item["title"], "severity": "INFO", "event_type": "INTEL"}
|
||||
)
|
||||
|
||||
briefs = []
|
||||
for region, events in region_events.items():
|
||||
sev_counts: dict[str, int] = {}
|
||||
for ev in events:
|
||||
s = ev.get("severity", "INFO")
|
||||
sev_counts[s] = sev_counts.get(s, 0) + 1
|
||||
|
||||
critical = sev_counts.get("CRITICAL", 0)
|
||||
high = sev_counts.get("HIGH", 0)
|
||||
|
||||
if critical >= 3:
|
||||
status = "CRITICAL"
|
||||
elif critical >= 1 or high >= 3:
|
||||
status = "HIGH"
|
||||
elif high >= 1:
|
||||
status = "ELEVATED"
|
||||
else:
|
||||
status = "ACTIVE"
|
||||
|
||||
top = [e for e in events if e.get("severity") in ("CRITICAL", "HIGH")][:3]
|
||||
if top:
|
||||
summary = "; ".join(
|
||||
e.get("event_type", e.get("title", "")[:40]) for e in top[:2]
|
||||
)
|
||||
else:
|
||||
summary = f"{len(events)} events tracked in region"
|
||||
|
||||
briefs.append({
|
||||
"region": region,
|
||||
"status": status,
|
||||
"event_count": len(events),
|
||||
"critical": critical,
|
||||
"high": high,
|
||||
"summary": summary[:120],
|
||||
})
|
||||
|
||||
briefs.sort(key=lambda b: (_WEIGHT_ORDER.get(b["status"], 5), -b["event_count"]))
|
||||
return briefs[:6]
|
||||
|
||||
|
||||
def _key_drivers(
|
||||
score: int, reasons: list[str], gps_data: list, bgp_data: dict,
|
||||
conflicts: list, space_weather: dict, planes: list = None, ships: list = None,
|
||||
) -> list[dict]:
|
||||
"""Identify and rank the top threat signal drivers."""
|
||||
drivers: list[dict] = []
|
||||
|
||||
# GPS Interference
|
||||
gps_count = len(gps_data or [])
|
||||
if gps_count:
|
||||
drivers.append({
|
||||
"signal": "GPS Interference",
|
||||
"detail": f"{gps_count} active jamming zone{'s' if gps_count > 1 else ''} detected via ADS-B navigation anomalies",
|
||||
"weight": "CRITICAL" if gps_count >= 3 else "HIGH",
|
||||
"icon": "⬡",
|
||||
})
|
||||
|
||||
# Military Conflicts
|
||||
if conflicts:
|
||||
critical = sum(1 for c in conflicts if c.get("severity") == "CRITICAL")
|
||||
high = sum(1 for c in conflicts if c.get("severity") == "HIGH")
|
||||
active_regions = set(
|
||||
_match_region(c.get("title", "")) for c in conflicts
|
||||
if _match_region(c.get("title", ""))
|
||||
)
|
||||
if critical or high:
|
||||
drivers.append({
|
||||
"signal": "Military Activity",
|
||||
"detail": f"{critical} CRITICAL, {high} HIGH severity events across {len(active_regions)} region{'s' if len(active_regions) != 1 else ''}",
|
||||
"weight": "CRITICAL" if critical >= 5 else "HIGH" if critical >= 1 else "ELEVATED",
|
||||
"icon": "⚔",
|
||||
})
|
||||
|
||||
# BGP Routing
|
||||
bgp_status = str((bgp_data or {}).get("status", "STABLE")).upper()
|
||||
if bgp_status in ("ELEVATED", "CRITICAL"):
|
||||
updates = (bgp_data or {}).get("total_updates", 0)
|
||||
drivers.append({
|
||||
"signal": "BGP Routing",
|
||||
"detail": f"Internet routing instability: {updates:,} updates/hour ({bgp_status})",
|
||||
"weight": bgp_status,
|
||||
"icon": "📡",
|
||||
})
|
||||
|
||||
# Space Weather
|
||||
sw = space_weather or {}
|
||||
kp = sw.get("kp_index", 0)
|
||||
if isinstance(kp, (int, float)) and kp >= 4:
|
||||
drivers.append({
|
||||
"signal": "Space Weather",
|
||||
"detail": f"Geomagnetic storm Kp={kp:.1f} — {sw.get('description', 'HF radio / satellite disruption risk')}",
|
||||
"weight": "CRITICAL" if kp >= 7 else "HIGH" if kp >= 5 else "ELEVATED",
|
||||
"icon": "☀",
|
||||
})
|
||||
|
||||
# News keywords
|
||||
keyword_drivers = [r for r in reasons if "×" in r]
|
||||
if keyword_drivers:
|
||||
top_kw = keyword_drivers[:3]
|
||||
drivers.append({
|
||||
"signal": "Intel Keywords",
|
||||
"detail": f"High-weight terms in news: {', '.join(top_kw)}",
|
||||
"weight": "ELEVATED" if score >= 8 else "GUARDED",
|
||||
"icon": "📰",
|
||||
})
|
||||
|
||||
# Military air movement
|
||||
mil_count = sum(1 for p in (planes or []) if p.get("military"))
|
||||
total_planes = len(planes or [])
|
||||
if mil_count >= 5:
|
||||
drivers.append({
|
||||
"signal": "Military Air Movement",
|
||||
"detail": f"{mil_count} military aircraft tracked globally ({total_planes:,} total airframes monitored)",
|
||||
"weight": "HIGH" if mil_count >= 30 else "ELEVATED" if mil_count >= 15 else "GUARDED",
|
||||
"icon": "✈",
|
||||
})
|
||||
|
||||
# Maritime movement
|
||||
ship_count = len(ships or [])
|
||||
if ship_count >= 10:
|
||||
tankers = sum(1 for s in (ships or []) if str(s.get("type", "")).lower() in ("tanker", "lng carrier"))
|
||||
drivers.append({
|
||||
"signal": "Maritime Activity",
|
||||
"detail": f"{ship_count} vessels tracked globally" + (f" ({tankers} tankers near chokepoints)" if tankers >= 5 else ""),
|
||||
"weight": "ELEVATED" if ship_count >= 100 else "GUARDED",
|
||||
"icon": "🚢",
|
||||
})
|
||||
|
||||
drivers.sort(key=lambda d: _WEIGHT_ORDER.get(d["weight"], 5))
|
||||
return drivers[:6]
|
||||
|
||||
|
||||
def _correlations(
|
||||
gps_data: list, conflicts: list, bgp_data: dict, space_weather: dict,
|
||||
) -> list[str]:
|
||||
"""Find cross-domain signal correlations."""
|
||||
out: list[str] = []
|
||||
|
||||
conflict_regions = set(
|
||||
_match_region(c.get("title", "")) for c in (conflicts or [])
|
||||
if _match_region(c.get("title", ""))
|
||||
)
|
||||
|
||||
if gps_data and conflicts:
|
||||
for z in gps_data:
|
||||
glat, glon = z.get("lat", 0), z.get("lon", 0)
|
||||
if 35 < glat < 55 and 25 < glon < 45 and "Eastern Europe" in conflict_regions:
|
||||
out.append(
|
||||
"GPS jamming in Eastern Europe coincides with active military operations "
|
||||
"— likely electronic warfare activity"
|
||||
)
|
||||
break
|
||||
for z in gps_data:
|
||||
glat, glon = z.get("lat", 0), z.get("lon", 0)
|
||||
if 25 < glat < 40 and 30 < glon < 50 and "Middle East" in conflict_regions:
|
||||
out.append(
|
||||
"GPS interference near Middle Eastern conflict zones suggests coordinated "
|
||||
"electronic warfare alongside kinetic operations"
|
||||
)
|
||||
break
|
||||
|
||||
bgp_status = str((bgp_data or {}).get("status", "STABLE")).upper()
|
||||
if bgp_status in ("ELEVATED", "CRITICAL") and conflicts:
|
||||
out.append(
|
||||
f"BGP routing instability ({bgp_status}) during active military operations "
|
||||
"may indicate cyber operations or infrastructure targeting"
|
||||
)
|
||||
|
||||
sw = space_weather or {}
|
||||
kp = sw.get("kp_index", 0) if isinstance(sw.get("kp_index"), (int, float)) else 0
|
||||
if kp >= 4 and gps_data:
|
||||
out.append(
|
||||
f"Elevated geomagnetic activity (Kp={kp:.1f}) may amplify GPS signal "
|
||||
"degradation in existing interference zones"
|
||||
)
|
||||
if kp >= 5:
|
||||
out.append(
|
||||
f"Geomagnetic storm (Kp={kp:.1f}) affecting HF radio propagation — "
|
||||
"military comms may shift to satellite links"
|
||||
)
|
||||
|
||||
if not out:
|
||||
out.append("No significant cross-domain signal correlations detected at this time")
|
||||
return out[:4]
|
||||
|
||||
|
||||
def _watch_items(conflicts: list, gps_data: list, space_weather: dict) -> list[str]:
|
||||
"""Generate actionable intelligence watch items."""
|
||||
items: list[str] = []
|
||||
|
||||
if conflicts:
|
||||
critical = [c for c in conflicts if c.get("severity") == "CRITICAL"]
|
||||
if critical:
|
||||
regions: dict[str, list] = {}
|
||||
for c in critical[:15]:
|
||||
r = _match_region(c.get("title", "")) or "Global"
|
||||
regions.setdefault(r, []).append(c)
|
||||
for region, evts in sorted(regions.items(), key=lambda x: -len(x[1])):
|
||||
items.append(
|
||||
f"Monitor {region}: {len(evts)} critical event{'s' if len(evts) > 1 else ''} "
|
||||
f"— {evts[0].get('event_type', 'military activity')}"
|
||||
)
|
||||
|
||||
if gps_data:
|
||||
items.append(
|
||||
f"Track {len(gps_data)} GPS interference zone{'s' if len(gps_data) > 1 else ''} "
|
||||
"— assess impact on aviation safety and military navigation"
|
||||
)
|
||||
|
||||
sw = space_weather or {}
|
||||
if sw.get("alerts"):
|
||||
items.append("Space weather alerts active — monitor satellite communication reliability")
|
||||
|
||||
if not items:
|
||||
items.append("Maintain standard surveillance posture across all domains")
|
||||
return items[:5]
|
||||
|
||||
|
||||
def _threat_from_score(score: int) -> str:
|
||||
if score >= 18:
|
||||
return "SEVERE"
|
||||
if score >= 13:
|
||||
return "HIGH"
|
||||
if score >= 8:
|
||||
return "ELEVATED"
|
||||
if score >= 4:
|
||||
return "GUARDED"
|
||||
return "LOW"
|
||||
|
||||
|
||||
def _score_from_signals(news_items, gps_data, bgp_data) -> tuple[int, list[str]]:
|
||||
score = 0
|
||||
reasons: list[str] = []
|
||||
|
||||
titles = [str(item.get("title", "")).lower() for item in news_items[:20]]
|
||||
title_blob = " ".join(titles)
|
||||
|
||||
for kw, weight in KEYWORD_SCORES.items():
|
||||
hits = title_blob.count(kw)
|
||||
if hits:
|
||||
inc = min(hits, 3) * weight
|
||||
score += inc
|
||||
reasons.append(f"{kw}×{hits}")
|
||||
|
||||
gps_count = len(gps_data or [])
|
||||
if gps_count:
|
||||
bump = min(gps_count, 12)
|
||||
score += bump
|
||||
reasons.append(f"gps_anomalies={gps_count}")
|
||||
|
||||
bgp_status = str((bgp_data or {}).get("status", "STABLE")).upper()
|
||||
if bgp_status == "ELEVATED":
|
||||
score += 3
|
||||
reasons.append("bgp=elevated")
|
||||
elif bgp_status == "CRITICAL":
|
||||
score += 6
|
||||
reasons.append("bgp=critical")
|
||||
elif bgp_status == "OFFLINE":
|
||||
score += 2
|
||||
reasons.append("bgp=offline")
|
||||
|
||||
return score, reasons
|
||||
|
||||
|
||||
def _probability(score: int, offset: int) -> str:
|
||||
pct = max(20, min(95, 35 + score * 3 + offset))
|
||||
return f"{pct}%"
|
||||
|
||||
|
||||
def _in_bbox(lat: float, lon: float, bbox: tuple) -> bool:
|
||||
return bbox[0] <= lat <= bbox[2] and bbox[1] <= lon <= bbox[3]
|
||||
|
||||
|
||||
def _heuristic_predictions(
|
||||
news_items, gps_data, score: int,
|
||||
conflicts=None, planes=None, ships=None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Generate predicted hotspots by cross-referencing:
|
||||
- news keyword mentions (intel signal)
|
||||
- conflict event severity (OSINT signal)
|
||||
- military aircraft concentrations (movement signal)
|
||||
- ship density in strategic chokepoints (maritime signal)
|
||||
- GPS interference zones (EW signal)
|
||||
"""
|
||||
titles = [str(item.get("title", "")).lower() for item in (news_items or [])[:30]]
|
||||
title_blob = " ".join(titles)
|
||||
planes = planes or []
|
||||
ships = ships or []
|
||||
conflicts = conflicts or []
|
||||
|
||||
scored: list[tuple[float, str, dict]] = []
|
||||
|
||||
for key, region in HOTSPOT_REGIONS.items():
|
||||
bbox = region["bbox"]
|
||||
|
||||
# ── 1. News keyword mentions ──
|
||||
news_hits = sum(1 for kw in region["keywords"] if kw in title_blob)
|
||||
news_score = news_hits * 3.0
|
||||
|
||||
# ── 2. Conflict event severity ──
|
||||
conflict_crit = 0
|
||||
conflict_high = 0
|
||||
for c in conflicts:
|
||||
ct = (c.get("title") or "").lower()
|
||||
if any(kw in ct for kw in region["keywords"]):
|
||||
sev = c.get("severity", "")
|
||||
if sev == "CRITICAL":
|
||||
conflict_crit += 1
|
||||
elif sev == "HIGH":
|
||||
conflict_high += 1
|
||||
conflict_score = conflict_crit * 5.0 + conflict_high * 2.0
|
||||
|
||||
# ── 3. Military aircraft in region ──
|
||||
mil_count = 0
|
||||
civ_count = 0
|
||||
for p in planes:
|
||||
plat = p.get("lat")
|
||||
plon = p.get("lon")
|
||||
if isinstance(plat, (int, float)) and isinstance(plon, (int, float)):
|
||||
if _in_bbox(plat, plon, bbox):
|
||||
if p.get("military"):
|
||||
mil_count += 1
|
||||
else:
|
||||
civ_count += 1
|
||||
mil_score = min(mil_count * 2.0, 16.0)
|
||||
|
||||
# ── 4. Ship density near strategic chokepoints ──
|
||||
ship_count = 0
|
||||
tanker_count = 0
|
||||
for s in ships:
|
||||
slat = s.get("lat")
|
||||
slon = s.get("lon")
|
||||
if isinstance(slat, (int, float)) and isinstance(slon, (int, float)):
|
||||
if _in_bbox(slat, slon, bbox):
|
||||
ship_count += 1
|
||||
if str(s.get("type", "")).lower() in ("tanker", "lng carrier"):
|
||||
tanker_count += 1
|
||||
ship_score = min(ship_count * 0.8 + tanker_count * 1.2, 10.0)
|
||||
|
||||
# ── 5. GPS interference overlap ──
|
||||
gps_overlap = 0
|
||||
for z in (gps_data or []):
|
||||
zlat = z.get("lat", 0)
|
||||
zlon = z.get("lon", 0)
|
||||
if isinstance(zlat, (int, float)) and isinstance(zlon, (int, float)):
|
||||
if _in_bbox(zlat, zlon, bbox):
|
||||
gps_overlap += 1
|
||||
ew_score = gps_overlap * 4.0
|
||||
|
||||
total = news_score + conflict_score + mil_score + ship_score + ew_score
|
||||
if total < 1.0:
|
||||
continue
|
||||
|
||||
# Build explanation string from active signals
|
||||
signals = []
|
||||
if news_hits:
|
||||
signals.append(f"{news_hits} intel mentions")
|
||||
if conflict_crit or conflict_high:
|
||||
parts = []
|
||||
if conflict_crit:
|
||||
parts.append(f"{conflict_crit} CRITICAL")
|
||||
if conflict_high:
|
||||
parts.append(f"{conflict_high} HIGH")
|
||||
signals.append(f"{'+'.join(parts)} events")
|
||||
if mil_count:
|
||||
signals.append(f"{mil_count} military aircraft tracked")
|
||||
if ship_count:
|
||||
desc = f"{ship_count} vessels"
|
||||
if tanker_count:
|
||||
desc += f" ({tanker_count} tankers)"
|
||||
signals.append(desc)
|
||||
if gps_overlap:
|
||||
signals.append(f"{gps_overlap} GPS jamming zone{'s' if gps_overlap > 1 else ''}")
|
||||
|
||||
event = region["baseline"]
|
||||
if signals:
|
||||
event += " — " + ", ".join(signals[:3])
|
||||
|
||||
prob_pct = max(25, min(95, int(30 + total * 2.5 + score * 1.5)))
|
||||
|
||||
# Build AI-style reasoning text from active signals
|
||||
reason_parts = []
|
||||
if news_hits:
|
||||
matching_kws = [kw for kw in region["keywords"] if kw in title_blob][:3]
|
||||
reason_parts.append(
|
||||
f"Multiple intelligence feeds ({news_hits}) reference "
|
||||
f"{', '.join(matching_kws) if matching_kws else 'this region'}, "
|
||||
f"indicating heightened international attention."
|
||||
)
|
||||
if conflict_crit:
|
||||
reason_parts.append(
|
||||
f"{conflict_crit} CRITICAL-severity conflict event{'s' if conflict_crit > 1 else ''} "
|
||||
f"detected via OSINT, suggesting active kinetic operations or imminent escalation."
|
||||
)
|
||||
if conflict_high:
|
||||
reason_parts.append(
|
||||
f"{conflict_high} HIGH-severity event{'s' if conflict_high > 1 else ''} "
|
||||
f"reported in the area, indicating elevated security posture."
|
||||
)
|
||||
if mil_count:
|
||||
reason_parts.append(
|
||||
f"ADS-B tracking shows {mil_count} military aircraft operating within the region"
|
||||
f"{f' alongside {civ_count} civilian flights' if civ_count > 20 else ''}, "
|
||||
f"{'which represents unusual concentration' if mil_count >= 5 else 'consistent with ongoing monitoring'}."
|
||||
)
|
||||
if ship_count:
|
||||
s_detail = f"{ship_count} vessels tracked"
|
||||
if tanker_count:
|
||||
s_detail += f" including {tanker_count} tanker{'s' if tanker_count > 1 else ''}"
|
||||
reason_parts.append(
|
||||
f"Maritime surveillance identifies {s_detail} in strategic waters, "
|
||||
f"{'raising chokepoint disruption concerns' if tanker_count >= 2 else 'indicating normal commerce flow'}."
|
||||
)
|
||||
if gps_overlap:
|
||||
reason_parts.append(
|
||||
f"Active GPS jamming/spoofing detected ({gps_overlap} zone{'s' if gps_overlap > 1 else ''}), "
|
||||
f"a strong indicator of electronic warfare activity in the area."
|
||||
)
|
||||
if not reason_parts:
|
||||
reason_parts.append(region["baseline"])
|
||||
|
||||
reason = " ".join(reason_parts)
|
||||
|
||||
scored.append((total, key, {
|
||||
"location": region["name"],
|
||||
"lat": region["lat"],
|
||||
"lon": region["lon"],
|
||||
"event": event[:200],
|
||||
"reason": reason[:500],
|
||||
"probability": f"{prob_pct}%",
|
||||
"_signals": {
|
||||
"news": news_hits, "conflicts": conflict_crit + conflict_high,
|
||||
"mil_planes": mil_count, "ships": ship_count, "gps": gps_overlap,
|
||||
},
|
||||
}))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
predictions = []
|
||||
for _, _, item in scored[:4]:
|
||||
# Strip internal fields
|
||||
item.pop("_signals", None)
|
||||
predictions.append(item)
|
||||
|
||||
# Fallback if nothing ranked
|
||||
if not predictions:
|
||||
predictions.append({
|
||||
"location": "Global Monitoring",
|
||||
"lat": 20.0, "lon": 10.0,
|
||||
"event": "No dominant hotspot; maintain broad surveillance",
|
||||
"reason": "Current intelligence signals do not indicate a concentrated threat in any single region. Continuing broad-spectrum monitoring across all sensor feeds.",
|
||||
"probability": _probability(score, 0),
|
||||
})
|
||||
|
||||
return predictions
|
||||
|
||||
|
||||
def _sanitize_prediction(item: dict, fallback_prob: str) -> dict | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
lat = item.get("lat")
|
||||
lon = item.get("lon")
|
||||
try:
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
except Exception:
|
||||
return None
|
||||
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
||||
return None
|
||||
|
||||
location = str(item.get("location", "Unknown Location")).strip()[:80]
|
||||
event = str(item.get("event", "Potential activity shift")).strip()[:180]
|
||||
prob = str(item.get("probability", fallback_prob)).strip()[:8]
|
||||
if not re.match(r"^\d{1,3}%$", prob):
|
||||
prob = fallback_prob
|
||||
|
||||
return {
|
||||
"location": location or "Unknown Location",
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"event": event or "Potential activity shift",
|
||||
"reason": str(item.get("reason", "")).strip()[:500],
|
||||
"probability": prob,
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_llm_output(raw: dict, fallback_level: str, fallback_predictions: list[dict]) -> dict:
|
||||
summary = str(raw.get("summary", "")).strip()
|
||||
if not summary:
|
||||
summary = "AI produced no summary; heuristic assessment applied."
|
||||
summary = summary[:220]
|
||||
|
||||
level = str(raw.get("threat_level", "")).upper().strip()
|
||||
if level not in THREAT_LEVELS:
|
||||
level = fallback_level
|
||||
|
||||
# Build lookup of heuristic reasons by location name for fallback
|
||||
heuristic_reasons: dict[str, str] = {}
|
||||
for fp in fallback_predictions:
|
||||
loc = str(fp.get("location", "")).lower().strip()
|
||||
if loc and fp.get("reason"):
|
||||
heuristic_reasons[loc] = fp["reason"]
|
||||
|
||||
clean_predictions: list[dict] = []
|
||||
for p in raw.get("predictions", [])[:4]:
|
||||
cleaned = _sanitize_prediction(p, fallback_predictions[0]["probability"])
|
||||
if cleaned:
|
||||
# If LLM didn't provide a reason, try to merge from heuristic
|
||||
if not cleaned.get("reason"):
|
||||
loc_key = str(cleaned.get("location", "")).lower().strip()
|
||||
cleaned["reason"] = heuristic_reasons.get(loc_key, "")
|
||||
clean_predictions.append(cleaned)
|
||||
if len(clean_predictions) >= 4:
|
||||
break
|
||||
|
||||
if len(clean_predictions) < 2:
|
||||
clean_predictions = fallback_predictions
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"threat_level": level,
|
||||
"predictions": clean_predictions,
|
||||
}
|
||||
|
||||
|
||||
async def _query_ollama(prompt: str) -> dict | None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
OLLAMA_URL,
|
||||
json={
|
||||
"model": MODEL_NAME,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
},
|
||||
timeout=20.0,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
payload = response.json()
|
||||
raw = payload.get("response", "")
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def analyze_threat(
|
||||
news_items,
|
||||
gps_data=None,
|
||||
bgp_data=None,
|
||||
conflicts=None,
|
||||
space_weather=None,
|
||||
ships=None,
|
||||
planes=None,
|
||||
):
|
||||
if not news_items and not conflicts:
|
||||
return {
|
||||
"summary": "Awaiting intelligence data...",
|
||||
"threat_level": "LOW",
|
||||
"predictions": [],
|
||||
"key_drivers": [],
|
||||
"regional_briefs": [],
|
||||
"correlations": [],
|
||||
"watch_items": ["Maintain standard surveillance posture across all domains"],
|
||||
"source": "heuristic",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
score, reasons = _score_from_signals(news_items, gps_data or [], bgp_data or {})
|
||||
|
||||
# Boost score from conflicts
|
||||
if conflicts:
|
||||
crit = sum(1 for c in conflicts if c.get("severity") == "CRITICAL")
|
||||
high = sum(1 for c in conflicts if c.get("severity") == "HIGH")
|
||||
conflict_bump = min(crit * 2 + high, 10)
|
||||
score += conflict_bump
|
||||
if conflict_bump:
|
||||
reasons.append(f"conflicts={crit}C/{high}H")
|
||||
|
||||
base_level = _threat_from_score(score)
|
||||
base_predictions = _heuristic_predictions(
|
||||
news_items, gps_data or [], score,
|
||||
conflicts=conflicts or [],
|
||||
planes=planes or [],
|
||||
ships=ships or [],
|
||||
)
|
||||
|
||||
# Build enhanced intel sections
|
||||
regional = _regional_briefs(news_items, conflicts or [])
|
||||
drivers = _key_drivers(score, reasons, gps_data or [], bgp_data or {}, conflicts or [], space_weather or {}, planes=planes, ships=ships)
|
||||
corr = _correlations(gps_data or [], conflicts or [], bgp_data or {}, space_weather or {})
|
||||
watch = _watch_items(conflicts or [], gps_data or [], space_weather or {})
|
||||
|
||||
# Build movement summary for LLM prompt
|
||||
mil_planes_total = sum(1 for p in (planes or []) if p.get("military"))
|
||||
ship_total = len(ships or [])
|
||||
movement_ctx = (
|
||||
f"Military aircraft tracked: {mil_planes_total}. "
|
||||
f"Vessels tracked: {ship_total}. "
|
||||
)
|
||||
# Summarize heuristic predictions for LLM context
|
||||
hotspot_ctx = " | ".join(
|
||||
f"{p['location']}({p['probability']}): {p['event'][:60]}"
|
||||
for p in base_predictions[:3]
|
||||
)
|
||||
|
||||
headlines = " | ".join(str(item.get('title', ''))[:80] for item in news_items[:6])
|
||||
prompt = (
|
||||
f"STRATCOM AI. Strict JSON only, no markdown.\n"
|
||||
f"News: {headlines}\n"
|
||||
f"Signals: GPS={len(gps_data or [])}, BGP={(bgp_data or {}).get('status','?')}, {movement_ctx.strip()}\n"
|
||||
f"Hotspots: {hotspot_ctx}\n"
|
||||
f'Return: {{"summary":"<1 sentence>","threat_level":"LOW|GUARDED|ELEVATED|HIGH|SEVERE",'
|
||||
f'"predictions":[{{"location":"","lat":0,"lon":0,"event":"","probability":"NN%","reason":""}}]}}'
|
||||
)
|
||||
|
||||
try:
|
||||
if _CLI_BACKEND != "ollama":
|
||||
llm_raw = await _query_cli(prompt)
|
||||
source_name = _CLI_BACKEND
|
||||
else:
|
||||
llm_raw = await _query_ollama(prompt)
|
||||
source_name = MODEL_NAME
|
||||
if llm_raw:
|
||||
sanitized = _sanitize_llm_output(llm_raw, base_level, base_predictions)
|
||||
sanitized["source"] = source_name
|
||||
sanitized["generated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
sanitized["key_drivers"] = drivers
|
||||
sanitized["regional_briefs"] = regional
|
||||
sanitized["correlations"] = corr
|
||||
sanitized["watch_items"] = watch
|
||||
return sanitized
|
||||
except Exception as exc:
|
||||
print(f"[AI] LLM path failed ({_CLI_BACKEND}), fallback engaged: {exc}")
|
||||
|
||||
summary = f"{base_level} risk posture based on {len(news_items)} intelligence items"
|
||||
if conflicts:
|
||||
summary += f" + {len(conflicts)} military events"
|
||||
if reasons:
|
||||
summary += f"; key drivers: {', '.join(reasons[:4])}."
|
||||
else:
|
||||
summary += "."
|
||||
|
||||
return {
|
||||
"summary": summary[:220],
|
||||
"threat_level": base_level,
|
||||
"predictions": base_predictions,
|
||||
"key_drivers": drivers,
|
||||
"regional_briefs": regional,
|
||||
"correlations": corr,
|
||||
"watch_items": watch,
|
||||
"source": "heuristic",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
TEST = [{"title": "Missile strike reported near border region"}, {"title": "Cyberattack impacts telecom routing"}]
|
||||
print(asyncio.run(analyze_threat(TEST, gps_data=[{"lat": 32.1, "lon": 35.0}], bgp_data={"status": "ELEVATED"})))
|
||||
@@ -0,0 +1,177 @@
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
import websockets
|
||||
import httpx
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
AIS_STREAM_URL = "wss://stream.aisstream.io/v1/stream"
|
||||
API_KEY = os.getenv("AIS_STREAM_KEY", "185a6c61223ce81ed70cec58eab359009ebd5a0e")
|
||||
|
||||
_real_vessel_cache: dict = {}
|
||||
_real_vessel_lock = asyncio.Lock()
|
||||
|
||||
def get_vessel_category(ship_type):
|
||||
if not ship_type: return "Other"
|
||||
if 70 <= ship_type <= 79: return "Cargo"
|
||||
if 80 <= ship_type <= 89: return "Tanker"
|
||||
if 60 <= ship_type <= 69: return "Passenger"
|
||||
if ship_type == 35: return "Military"
|
||||
if 30 <= ship_type <= 34: return "Fishing"
|
||||
return "Other"
|
||||
|
||||
async def ais_worker():
|
||||
if not API_KEY:
|
||||
print("[MARITIME] Missing API Key.")
|
||||
return
|
||||
print("[MARITIME] Starting Global AIS Stream...")
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(AIS_STREAM_URL, ping_interval=20, ping_timeout=20) as ws:
|
||||
await ws.send(json.dumps({
|
||||
"APIKey": API_KEY,
|
||||
"BoundingBoxes": [[[-90, -180], [90, 180]]],
|
||||
"FilterMessageTypes": ["PositionReport"]
|
||||
}))
|
||||
msg_count = 0
|
||||
async for message in ws:
|
||||
msg = json.loads(message)
|
||||
meta = msg.get("MetaData", {})
|
||||
mmsi = meta.get("MMSI")
|
||||
if not mmsi:
|
||||
continue
|
||||
async with _real_vessel_lock:
|
||||
mid = str(mmsi)
|
||||
if mid not in _real_vessel_cache:
|
||||
_real_vessel_cache[mid] = {"id": mid, "source": "aisstream"}
|
||||
v = _real_vessel_cache[mid]
|
||||
v["last_seen"] = datetime.now(timezone.utc)
|
||||
name = meta.get("ShipName", "").strip()
|
||||
if name:
|
||||
v["name"] = name
|
||||
elif "name" not in v:
|
||||
v["name"] = f"MMSI-{mmsi}"
|
||||
ship_type = meta.get("ShipType", 0)
|
||||
v["type"] = get_vessel_category(ship_type)
|
||||
if msg["MessageType"] == "PositionReport":
|
||||
pos = msg["Message"]["PositionReport"]
|
||||
lat = pos.get("Latitude")
|
||||
lon = pos.get("Longitude")
|
||||
# Filter invalid coordinates (land-based noise, 0/0, out of range)
|
||||
if (lat is None or lon is None
|
||||
or abs(lat) > 90 or abs(lon) > 180
|
||||
or (abs(lat) < 0.1 and abs(lon) < 0.1)):
|
||||
continue
|
||||
v["lat"] = round(lat, 5)
|
||||
v["lon"] = round(lon, 5)
|
||||
v["velocity"] = round(pos.get("Sog", 0) * 0.5144, 2)
|
||||
v["heading"] = pos.get("Cog", 0)
|
||||
msg_count += 1
|
||||
if msg_count % 500 == 0:
|
||||
print(f"[MARITIME] {len(_real_vessel_cache)} vessels cached")
|
||||
except Exception as e:
|
||||
print(f"[MARITIME] Stream error: {e}. Reconnecting in 10s...")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
async def ais_pruner():
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
now = datetime.now(timezone.utc)
|
||||
async with _real_vessel_lock:
|
||||
stale = [k for k, v in _real_vessel_cache.items()
|
||||
if (now - v["last_seen"]).total_seconds() > 1200]
|
||||
for k in stale:
|
||||
del _real_vessel_cache[k]
|
||||
|
||||
|
||||
async def _fetch_digitraffic() -> list:
|
||||
"""
|
||||
Digitraffic AIS — free, no key, Baltic/European coverage.
|
||||
Uses `from` parameter to get only vessels seen in the last 20 minutes.
|
||||
Typically returns 300–700 active vessels with fresh positions.
|
||||
"""
|
||||
import time
|
||||
try:
|
||||
from_ms = int((time.time() - 1200) * 1000) # last 20 minutes
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Digitraffic-User": "GodsEye/3.0",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.get(
|
||||
f"https://meri.digitraffic.fi/api/ais/v1/locations?from={from_ms}",
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
print(f"[MARITIME] Digitraffic {r.status_code}")
|
||||
return []
|
||||
raw = r.content
|
||||
try:
|
||||
data = json.loads(gzip.decompress(raw))
|
||||
except Exception:
|
||||
data = r.json()
|
||||
|
||||
features = data.get("features", []) if isinstance(data, dict) else []
|
||||
ships = []
|
||||
for item in features:
|
||||
geo = (item.get("geometry") or {}).get("coordinates", [])
|
||||
if len(geo) < 2:
|
||||
continue
|
||||
lon, lat = geo[0], geo[1]
|
||||
if abs(lat) > 90 or abs(lon) > 180 or (abs(lat) < 0.01 and abs(lon) < 0.01):
|
||||
continue
|
||||
props = item.get("properties", {})
|
||||
mmsi = str(item.get("mmsi") or props.get("mmsi", ""))
|
||||
sog = props.get("sog", 0) or 0
|
||||
heading = props.get("heading", 0) or props.get("cog", 0) or 0
|
||||
ships.append({
|
||||
"id": f"dt_{mmsi}",
|
||||
"name": f"MMSI-{mmsi}",
|
||||
"lat": round(lat, 5),
|
||||
"lon": round(lon, 5),
|
||||
"velocity": round(sog * 0.5144, 2),
|
||||
"heading": heading,
|
||||
"type": "Cargo",
|
||||
"source": "digitraffic",
|
||||
})
|
||||
print(f"[MARITIME] Digitraffic: {len(ships)} vessels (last 20min)")
|
||||
return ships
|
||||
except Exception as e:
|
||||
print(f"[MARITIME] Digitraffic error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
_dt_cache: list = []
|
||||
_dt_cache_time: float = 0.0
|
||||
_DT_TTL = 300.0 # refresh Digitraffic every 5 minutes
|
||||
|
||||
|
||||
async def fetch_ships() -> list:
|
||||
"""Merge AISStream (global, real-time) + Digitraffic (Baltic, 5-min cache)."""
|
||||
import time
|
||||
global _dt_cache, _dt_cache_time
|
||||
|
||||
# Refresh Digitraffic cache if stale
|
||||
if time.monotonic() - _dt_cache_time > _DT_TTL:
|
||||
_dt_cache = await _fetch_digitraffic()
|
||||
_dt_cache_time = time.monotonic()
|
||||
|
||||
async with _real_vessel_lock:
|
||||
stream_ships = [
|
||||
v for v in _real_vessel_cache.values()
|
||||
if v.get("lat") is not None and v.get("lon") is not None
|
||||
]
|
||||
|
||||
# Merge: AISStream takes priority, Digitraffic fills in the rest
|
||||
if stream_ships:
|
||||
stream_ids = {s["id"] for s in stream_ships}
|
||||
dt_extra = [s for s in _dt_cache if s["id"] not in stream_ids]
|
||||
return stream_ships + dt_extra
|
||||
|
||||
return _dt_cache
|
||||
@@ -0,0 +1,71 @@
|
||||
import httpx
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# RIPE Stat - free API, no key required
|
||||
# Fetch BGP update activity (withdrawals/announcements) for a given time window
|
||||
RIPE_UPDATES_URL = "https://stat.ripe.net/data/bgp-updates/data.json"
|
||||
RIPE_ROUTING_URL = "https://stat.ripe.net/data/routing-status/data.json"
|
||||
|
||||
# Monitor ASNs of strategic internet infrastructure
|
||||
MONITORED_ASNS = [
|
||||
"AS13335", # Cloudflare
|
||||
"AS15169", # Google
|
||||
"AS8075", # Microsoft
|
||||
"AS16509", # Amazon AWS
|
||||
"AS3356", # Lumen/Level3 (backbone)
|
||||
]
|
||||
|
||||
async def fetch_bgp_status() -> dict:
|
||||
"""
|
||||
Fetch BGP routing stability data from RIPE Stat.
|
||||
Improved: now monitors multiple strategic ASNs and aggregates stability data.
|
||||
"""
|
||||
result = {
|
||||
"status": "STABLE",
|
||||
"monitored_asns": [],
|
||||
"total_updates": 0,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Fetch routing status for a major backbone provider
|
||||
response = await client.get(
|
||||
RIPE_ROUTING_URL,
|
||||
params={"resource": "1.1.1.1"},
|
||||
timeout=10.0
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json().get("data", {})
|
||||
result["routing_data"] = {
|
||||
"prefixes_originated": data.get("prefixes_originated", []),
|
||||
"visibility": data.get("visibility", {}),
|
||||
"resource": data.get("resource", "1.1.1.1")
|
||||
}
|
||||
|
||||
# Fetch recent BGP update counts for anomaly detection
|
||||
updates_response = await client.get(
|
||||
RIPE_UPDATES_URL,
|
||||
params={"resource": "0.0.0.0/0", "hours": 1},
|
||||
timeout=10.0
|
||||
)
|
||||
if updates_response.status_code == 200:
|
||||
updates_data = updates_response.json().get("data", {})
|
||||
nr_updates = updates_data.get("nr_updates", 0)
|
||||
result["total_updates"] = nr_updates
|
||||
# Flag elevated BGP activity (>5000 updates/hour is unusual)
|
||||
if nr_updates > 20000:
|
||||
result["status"] = "CRITICAL"
|
||||
elif nr_updates > 10000:
|
||||
result["status"] = "ELEVATED"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[BGP] Fetch error: {e}")
|
||||
result["status"] = "OFFLINE"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(asyncio.run(fetch_bgp_status()))
|
||||
@@ -0,0 +1,244 @@
|
||||
import httpx
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
# GDELT DOC 2.0 API — free, no key
|
||||
# Focused specifically on military attacks and kinetic events
|
||||
GDELT_URL = (
|
||||
"https://api.gdeltproject.org/api/v2/doc/doc?"
|
||||
"query=(missile attack OR rocket attack OR airstrike OR air strike OR "
|
||||
"bombing OR drone strike OR artillery fire OR shelling OR warship attack OR "
|
||||
"naval attack OR military strike OR armed attack OR mortar OR explosion site OR "
|
||||
"IED explosion OR sniper OR combat OR military offensive OR invasion force)"
|
||||
"&mode=artlist&format=json&maxrecords=75&sourcelang=english×pan=12h"
|
||||
)
|
||||
|
||||
# Geo-coder: keyword → (lat, lon)
|
||||
GEO_DATA = {
|
||||
# Ukraine conflict zones
|
||||
"Ukraine": [48.3794, 31.1656], "Crimea": [44.9521, 34.1024],
|
||||
"Donbas": [48.0159, 37.8028], "Donbass": [48.0159, 37.8028],
|
||||
"Kherson": [46.6354, 32.6169], "Zaporizhzhia": [47.8388, 35.1396],
|
||||
"Kharkiv": [49.9935, 36.2304], "Kyiv": [50.4501, 30.5234],
|
||||
"Kiev": [50.4501, 30.5234], "Odesa": [46.4825, 30.7233],
|
||||
"Mariupol": [47.0958, 37.5483], "Bakhmut": [48.5963, 38.0000],
|
||||
"Avdiivka": [48.1344, 37.7490],
|
||||
# Russia
|
||||
"Russia": [61.5240, 105.3188], "Moscow": [55.7558, 37.6173],
|
||||
"Belarus": [53.7098, 27.9534],
|
||||
# Middle East
|
||||
"Israel": [31.0461, 34.8516], "Gaza": [31.3547, 34.3088],
|
||||
"West Bank": [31.9466, 35.3027], "Rafah": [31.2969, 34.2455],
|
||||
"Jenin": [32.4607, 35.3027], "Lebanon": [33.8547, 35.8623],
|
||||
"Hezbollah": [33.8547, 35.8623], "Syria": [34.8021, 38.9968],
|
||||
"Damascus": [33.5138, 36.2765], "Aleppo": [36.2021, 37.1343],
|
||||
"Iran": [32.4279, 53.6880], "Tehran": [35.6892, 51.3890],
|
||||
"Iraq": [33.2232, 43.6793], "Baghdad": [33.3152, 44.3661],
|
||||
"Yemen": [15.5527, 48.5164], "Houthi": [15.5527, 48.5164],
|
||||
"Saudi Arabia": [23.8859, 45.0792],
|
||||
# Asia-Pacific
|
||||
"China": [35.8617, 104.1954], "Taiwan": [23.6978, 120.9605],
|
||||
"Taipei": [25.0330, 121.5654], "Taiwan Strait": [24.0000, 119.5000],
|
||||
"North Korea": [40.3399, 127.5101], "Pyongyang": [39.0194, 125.7381],
|
||||
"South Korea": [35.9078, 127.7669], "Japan": [36.2048, 138.2529],
|
||||
"Philippines": [12.8797, 121.7740], "South China Sea": [12.0000, 113.0000],
|
||||
"Myanmar": [21.9162, 95.9560], "India": [20.5937, 78.9629],
|
||||
"Pakistan": [30.3753, 69.3451], "Afghanistan": [33.9391, 67.7100],
|
||||
"Kashmir": [34.0837, 74.7973],
|
||||
# Africa
|
||||
"Sudan": [12.8628, 30.2176], "Ethiopia": [9.1450, 40.4897],
|
||||
"Somalia": [5.1521, 46.1996], "Libya": [26.3351, 17.2283],
|
||||
"Mali": [17.5707, -3.9962], "Niger": [17.6078, 8.0817],
|
||||
"Nigeria": [9.0820, 8.6753], "Congo": [-4.0383, 21.7587],
|
||||
"Sahel": [15.4542, 0.0000], "Burkina Faso": [12.2383, -1.5616],
|
||||
"Mozambique": [-18.6657, 35.5296],
|
||||
# Americas
|
||||
"Venezuela": [6.4238, -66.5897], "Colombia": [4.5709, -74.2973],
|
||||
"Mexico": [23.6345, -102.5528],
|
||||
# Strategic waterways
|
||||
"Red Sea": [20.0000, 38.0000], "Strait of Hormuz": [26.5667, 56.2500],
|
||||
"Bab-el-Mandeb": [12.6, 43.5], "Suez Canal": [30.4550, 32.3500],
|
||||
"Black Sea": [43.4000, 34.0000], "Baltic Sea": [58.0000, 20.0000],
|
||||
"East China Sea": [30.0000, 126.0000], "Sea of Azov": [46.0000, 36.5000],
|
||||
"Persian Gulf": [26.0000, 52.0000],
|
||||
# Palestine
|
||||
"Palestine": [31.9522, 35.2332], "Jerusalem": [31.7683, 35.2137],
|
||||
"Tel Aviv": [32.0853, 34.7818], "Nablus": [32.2211, 35.2544],
|
||||
}
|
||||
|
||||
# Attack type classifier — ordered by priority (most specific first)
|
||||
# Each entry: (search_term, event_label, severity)
|
||||
ATTACK_CLASSIFIER = [
|
||||
("ballistic missile", "BALLISTIC MISSILE", "CRITICAL"),
|
||||
("cruise missile", "CRUISE MISSILE", "CRITICAL"),
|
||||
("hypersonic missile", "HYPERSONIC MISSILE", "CRITICAL"),
|
||||
("missile strike", "MISSILE STRIKE", "CRITICAL"),
|
||||
("missile attack", "MISSILE ATTACK", "CRITICAL"),
|
||||
("rocket attack", "ROCKET ATTACK", "CRITICAL"),
|
||||
("rocket barrage", "ROCKET BARRAGE", "CRITICAL"),
|
||||
("drone strike", "DRONE STRIKE", "CRITICAL"),
|
||||
("drone attack", "DRONE ATTACK", "CRITICAL"),
|
||||
("airstrike", "AIRSTRIKE", "CRITICAL"),
|
||||
("air strike", "AIRSTRIKE", "CRITICAL"),
|
||||
("air raid", "AIR RAID", "CRITICAL"),
|
||||
("bombing", "BOMBING", "CRITICAL"),
|
||||
("bomb", "EXPLOSION", "HIGH"),
|
||||
("shelling", "ARTILLERY SHELLING", "HIGH"),
|
||||
("artillery", "ARTILLERY FIRE", "HIGH"),
|
||||
("mortar", "MORTAR ATTACK", "HIGH"),
|
||||
("ied explosion", "IED EXPLOSION", "HIGH"),
|
||||
("explosion", "EXPLOSION", "HIGH"),
|
||||
("sniper", "SNIPER ACTIVITY", "MODERATE"),
|
||||
("naval attack", "NAVAL ATTACK", "HIGH"),
|
||||
("warship", "NAVAL ACTIVITY", "MODERATE"),
|
||||
("invasion", "INVASION", "CRITICAL"),
|
||||
("offensive", "MILITARY OFFENSIVE", "HIGH"),
|
||||
("combat", "COMBAT", "HIGH"),
|
||||
("attack", "ATTACK", "HIGH"),
|
||||
("strike", "STRIKE", "HIGH"),
|
||||
("troops", "GROUND FORCES", "MODERATE"),
|
||||
("military", "MILITARY ACTIVITY", "MODERATE"),
|
||||
]
|
||||
|
||||
SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MODERATE": 2}
|
||||
|
||||
EXCLUDE_KEYWORDS = [
|
||||
"sport", "football", "soccer", "la liga", "cup", "olympics",
|
||||
"tennis", "nfl", "nba", "golf", "cricket", "rugby",
|
||||
"celebrity", "fashion", "movie", "film", "oscars", "grammy",
|
||||
"recipe", "horoscope", "box office", "netflix",
|
||||
"rocket launch", "spacex", "starship", "nasa launch", # civilian launches
|
||||
]
|
||||
|
||||
|
||||
def classify_event(title: str) -> tuple[str, str]:
|
||||
"""Returns (event_label, severity) for a conflict title."""
|
||||
tl = title.lower()
|
||||
for term, label, severity in ATTACK_CLASSIFIER:
|
||||
if term in tl:
|
||||
return label, severity
|
||||
return "MILITARY EVENT", "MODERATE"
|
||||
|
||||
|
||||
def _title_similarity(a: str, b: str) -> float:
|
||||
return SequenceMatcher(None, a.lower(), b.lower()).ratio()
|
||||
|
||||
|
||||
def _parse_gdelt_date(seendate: str) -> str:
|
||||
try:
|
||||
dt = datetime.strptime(seendate, "%Y%m%dT%H%M%SZ")
|
||||
return dt.replace(tzinfo=timezone.utc).isoformat()
|
||||
except Exception:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
async def fetch_conflicts() -> list:
|
||||
"""
|
||||
Fetch military attack/conflict events from GDELT DOC API.
|
||||
Returns events classified by attack type and severity.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Retry with backoff for 429 rate limits
|
||||
for attempt in range(3):
|
||||
if attempt > 0:
|
||||
await asyncio.sleep(6 * attempt)
|
||||
response = await client.get(GDELT_URL, timeout=12.0)
|
||||
if response.status_code == 429:
|
||||
print(f"[CONFLICTS] GDELT rate limited, retry {attempt+1}/3")
|
||||
continue
|
||||
break
|
||||
if response.status_code != 200:
|
||||
print(f"[CONFLICTS] GDELT returned {response.status_code}")
|
||||
return []
|
||||
|
||||
try:
|
||||
data = response.json() if response.text.strip() else {}
|
||||
except Exception:
|
||||
print(f"[CONFLICTS] GDELT returned non-JSON body")
|
||||
return []
|
||||
raw_articles = data.get("articles", [])
|
||||
if not raw_articles:
|
||||
print("[CONFLICTS] No articles in GDELT response")
|
||||
return []
|
||||
|
||||
events = []
|
||||
|
||||
for article in raw_articles:
|
||||
title = (article.get("title") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
|
||||
tl = title.lower()
|
||||
|
||||
# Filter out non-conflict content
|
||||
if any(kw in tl for kw in EXCLUDE_KEYWORDS):
|
||||
continue
|
||||
|
||||
# Geocode
|
||||
lat, lon = None, None
|
||||
for region, coords in GEO_DATA.items():
|
||||
if re.search(r'\b' + re.escape(region) + r'\b', title, re.IGNORECASE):
|
||||
lat, lon = coords
|
||||
break
|
||||
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||
continue
|
||||
|
||||
# Deduplicate by title similarity
|
||||
is_duplicate = any(
|
||||
_title_similarity(title, ev["title"]) > 0.75
|
||||
for ev in events
|
||||
)
|
||||
if is_duplicate:
|
||||
continue
|
||||
|
||||
event_label, severity = classify_event(title)
|
||||
seendate = article.get("seendate", "")
|
||||
published = _parse_gdelt_date(seendate) if seendate else datetime.now(timezone.utc).isoformat()
|
||||
|
||||
events.append({
|
||||
"title": title,
|
||||
"url": article.get("url", ""),
|
||||
"image": article.get("urlMobileImage", ""),
|
||||
"source": article.get("domain", "Unknown"),
|
||||
"domain": article.get("sourcecountry", "Unknown"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"published_at": published,
|
||||
"event_type": event_label,
|
||||
"severity": severity,
|
||||
"type": "conflict",
|
||||
})
|
||||
|
||||
# Sort: CRITICAL first, then by date
|
||||
events.sort(key=lambda e: (
|
||||
SEVERITY_ORDER.get(e["severity"], 3),
|
||||
e["published_at"]
|
||||
), reverse=False)
|
||||
events.sort(key=lambda e: SEVERITY_ORDER.get(e["severity"], 3))
|
||||
|
||||
events = events[:40]
|
||||
|
||||
by_sev = {}
|
||||
for e in events:
|
||||
s = e["severity"]
|
||||
by_sev[s] = by_sev.get(s, 0) + 1
|
||||
|
||||
print(f"[CONFLICTS] {len(events)} military events — {by_sev} (from {len(raw_articles)} raw)")
|
||||
return events
|
||||
|
||||
except Exception as e:
|
||||
print(f"[CONFLICTS] Fetch error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(fetch_conflicts())
|
||||
print(f"\nMilitary events: {len(result)}")
|
||||
for ev in result[:15]:
|
||||
print(f" [{ev['severity']:8s}] [{ev['event_type']:22s}] {ev['title'][:70]}")
|
||||
@@ -0,0 +1,124 @@
|
||||
import httpx
|
||||
import asyncio
|
||||
import random
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Abuse.ch ThreatFox API - Real-time indicators of compromise
|
||||
THREATFOX_URL = "https://threatfox-api.abuse.ch/api/v1/"
|
||||
|
||||
CACHE_FILE = Path(__file__).parent.parent / ".cache" / "cyber.json"
|
||||
CACHE_DURATION_SEC = 300 # 5 minutes
|
||||
|
||||
def _get_cached_cyber():
|
||||
if CACHE_FILE.exists() and (datetime.now().timestamp() - CACHE_FILE.stat().st_mtime) < CACHE_DURATION_SEC:
|
||||
try:
|
||||
with open(CACHE_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except: pass
|
||||
return None
|
||||
|
||||
def _save_cache(data):
|
||||
CACHE_FILE.parent.mkdir(exist_ok=True, parents=True)
|
||||
try:
|
||||
with open(CACHE_FILE, "w") as f:
|
||||
json.dump(data, f)
|
||||
except: pass
|
||||
|
||||
# Fallback geocoding for major infrastructure if IP geocoding fails or to show targets
|
||||
CYBER_TARGETS = [
|
||||
{"name": "AWS US-East", "lat": 39.04, "lon": -77.48},
|
||||
{"name": "Google Cloud Europe", "lat": 50.11, "lon": 8.68},
|
||||
{"name": "Azure East Asia", "lat": 22.28, "lon": 114.17},
|
||||
{"name": "DE-CIX Frankfurt", "lat": 50.12, "lon": 8.67},
|
||||
{"name": "London Internet Exchange", "lat": 51.51, "lon": -0.12},
|
||||
{"name": "Equinix Ashburn", "lat": 39.03, "lon": -77.45},
|
||||
]
|
||||
|
||||
async def _get_ip_geo(client, ip):
|
||||
"""Real-time geocoding for malicious IPs using ip-api.com (free for non-commercial)."""
|
||||
try:
|
||||
# Rate limit is 45 requests per minute
|
||||
resp = await client.get(f"http://ip-api.com/json/{ip}?fields=status,lat,lon,country", timeout=2.0)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("status") == "success":
|
||||
return data.get("lat"), data.get("lon"), data.get("country")
|
||||
except: pass
|
||||
return None, None, None
|
||||
|
||||
async def fetch_cyber_warfare(bgp_data=None):
|
||||
"""
|
||||
Fetches REAL cyber threat data from Abuse.ch ThreatFox.
|
||||
Geocodes the malicious sources and maps them to critical infrastructure targets.
|
||||
"""
|
||||
cached = _get_cached_cyber()
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Query latest 20 malware/botnet indicators
|
||||
payload = {"query": "get_iocs", "days": 1}
|
||||
resp = await client.post(THREATFOX_URL, json=payload, timeout=10.0)
|
||||
|
||||
if resp.status_code != 200:
|
||||
if CACHE_FILE.exists():
|
||||
try:
|
||||
with open(CACHE_FILE, "r") as f: return json.load(f)
|
||||
except: pass
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
if data.get("query_status") != "ok":
|
||||
if CACHE_FILE.exists():
|
||||
try:
|
||||
with open(CACHE_FILE, "r") as f: return json.load(f)
|
||||
except: pass
|
||||
return []
|
||||
|
||||
iocs = data.get("data", [])
|
||||
# Filter for IP addresses (IPv4)
|
||||
ip_iocs = [i for i in iocs if i.get("threat_type") in ["botnet_cc", "payload_delivery"] and ":" not in i.get("ioc", "")]
|
||||
|
||||
attacks = []
|
||||
# Geocode only a subset to respect ip-api limits
|
||||
sample_size = min(len(ip_iocs), 8)
|
||||
sampled = random.sample(ip_iocs, sample_size) if len(ip_iocs) > sample_size else ip_iocs
|
||||
|
||||
for ioc in sampled:
|
||||
ip = ioc["ioc"].split(":")[0]
|
||||
lat, lon, country = await _get_ip_geo(client, ip)
|
||||
|
||||
if lat and lon:
|
||||
target = random.choice(CYBER_TARGETS)
|
||||
intensity = random.uniform(0.4, 1.0)
|
||||
|
||||
attacks.append({
|
||||
"id": f"real_cyb_{ioc['id']}",
|
||||
"source_name": f"{ioc['threat_type'].upper()} ({country or 'Unknown'})",
|
||||
"target_name": target["name"],
|
||||
"startLat": lat,
|
||||
"startLng": lon,
|
||||
"endLat": target["lat"],
|
||||
"endLng": target["lon"],
|
||||
"type": ioc["malware_printable"] or "Unknown Malware",
|
||||
"intensity": intensity,
|
||||
"color": "#ff003c" if "botnet" in ioc["threat_type"] else "#ff8800",
|
||||
"timestamp": ioc["first_seen"]
|
||||
})
|
||||
|
||||
_save_cache(attacks)
|
||||
return attacks
|
||||
|
||||
except Exception as e:
|
||||
print(f"[CYBER] Real data fetch error: {e}")
|
||||
if CACHE_FILE.exists():
|
||||
try:
|
||||
with open(CACHE_FILE, "r") as f: return json.load(f)
|
||||
except: pass
|
||||
return []
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(asyncio.run(fetch_cyber_warfare()))
|
||||
@@ -0,0 +1,91 @@
|
||||
import httpx
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# USGS Earthquake Hazards Program - completely free, no API key
|
||||
# Significant earthquakes (M 4.5+) from the past 7 days
|
||||
USGS_URL = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson"
|
||||
|
||||
|
||||
async def fetch_earthquakes() -> list:
|
||||
"""
|
||||
Fetches real earthquake data from USGS.
|
||||
Returns quakes with M >= 4.5 from the past week.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(USGS_URL, timeout=12.0)
|
||||
if response.status_code != 200:
|
||||
print(f"[SEISMIC] USGS returned {response.status_code}")
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
quakes = []
|
||||
|
||||
for feature in data.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
geom = feature.get("geometry", {})
|
||||
coords = geom.get("coordinates", [])
|
||||
|
||||
if len(coords) < 2:
|
||||
continue
|
||||
|
||||
lon, lat = coords[0], coords[1]
|
||||
depth_km = coords[2] if len(coords) > 2 else 0
|
||||
mag = props.get("mag")
|
||||
|
||||
if mag is None:
|
||||
continue
|
||||
# Validate coordinates
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||
continue
|
||||
|
||||
# Severity classification
|
||||
if mag >= 7.0:
|
||||
severity = "MAJOR"
|
||||
elif mag >= 6.0:
|
||||
severity = "STRONG"
|
||||
elif mag >= 5.0:
|
||||
severity = "MODERATE"
|
||||
else:
|
||||
severity = "MINOR"
|
||||
|
||||
# Convert USGS epoch ms to ISO string
|
||||
epoch_ms = props.get("time", 0)
|
||||
try:
|
||||
dt = datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc)
|
||||
time_str = dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
except Exception:
|
||||
time_str = "Unknown"
|
||||
|
||||
quakes.append({
|
||||
"id": feature.get("id", ""),
|
||||
"title": props.get("title", "Earthquake"),
|
||||
"place": props.get("place", "Unknown Location"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"depth_km": round(depth_km, 1),
|
||||
"magnitude": mag,
|
||||
"severity": severity,
|
||||
"time": time_str,
|
||||
"url": props.get("url", ""),
|
||||
"type": "earthquake",
|
||||
"felt": props.get("felt", 0),
|
||||
"tsunami": props.get("tsunami", 0),
|
||||
})
|
||||
|
||||
# Sort by magnitude descending
|
||||
quakes.sort(key=lambda q: q["magnitude"], reverse=True)
|
||||
print(f"[SEISMIC] {len(quakes)} earthquakes (M≥4.5) — strongest: M{quakes[0]['magnitude'] if quakes else 'N/A'}")
|
||||
return quakes
|
||||
|
||||
except Exception as e:
|
||||
print(f"[SEISMIC] Fetch error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(fetch_earthquakes())
|
||||
print(f"Earthquakes: {len(result)}")
|
||||
for q in result[:5]:
|
||||
print(f" M{q['magnitude']} {q['severity']} — {q['place']}")
|
||||
@@ -0,0 +1,100 @@
|
||||
import httpx
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Launch Library 2 API — completely free, no API key required
|
||||
UPCOMING_URL = "https://ll.thespacedevs.com/2.2.0/launch/upcoming/?limit=25&format=json"
|
||||
RECENT_URL = "https://ll.thespacedevs.com/2.2.0/launch/previous/?limit=10&format=json"
|
||||
|
||||
|
||||
def _parse_launch(item: dict) -> dict | None:
|
||||
"""Parse a single launch object from the LL2 API response."""
|
||||
pad = item.get("pad") or {}
|
||||
location = pad.get("location") or {}
|
||||
|
||||
try:
|
||||
lat = float(pad.get("latitude", ""))
|
||||
lon = float(pad.get("longitude", ""))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||
return None
|
||||
|
||||
provider = (item.get("launch_service_provider") or {}).get("name", "Unknown")
|
||||
rocket = ((item.get("rocket") or {}).get("configuration") or {}).get("name", "Unknown")
|
||||
mission = item.get("mission") or {}
|
||||
status = (item.get("status") or {}).get("name", "Unknown")
|
||||
|
||||
return {
|
||||
"id": item.get("id", ""),
|
||||
"name": item.get("name", "Unknown Launch"),
|
||||
"status": status,
|
||||
"net": item.get("net", ""),
|
||||
"provider": provider,
|
||||
"country": location.get("country_code", ""),
|
||||
"pad": pad.get("name", "Unknown Pad"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"image": item.get("image", ""),
|
||||
"rocket": rocket,
|
||||
"mission": mission.get("name") or "",
|
||||
"mission_description": mission.get("description") or "",
|
||||
"type": "launch",
|
||||
}
|
||||
|
||||
|
||||
async def fetch_launches() -> list:
|
||||
"""
|
||||
Fetches real rocket launch data from Launch Library 2.
|
||||
Returns combined upcoming + recent launches, sorted by date.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
upcoming_resp, recent_resp = await asyncio.gather(
|
||||
client.get(UPCOMING_URL, timeout=15.0),
|
||||
client.get(RECENT_URL, timeout=15.0),
|
||||
)
|
||||
|
||||
upcoming = []
|
||||
if upcoming_resp.status_code == 200:
|
||||
for item in upcoming_resp.json().get("results", []):
|
||||
parsed = _parse_launch(item)
|
||||
if parsed:
|
||||
upcoming.append(parsed)
|
||||
else:
|
||||
print(f"[LAUNCHES] Upcoming API returned {upcoming_resp.status_code}")
|
||||
|
||||
recent = []
|
||||
if recent_resp.status_code == 200:
|
||||
for item in recent_resp.json().get("results", []):
|
||||
parsed = _parse_launch(item)
|
||||
if parsed:
|
||||
recent.append(parsed)
|
||||
else:
|
||||
print(f"[LAUNCHES] Recent API returned {recent_resp.status_code}")
|
||||
|
||||
combined = upcoming + recent
|
||||
|
||||
# Sort by NET date (earliest first), unknown dates go last
|
||||
def sort_key(launch):
|
||||
try:
|
||||
return datetime.fromisoformat(launch["net"].replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return datetime.max.replace(tzinfo=timezone.utc)
|
||||
|
||||
combined.sort(key=sort_key)
|
||||
|
||||
print(f"[LAUNCHES] {len(upcoming)} upcoming, {len(recent)} recent launches")
|
||||
return combined
|
||||
|
||||
except Exception as e:
|
||||
print(f"[LAUNCHES] Fetch error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(fetch_launches())
|
||||
print(f"Total launches: {len(result)}")
|
||||
for launch in result[:5]:
|
||||
print(f" {launch['net'][:16]} {launch['status']:15s} {launch['name']}")
|
||||
@@ -0,0 +1,268 @@
|
||||
import httpx
|
||||
import asyncio
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
CACHE_FILE = Path(__file__).parent.parent / ".cache" / "news.json"
|
||||
CACHE_DURATION_SEC = 300 # 5 minutes
|
||||
|
||||
def _get_cached_news():
|
||||
if CACHE_FILE.exists() and (datetime.now().timestamp() - CACHE_FILE.stat().st_mtime) < CACHE_DURATION_SEC:
|
||||
try:
|
||||
with open(CACHE_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except: pass
|
||||
return None
|
||||
|
||||
def _save_cache(data):
|
||||
CACHE_FILE.parent.mkdir(exist_ok=True, parents=True)
|
||||
try:
|
||||
with open(CACHE_FILE, "w") as f:
|
||||
json.dump(data, f)
|
||||
except: pass
|
||||
|
||||
FEEDS = [
|
||||
"http://www.aljazeera.com/xml/rss/all.xml",
|
||||
"http://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
"https://www.reutersagency.com/feed/?best-topics=political-general&post_type=best",
|
||||
"https://www.theguardian.com/world/rss",
|
||||
"https://feeds.npr.org/1004/rss.xml",
|
||||
"https://foreignpolicy.com/feed/",
|
||||
"https://www.cnbc.com/id/100727362/device/rss/rss.html",
|
||||
"https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
||||
"https://feeds.washingtonpost.com/rss/world",
|
||||
]
|
||||
|
||||
# Expanded keyword geocoder — covers most geopolitically relevant regions
|
||||
GEO_DATA = {
|
||||
# Middle East
|
||||
"Iran": [32.4279, 53.6880], "Israel": [31.0461, 34.8516],
|
||||
"Gaza": [31.3547, 34.3088], "West Bank": [31.9466, 35.3027],
|
||||
"Lebanon": [33.8547, 35.8623], "Syria": [34.8021, 38.9968],
|
||||
"Yemen": [15.5527, 48.5164], "Iraq": [33.2232, 43.6793],
|
||||
"Saudi Arabia": [23.8859, 45.0792], "Jordan": [30.5852, 36.2384],
|
||||
"Kuwait": [29.3117, 47.4818], "Qatar": [25.3548, 51.1839],
|
||||
"UAE": [23.4241, 53.8478], "Bahrain": [26.0667, 50.5577],
|
||||
"Oman": [21.5126, 55.9233],
|
||||
# Europe
|
||||
"Ukraine": [48.3794, 31.1656], "Russia": [61.5240, 105.3188],
|
||||
"Germany": [51.1657, 10.4515], "France": [46.2276, 2.2137],
|
||||
"UK": [55.3781, -3.4360], "Poland": [51.9194, 19.1451],
|
||||
"Romania": [45.9432, 24.9668], "Finland": [61.9241, 25.7482],
|
||||
"Sweden": [60.1282, 18.6435], "Norway": [60.4720, 8.4689],
|
||||
"NATO": [50.8503, 4.3517], "Belarus": [53.7098, 27.9534],
|
||||
"Moldova": [47.4116, 28.3699], "Georgia": [42.3154, 43.3569],
|
||||
"Serbia": [44.0165, 20.9129], "Kosovo": [42.6026, 20.9030],
|
||||
# Asia-Pacific
|
||||
"China": [35.8617, 104.1954], "Taiwan": [23.6978, 120.9605],
|
||||
"North Korea": [40.3399, 127.5101], "South Korea": [35.9078, 127.7669],
|
||||
"Japan": [36.2048, 138.2529], "India": [20.5937, 78.9629],
|
||||
"Pakistan": [30.3753, 69.3451], "Afghanistan": [33.9391, 67.7100],
|
||||
"Myanmar": [21.9162, 95.9560], "Philippines": [12.8797, 121.7740],
|
||||
"Vietnam": [14.0583, 108.2772], "South China Sea": [12.0000, 113.0000],
|
||||
# Americas
|
||||
"USA": [37.0902, -95.7129], "Mexico": [23.6345, -102.5528],
|
||||
"Venezuela": [6.4238, -66.5897], "Colombia": [4.5709, -74.2973],
|
||||
"Cuba": [21.5218, -77.7812], "Nicaragua": [12.8654, -85.2072],
|
||||
"Haiti": [18.9712, -72.2852], "Brazil": [14.2350, -51.9253],
|
||||
"Argentina": [-38.4161, -63.6167], "Chile": [-35.6751, -71.5430],
|
||||
"Peru": [-9.1900, -75.0152], "Guyana": [4.8604, -58.9302],
|
||||
# Central Asia & Caucasus
|
||||
"Kazakhstan": [48.0196, 66.9237], "Azerbaijan": [40.1431, 47.5769],
|
||||
"Armenia": [40.0691, 45.0382], "Nagorno-Karabakh": [39.8177, 46.7528],
|
||||
"Uzbekistan": [41.3775, 64.5853], "Kyrgyzstan": [41.2044, 74.7661],
|
||||
# Specific Conflict Regions & Strategic Spots
|
||||
"Gaza": [31.3547, 34.3088], "West Bank": [31.9466, 35.3027],
|
||||
"Donbas": [48.0159, 37.8028], "Kashmir": [34.0837, 74.7973],
|
||||
"Sudan": [12.8628, 30.2176], "Darfur": [13.4175, 24.3311],
|
||||
"Tigray": [14.0323, 38.3166], "Somalia": [5.1521, 46.1996],
|
||||
"Suez Canal": [29.9329, 32.5539], "Panama Canal": [9.1012, -79.6967],
|
||||
"Bering Strait": [66.0, -169.0], "Malacca": [2.5, 102.0],
|
||||
# Cities
|
||||
"New York": [40.7128, -74.0060], "London": [51.5074, -0.1278],
|
||||
"Paris": [48.8566, 2.3522], "Brussels": [50.8503, 4.3517],
|
||||
"Geneva": [46.2044, 6.1432], "Vienna": [48.2082, 16.3738],
|
||||
"Istanbul": [41.0082, 28.9784], "Kyiv": [50.4501, 30.5234],
|
||||
"Moscow": [55.7558, 37.6173], "Tehran": [35.6892, 51.3890],
|
||||
"Beijing": [39.9042, 116.4074], "Tokyo": [35.6762, 139.6503],
|
||||
"Seoul": [37.5665, 126.9780],
|
||||
}
|
||||
|
||||
EXCLUDE_KEYWORDS = [
|
||||
"sport", "football", "soccer", "la liga", "champions league", "cup", "match",
|
||||
"olympics", "tennis", "nfl", "nba", "score", "goal", "premier league",
|
||||
"formula 1", "f1", "golf", "cricket", "rugby", "boxing", "mma",
|
||||
"celebrity", "oscars", "grammy", "fashion", "movie", "film", "series",
|
||||
"recipe", "weather forecast", "horoscope"
|
||||
]
|
||||
|
||||
# Strip HTML tags from RSS descriptions
|
||||
_TAG_RE = re.compile(r'<[^>]+>')
|
||||
|
||||
|
||||
def _strip_html(s: str) -> str:
|
||||
return _TAG_RE.sub('', s).strip()
|
||||
|
||||
|
||||
def _find_text(el: ET.Element, tag: str) -> str:
|
||||
"""Find text for a tag, checking common RSS/Atom namespaces."""
|
||||
node = el.find(tag)
|
||||
if node is not None and node.text:
|
||||
return node.text.strip()
|
||||
# Try with common namespaces
|
||||
for ns in ['{http://purl.org/dc/elements/1.1/}', '{http://purl.org/rss/1.0/}']:
|
||||
node = el.find(f'{ns}{tag}')
|
||||
if node is not None and node.text:
|
||||
return node.text.strip()
|
||||
return ''
|
||||
|
||||
|
||||
async def _fetch_single_feed(client: httpx.AsyncClient, feed_url: str) -> list[dict]:
|
||||
"""Fetch a single RSS feed directly and parse XML items."""
|
||||
articles: list[dict] = []
|
||||
try:
|
||||
resp = await client.get(feed_url, timeout=12.0, follow_redirects=True)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
|
||||
root = ET.fromstring(resp.content)
|
||||
# Determine feed title
|
||||
channel = root.find('channel')
|
||||
feed_title = 'Global Intel'
|
||||
if channel is not None:
|
||||
ft = channel.findtext('title')
|
||||
if ft:
|
||||
feed_title = ft.strip()
|
||||
else:
|
||||
# Atom feed
|
||||
ft = root.findtext('{http://www.w3.org/2005/Atom}title')
|
||||
if ft:
|
||||
feed_title = ft.strip()
|
||||
|
||||
# Find items — RSS uses <item>, Atom uses <entry>
|
||||
items = root.findall('.//item')
|
||||
if not items:
|
||||
items = root.findall('.//{http://www.w3.org/2005/Atom}entry')
|
||||
|
||||
for item in items[:10]:
|
||||
title = _find_text(item, 'title')
|
||||
if not title:
|
||||
# Atom title
|
||||
title = item.findtext('{http://www.w3.org/2005/Atom}title') or ''
|
||||
title = title.strip()
|
||||
if not title:
|
||||
continue
|
||||
|
||||
if any(kw in title.lower() for kw in EXCLUDE_KEYWORDS):
|
||||
continue
|
||||
|
||||
# Geocode from keywords
|
||||
lat, lon = None, None
|
||||
for region, coords in GEO_DATA.items():
|
||||
if re.search(r'\b' + re.escape(region) + r'\b', title, re.IGNORECASE):
|
||||
lat, lon = coords
|
||||
break
|
||||
|
||||
# Link
|
||||
link = _find_text(item, 'link')
|
||||
if not link:
|
||||
link_el = item.find('{http://www.w3.org/2005/Atom}link')
|
||||
if link_el is not None:
|
||||
link = link_el.get('href', '')
|
||||
|
||||
# Description
|
||||
desc = _find_text(item, 'description') or _find_text(item, 'summary')
|
||||
if not desc:
|
||||
desc = item.findtext('{http://www.w3.org/2005/Atom}summary') or ''
|
||||
desc = _strip_html(desc)[:200]
|
||||
|
||||
# Publication date
|
||||
pub_date = (
|
||||
_find_text(item, 'pubDate')
|
||||
or _find_text(item, 'published')
|
||||
or item.findtext('{http://www.w3.org/2005/Atom}published')
|
||||
or datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
# Image from enclosure or media:content
|
||||
image = ''
|
||||
enc = item.find('enclosure')
|
||||
if enc is not None:
|
||||
enc_url = enc.get('url', '')
|
||||
enc_type = enc.get('type', '')
|
||||
if 'image' in enc_type or enc_url.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')):
|
||||
image = enc_url
|
||||
if not image:
|
||||
media = item.find('{http://search.yahoo.com/mrss/}content')
|
||||
if media is not None:
|
||||
murl = media.get('url', '')
|
||||
if murl.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')):
|
||||
image = murl
|
||||
|
||||
# Determine Category and Severity
|
||||
title_lower = title.lower()
|
||||
category = "GEOPOLITICS"
|
||||
if any(w in title_lower for w in ["cyber", "hacking", "breach", "malware", "botnet"]):
|
||||
category = "CYBER"
|
||||
elif any(w in title_lower for w in ["satellite", "orbit", "rocket", "launch", "space", "iss"]):
|
||||
category = "SPACE"
|
||||
elif any(w in title_lower for w in ["military", "army", "navy", "airforce", "missile", "strike", "war", "conflict", "nato", "defense"]):
|
||||
category = "MILITARY"
|
||||
|
||||
severity = "MODERATE"
|
||||
if any(w in title_lower for w in ["attack", "strike", "crisis", "invasion", "nuclear", "killed"]):
|
||||
severity = "HIGH"
|
||||
if any(w in title_lower for w in ["critical", "emergency", "declaration", "imminent"]):
|
||||
severity = "CRITICAL"
|
||||
|
||||
articles.append({
|
||||
"title": title,
|
||||
"source": feed_title,
|
||||
"url": link,
|
||||
"image": image or None,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"summary": desc or "No details available.",
|
||||
"published_at": pub_date,
|
||||
"category": category,
|
||||
"severity": severity
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[NEWS] Feed error ({feed_url[:60]}): {e}")
|
||||
return articles
|
||||
|
||||
|
||||
async def fetch_news():
|
||||
"""Fetch all RSS feeds in parallel and return combined articles."""
|
||||
cached = _get_cached_news()
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
headers={"User-Agent": "GodsEye/2.0 RSS Reader"},
|
||||
) as client:
|
||||
results = await asyncio.gather(
|
||||
*[_fetch_single_feed(client, url) for url in FEEDS],
|
||||
return_exceptions=True,
|
||||
)
|
||||
articles: list[dict] = []
|
||||
for r in results:
|
||||
if isinstance(r, list):
|
||||
articles.extend(r)
|
||||
|
||||
print(f"[NEWS] Fetched {len(articles)} intelligence items from {len(FEEDS)} feeds.")
|
||||
_save_cache(articles)
|
||||
return articles
|
||||
except Exception as e:
|
||||
print(f"[NEWS] Critical error: {e}")
|
||||
# fallback to stale cache if error
|
||||
if CACHE_FILE.exists():
|
||||
try:
|
||||
with open(CACHE_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except: pass
|
||||
return []
|
||||
@@ -0,0 +1,208 @@
|
||||
import httpx
|
||||
import asyncio
|
||||
|
||||
ADSB_LOL_BASE = "https://api.adsb.lol/v2"
|
||||
|
||||
EMERGENCY_SQUAWKS = {
|
||||
"7700": "GENERAL EMERGENCY",
|
||||
"7600": "RADIO FAILURE",
|
||||
"7500": "HIJACK / UNLAWFUL INTERFERENCE",
|
||||
}
|
||||
|
||||
_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; GodsEye/3.0)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# Regional civilian coverage queries: (lat, lon, radius_nm, label)
|
||||
REGIONS = [
|
||||
(45.0, -95.0, 2500, "N.America"),
|
||||
(52.0, 8.0, 2000, "Europe"),
|
||||
(25.0, 60.0, 2000, "MiddleEast"),
|
||||
(35.0, 125.0, 2000, "E.Asia"),
|
||||
( 5.0, 110.0, 2000, "SE.Asia"),
|
||||
(-10.0, -40.0, 2500, "S.America+Africa"),
|
||||
]
|
||||
|
||||
|
||||
def _parse_ac(ac: dict, military: bool = False) -> dict | None:
|
||||
lat = ac.get("lat")
|
||||
lon = ac.get("lon")
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
alt_baro = ac.get("alt_baro", 0)
|
||||
if alt_baro == "ground":
|
||||
alt_m = 0
|
||||
else:
|
||||
alt_m = round(float(alt_baro) * 0.3048, 0) if isinstance(alt_baro, (int, float)) else 0
|
||||
squawk = str(ac.get("squawk", "")).strip()
|
||||
is_mil = military or bool(ac.get("mil")) or bool((ac.get("dbFlags", 0) or 0) & 1)
|
||||
return {
|
||||
"id": ac.get("hex", "UNKNOWN"),
|
||||
"callsign": str(ac.get("flight", "")).strip() or ac.get("hex", "UNKNOWN"),
|
||||
"country": ac.get("ownOp", ac.get("native", "")),
|
||||
"lon": round(lon, 4),
|
||||
"lat": round(lat, 4),
|
||||
"alt": alt_m,
|
||||
"velocity": round(float(ac.get("gs", 0) or 0) * 0.5144, 1),
|
||||
"heading": round(float(ac.get("track", 0) or 0), 1),
|
||||
"military": is_mil,
|
||||
"squawk": squawk,
|
||||
"_emergency": squawk in EMERGENCY_SQUAWKS,
|
||||
"type": "plane",
|
||||
# GPS quality fields for jamming detection
|
||||
"nac_p": ac.get("nac_p"), # Navigation Accuracy Category (0–11, ≥9=normal)
|
||||
"nic": ac.get("nic"), # Navigation Integrity Category (0=no integrity)
|
||||
"sil": ac.get("sil"), # Source Integrity Level (0–3)
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_military(client: httpx.AsyncClient) -> list[dict]:
|
||||
try:
|
||||
r = await client.get(f"{ADSB_LOL_BASE}/mil", headers=_HEADERS, timeout=20.0)
|
||||
if r.status_code != 200:
|
||||
print(f"[AIRSPACE] /v2/mil returned {r.status_code}")
|
||||
return []
|
||||
ac_list = r.json().get("ac", [])
|
||||
planes = []
|
||||
for ac in ac_list:
|
||||
p = _parse_ac(ac, military=True)
|
||||
if p:
|
||||
planes.append(p)
|
||||
print(f"[AIRSPACE] Military: {len(planes)} aircraft")
|
||||
return planes
|
||||
except Exception as e:
|
||||
print(f"[AIRSPACE] Military fetch error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def _fetch_region(client: httpx.AsyncClient, lat: float, lon: float, radius: int, label: str) -> list[dict]:
|
||||
try:
|
||||
url = f"{ADSB_LOL_BASE}/point/{lat}/{lon}/{radius}"
|
||||
r = await client.get(url, headers=_HEADERS, timeout=20.0)
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
ac_list = r.json().get("ac", [])
|
||||
planes = []
|
||||
for ac in ac_list:
|
||||
p = _parse_ac(ac)
|
||||
if p:
|
||||
planes.append(p)
|
||||
return planes
|
||||
except Exception as e:
|
||||
print(f"[AIRSPACE] Region {label} error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _compute_gps_interference(planes: list[dict]) -> list[dict]:
|
||||
"""
|
||||
Real GPS jamming detection from ADS-B navigation accuracy fields.
|
||||
Same method as gpsjam.org: cluster aircraft with degraded NAC_P / NIC values.
|
||||
|
||||
nac_p (Navigation Accuracy Category for Position):
|
||||
≥ 9 = normal GPS (HPU < 30m)
|
||||
7 = HPU < 0.1 NM (~185m)
|
||||
≤ 4 = HPU > 0.3 NM (~555m) → GPS quality degraded, likely jamming
|
||||
0 = HPU unknown / no fix
|
||||
|
||||
nic (Navigation Integrity Category):
|
||||
0 = no integrity assurance → strong spoofing/jamming indicator
|
||||
≥ 7 = normal
|
||||
"""
|
||||
# Grid cell size in degrees
|
||||
CELL = 2.0
|
||||
MIN_ANOMALOUS = 3 # need at least 3 aircraft showing anomalies per cell
|
||||
|
||||
cell_counts: dict[tuple, dict] = {}
|
||||
|
||||
for p in planes:
|
||||
nac = p.get("nac_p")
|
||||
nic = p.get("nic")
|
||||
lat, lon = p.get("lat"), p.get("lon")
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
|
||||
# Detect GPS-degraded aircraft
|
||||
gps_degraded = (
|
||||
(nac is not None and nac <= 4) or
|
||||
(nic is not None and nic == 0 and nac is not None and nac < 9)
|
||||
)
|
||||
if not gps_degraded:
|
||||
continue
|
||||
|
||||
# Snap to grid cell
|
||||
cell = (round(lat / CELL) * CELL, round(lon / CELL) * CELL)
|
||||
if cell not in cell_counts:
|
||||
cell_counts[cell] = {"count": 0, "lats": [], "lons": [], "min_nac": 99}
|
||||
cell_counts[cell]["count"] += 1
|
||||
cell_counts[cell]["lats"].append(lat)
|
||||
cell_counts[cell]["lons"].append(lon)
|
||||
if nac is not None:
|
||||
cell_counts[cell]["min_nac"] = min(cell_counts[cell]["min_nac"], nac)
|
||||
|
||||
zones = []
|
||||
for (clat, clon), info in cell_counts.items():
|
||||
if info["count"] < MIN_ANOMALOUS:
|
||||
continue
|
||||
count = info["count"]
|
||||
min_nac = info["min_nac"]
|
||||
intensity = (
|
||||
"CRITICAL" if count >= 10 or min_nac == 0 else
|
||||
"HIGH" if count >= 5 or min_nac <= 2 else
|
||||
"ACTIVE"
|
||||
)
|
||||
# Use centroid of affected aircraft for more accurate placement
|
||||
center_lat = sum(info["lats"]) / len(info["lats"])
|
||||
center_lon = sum(info["lons"]) / len(info["lons"])
|
||||
zones.append({
|
||||
"lat": round(center_lat, 2),
|
||||
"lon": round(center_lon, 2),
|
||||
"intensity": intensity,
|
||||
"aircraft_count": count,
|
||||
"min_nac_p": min_nac if min_nac < 99 else None,
|
||||
"source": "ADS-B NAC/NIC",
|
||||
})
|
||||
|
||||
if zones:
|
||||
print(f"[GPS] {len(zones)} jamming zones detected from {sum(z['aircraft_count'] for z in zones)} anomalous aircraft")
|
||||
return zones
|
||||
|
||||
|
||||
async def fetch_planes() -> dict:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Military first, then all regions in parallel
|
||||
tasks = [_fetch_military(client)] + [
|
||||
_fetch_region(client, lat, lon, r, label)
|
||||
for lat, lon, r, label in REGIONS
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
mil_planes = results[0] if isinstance(results[0], list) else []
|
||||
mil_ids = {p["id"] for p in mil_planes}
|
||||
|
||||
# Merge regional civilian results, deduplicate
|
||||
seen: set[str] = set(mil_ids)
|
||||
civilian: list[dict] = []
|
||||
for region_result in results[1:]:
|
||||
if not isinstance(region_result, list):
|
||||
continue
|
||||
for p in region_result:
|
||||
if p["id"] not in seen:
|
||||
seen.add(p["id"])
|
||||
civilian.append(p)
|
||||
|
||||
emergencies = [p for p in civilian if p.pop("_emergency", False)]
|
||||
for p in mil_planes:
|
||||
p.pop("_emergency", None)
|
||||
regular = [p for p in civilian if not p.get("_emergency")]
|
||||
|
||||
final = mil_planes + emergencies + regular
|
||||
interference = _compute_gps_interference(final)
|
||||
print(f"[AIRSPACE] Synced {len(final)} aircraft ({len(mil_planes)} mil, {len(emergencies)} emergency) | {len(interference)} GPS zones")
|
||||
return {"planes": final, "interference": interference, "emergencies": emergencies}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
result = asyncio.run(fetch_planes())
|
||||
print(f"Total: {len(result['planes'])}, Military: {sum(1 for p in result['planes'] if p['military'])}")
|
||||
@@ -0,0 +1,105 @@
|
||||
from skyfield.api import load
|
||||
import asyncio
|
||||
|
||||
SATELLITE_GROUPS = {
|
||||
"military": "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=tle",
|
||||
"stations": "https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle",
|
||||
"navigation": "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=tle",
|
||||
}
|
||||
|
||||
GROUP_LIMITS = {
|
||||
"military": 800,
|
||||
"stations": 50,
|
||||
"navigation": 120,
|
||||
}
|
||||
|
||||
satellites_data: dict = {}
|
||||
|
||||
|
||||
def load_satellites():
|
||||
"""
|
||||
Blocking I/O: loads TLE data from Celestrak.
|
||||
Must be called via asyncio.to_thread to avoid blocking the event loop.
|
||||
"""
|
||||
global satellites_data
|
||||
try:
|
||||
ts = load.timescale()
|
||||
loaded = {}
|
||||
for group, url in SATELLITE_GROUPS.items():
|
||||
try:
|
||||
filename = f"{group}.tle"
|
||||
sats = load.tle_file(url, filename=filename)
|
||||
loaded[group] = sats
|
||||
print(f"[SPACE] {group}: {len(sats)} satellites loaded.")
|
||||
except Exception as e:
|
||||
print(f"[SPACE] Failed to load {group}: {e}")
|
||||
satellites_data = {**loaded, "ts": ts}
|
||||
except Exception as e:
|
||||
print(f"[SPACE] Critical load failure: {e}")
|
||||
|
||||
|
||||
def _compute_positions_sync(sats: list, category: str, t, limit: int) -> list:
|
||||
"""
|
||||
CPU-bound position computation — runs in a thread executor.
|
||||
Returns dicts, does NOT mutate shared state.
|
||||
"""
|
||||
positions = []
|
||||
for sat in sats[:limit]:
|
||||
try:
|
||||
geocentric = sat.at(t)
|
||||
subpoint = geocentric.subpoint()
|
||||
lat = subpoint.latitude.degrees
|
||||
lon = subpoint.longitude.degrees
|
||||
alt = subpoint.elevation.m
|
||||
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||
continue
|
||||
|
||||
positions.append({
|
||||
"id": sat.model.satnum,
|
||||
"name": sat.name,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"alt": alt,
|
||||
"category": category,
|
||||
"type": "satellite"
|
||||
})
|
||||
except Exception:
|
||||
pass # skip satellites with bad TLE data
|
||||
return positions
|
||||
|
||||
|
||||
async def get_satellite_positions() -> list:
|
||||
if "ts" not in satellites_data:
|
||||
# First run: load everything in a background thread
|
||||
await asyncio.to_thread(load_satellites)
|
||||
|
||||
if "ts" not in satellites_data:
|
||||
print("[SPACE] Satellite data unavailable.")
|
||||
return []
|
||||
|
||||
t = satellites_data["ts"].now()
|
||||
all_positions = []
|
||||
|
||||
for group, limit in GROUP_LIMITS.items():
|
||||
if group in satellites_data:
|
||||
result = await asyncio.to_thread(
|
||||
_compute_positions_sync,
|
||||
satellites_data[group],
|
||||
group,
|
||||
t,
|
||||
limit
|
||||
)
|
||||
all_positions.extend(result)
|
||||
|
||||
print(f"[SPACE] {len(all_positions)} satellite positions computed.")
|
||||
return all_positions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_satellites()
|
||||
positions = asyncio.run(get_satellite_positions())
|
||||
print(f"Total: {len(positions)}")
|
||||
from collections import Counter
|
||||
for cat, count in Counter(p["category"] for p in positions).items():
|
||||
print(f" {cat}: {count}")
|
||||
@@ -0,0 +1,88 @@
|
||||
import httpx
|
||||
import asyncio
|
||||
|
||||
# NOAA Space Weather Prediction Center - free, no key
|
||||
NOAA_KP_URL = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json"
|
||||
NOAA_ALERTS_URL = "https://services.swpc.noaa.gov/products/alerts.json"
|
||||
NOAA_SOLAR_URL = "https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json"
|
||||
|
||||
KP_STATUS = {
|
||||
(0, 2): ("QUIET", "No significant geomagnetic activity."),
|
||||
(2, 4): ("UNSETTLED", "Minor geomagnetic activity."),
|
||||
(4, 5): ("ACTIVE", "Active geomagnetic conditions."),
|
||||
(5, 6): ("STORM-G1", "Minor geomagnetic storm (G1)."),
|
||||
(6, 7): ("STORM-G2", "Moderate geomagnetic storm (G2). HF radio disruption possible."),
|
||||
(7, 8): ("STORM-G3", "Strong geomagnetic storm (G3). Possible power grid fluctuations."),
|
||||
(8, 9): ("STORM-G4", "Severe geomagnetic storm (G4). Widespread power disruption."),
|
||||
(9, 10): ("STORM-G5", "Extreme geomagnetic storm (G5). Grid collapse risk."),
|
||||
}
|
||||
|
||||
def classify_kp(kp: float) -> tuple[str, str]:
|
||||
for (lo, hi), (status, desc) in KP_STATUS.items():
|
||||
if lo <= kp < hi:
|
||||
return status, desc
|
||||
return "STORM-G5", "Extreme geomagnetic storm."
|
||||
|
||||
|
||||
async def fetch_space_weather() -> dict:
|
||||
"""
|
||||
Fetches real space weather data from NOAA SWPC.
|
||||
- Planetary K-index (geomagnetic storm indicator)
|
||||
- Active space weather alerts
|
||||
"""
|
||||
result = {
|
||||
"kp_index": 0.0,
|
||||
"status": "UNKNOWN",
|
||||
"description": "Awaiting space weather data...",
|
||||
"alerts": [],
|
||||
"timestamp": "",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Fetch Kp index - returns list of [time_tag, Kp, ...]
|
||||
kp_resp = await client.get(NOAA_KP_URL, timeout=10.0)
|
||||
if kp_resp.status_code == 200:
|
||||
kp_data = kp_resp.json()
|
||||
# Skip header row [0], get latest reading from the end
|
||||
if len(kp_data) > 1:
|
||||
latest = kp_data[-1]
|
||||
raw_kp = latest[1] if latest[1] not in ("-1", None, "") else "0"
|
||||
kp = float(raw_kp)
|
||||
status, description = classify_kp(kp)
|
||||
result["kp_index"] = kp
|
||||
result["status"] = status
|
||||
result["description"] = description
|
||||
result["timestamp"] = latest[0]
|
||||
|
||||
# Fetch active alerts
|
||||
alerts_resp = await client.get(NOAA_ALERTS_URL, timeout=10.0)
|
||||
if alerts_resp.status_code == 200:
|
||||
alerts_raw = alerts_resp.json()
|
||||
# Filter for active, non-cancelled alerts
|
||||
active_alerts = []
|
||||
for alert in (alerts_raw or [])[:10]:
|
||||
msg = alert.get("message", "")
|
||||
if "CANCEL" not in msg and "SUMMARY" not in msg:
|
||||
# Extract first meaningful line as title
|
||||
lines = [l.strip() for l in msg.split("\n") if l.strip()]
|
||||
title = lines[0] if lines else "Space Weather Alert"
|
||||
active_alerts.append({
|
||||
"issue_time": alert.get("issue_datetime", ""),
|
||||
"title": title[:100],
|
||||
})
|
||||
result["alerts"] = active_alerts[:5]
|
||||
|
||||
print(f"[SPACEWEATHER] Kp={result['kp_index']} — {result['status']} | {len(result['alerts'])} active alerts")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[SPACEWEATHER] Fetch error: {e}")
|
||||
result["status"] = "OFFLINE"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
data = asyncio.run(fetch_space_weather())
|
||||
print(json.dumps(data, indent=2))
|
||||
@@ -0,0 +1,69 @@
|
||||
import asyncio # kept for __main__ block
|
||||
|
||||
|
||||
def _windy_embed(lat: float, lon: float) -> str:
|
||||
"""Windy's official iframe-embeddable webcam map — always works."""
|
||||
return (
|
||||
f"https://embed.windy.com/embed.html"
|
||||
f"?type=map&location=coordinates&metricRain=default&metricTemp=default"
|
||||
f"&metricWind=default&zoom=12&overlay=webcams&product=ecmwf"
|
||||
f"&level=surface&lat={lat}&lon={lon}"
|
||||
)
|
||||
|
||||
|
||||
def _windy(lat: float, lon: float) -> str:
|
||||
return f"https://www.windy.com/-Webcams/webcams?{lat},{lon},12"
|
||||
|
||||
|
||||
WEBCAM_SOURCES = [
|
||||
# North America
|
||||
{"id": "wc-nyc", "name": "Times Square, NYC", "lat": 40.7580, "lon": -73.9855},
|
||||
{"id": "wc-miami", "name": "Miami Beach, Florida", "lat": 25.7617, "lon": -80.1918},
|
||||
{"id": "wc-sf", "name": "San Francisco Bay", "lat": 37.8083, "lon": -122.4156},
|
||||
{"id": "wc-dc", "name": "Washington DC", "lat": 38.8899, "lon": -77.0091},
|
||||
# Europe
|
||||
{"id": "wc-lon", "name": "Tower Bridge, London", "lat": 51.5055, "lon": -0.0754},
|
||||
{"id": "wc-par", "name": "Eiffel Tower, Paris", "lat": 48.8584, "lon": 2.2945},
|
||||
{"id": "wc-ber", "name": "Brandenburg Gate, Berlin", "lat": 52.5163, "lon": 13.3777},
|
||||
{"id": "wc-rome", "name": "Colosseum, Rome", "lat": 41.8902, "lon": 12.4922},
|
||||
{"id": "wc-barcelona", "name": "Barcelona Beach", "lat": 41.4036, "lon": 2.1744},
|
||||
{"id": "wc-amsterdam", "name": "Dam Square, Amsterdam", "lat": 52.3731, "lon": 4.8932},
|
||||
{"id": "wc-moscow", "name": "Moscow Kremlin View", "lat": 55.7520, "lon": 37.6175},
|
||||
# Middle East & Africa
|
||||
{"id": "wc-istanbul", "name": "Bosphorus, Istanbul", "lat": 41.0422, "lon": 29.0083},
|
||||
{"id": "wc-jerusalem", "name": "Western Wall, Jerusalem", "lat": 31.7767, "lon": 35.2345},
|
||||
{"id": "wc-dxb", "name": "Dubai Marina", "lat": 25.0800, "lon": 55.1400},
|
||||
{"id": "wc-cairo", "name": "Pyramids of Giza, Cairo", "lat": 29.9792, "lon": 31.1342},
|
||||
# Asia & Pacific
|
||||
{"id": "wc-tok", "name": "Shibuya Crossing, Tokyo", "lat": 35.6595, "lon": 139.7001},
|
||||
{"id": "wc-hk", "name": "Victoria Harbour, HK", "lat": 22.2855, "lon": 114.1577},
|
||||
{"id": "wc-sin", "name": "Singapore Skyline", "lat": 1.2897, "lon": 103.8501},
|
||||
{"id": "wc-syd", "name": "Sydney Opera House", "lat": -33.8568, "lon": 151.2153},
|
||||
{"id": "wc-seoul", "name": "Seoul Skyline", "lat": 37.5665, "lon": 126.9780},
|
||||
# Strategic / military-adjacent
|
||||
{"id": "wc-gibraltar", "name": "Strait of Gibraltar", "lat": 36.1408, "lon": -5.3536},
|
||||
]
|
||||
|
||||
|
||||
async def fetch_webcams() -> list:
|
||||
webcams = [
|
||||
{
|
||||
"id": cam["id"],
|
||||
"name": cam["name"],
|
||||
"lat": cam["lat"],
|
||||
"lon": cam["lon"],
|
||||
"url": _windy(cam["lat"], cam["lon"]),
|
||||
"embed_url": _windy_embed(cam["lat"], cam["lon"]),
|
||||
"type": "webcam",
|
||||
"status": "ONLINE",
|
||||
"source": "Windy Webcams",
|
||||
}
|
||||
for cam in WEBCAM_SOURCES
|
||||
]
|
||||
print(f"[WEBCAMS] {len(webcams)} webcam locations loaded.")
|
||||
return webcams
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cams = asyncio.run(fetch_webcams())
|
||||
print(f"Total webcam locations: {len(cams)}")
|
||||
Reference in New Issue
Block a user