2026-06-04 20:30:04 -04:00
|
|
|
"""Data source ingestors — fetch from external APIs and push to NATS."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from email.utils import parsedate_to_datetime
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
import feedparser
|
|
|
|
|
import nats
|
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
from config import NATS_URL, OSINT_USER_AGENT
|
2026-08-28 09:33:19 -04:00
|
|
|
from upstream_cache import rss_cache
|
2026-07-07 17:50:51 -04:00
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
logger = logging.getLogger("osint.sources")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_rfc822(date_str: object) -> str | None:
|
|
|
|
|
"""Parse RFC-822 date string from feedparser entries."""
|
|
|
|
|
if not isinstance(date_str, str):
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return parsedate_to_datetime(date_str).astimezone(timezone.utc).isoformat()
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
return None
|
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
|
2026-07-07 17:50:51 -04:00
|
|
|
NATS_URLS = NATS_URL
|
2026-08-28 21:49:05 -04:00
|
|
|
_nc = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _jetstream():
|
|
|
|
|
"""Reuse one NATS connection across publishes (no connect/close per event)."""
|
|
|
|
|
global _nc
|
|
|
|
|
if _nc is None or _nc.is_closed:
|
|
|
|
|
_nc = await nats.connect(NATS_URLS)
|
|
|
|
|
return _nc.jetstream()
|
2026-06-04 20:30:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def publish_event(subject: str, event: dict):
|
|
|
|
|
"""Publish an event to NATS JetStream."""
|
2026-08-28 21:49:05 -04:00
|
|
|
js = await _jetstream()
|
2026-06-04 20:30:04 -04:00
|
|
|
await js.publish(subject, json.dumps(event).encode())
|
|
|
|
|
logger.debug("Published event to %s", subject)
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
def event_dedup_key(msg: dict) -> str | None:
|
|
|
|
|
"""Natural key for generic events. URL when present; else None (always insert)."""
|
|
|
|
|
url = msg.get("url")
|
|
|
|
|
if not isinstance(url, str):
|
|
|
|
|
return None
|
|
|
|
|
url = url.strip()
|
|
|
|
|
return url or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ua_headers() -> dict[str, str]:
|
|
|
|
|
return {"User-Agent": OSINT_USER_AGENT}
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
# ─── RSS Feed Ingestor ──────────────────────────────────────────────────
|
|
|
|
|
|
2026-08-28 09:33:19 -04:00
|
|
|
async def ingest_rss_feed(feed_url: str, source_id: str | None = None):
|
2026-06-04 20:30:04 -04:00
|
|
|
"""Fetch and parse an RSS feed, publish items to NATS."""
|
2026-08-28 09:33:19 -04:00
|
|
|
text = rss_cache.get(feed_url)
|
|
|
|
|
if text is None:
|
2026-08-28 21:49:05 -04:00
|
|
|
async with httpx.AsyncClient(timeout=30, headers=_ua_headers()) as client:
|
2026-08-28 09:33:19 -04:00
|
|
|
resp = await client.get(feed_url)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
text = resp.text
|
|
|
|
|
rss_cache[feed_url] = text
|
|
|
|
|
feed = feedparser.parse(text)
|
2026-06-04 20:30:04 -04:00
|
|
|
|
|
|
|
|
count = 0
|
|
|
|
|
for entry in feed.entries[:100]: # max 100 per run
|
|
|
|
|
event = {
|
|
|
|
|
"source_type": "rss",
|
|
|
|
|
"title": entry.get("title"),
|
|
|
|
|
"body": entry.get("summary") or entry.get("description"),
|
|
|
|
|
"url": entry.get("link"),
|
|
|
|
|
"source_timestamp": _parse_rfc822(entry.get("published"))
|
|
|
|
|
or datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"tags": [t.get("term") for t in entry.get("tags", []) if t.get("term")],
|
|
|
|
|
"raw": {
|
2026-07-07 19:35:28 -04:00
|
|
|
"feed_url": feed_url,
|
2026-06-04 20:30:04 -04:00
|
|
|
"feed_title": feed.feed.get("title"),
|
|
|
|
|
"author": entry.get("author"),
|
|
|
|
|
"categories": [c.get("term") for c in entry.get("categories", [])],
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
await publish_event("events.rss", event)
|
|
|
|
|
count += 1
|
|
|
|
|
|
|
|
|
|
logger.info("Ingested %d items from RSS feed %s", count, feed_url)
|
|
|
|
|
return count
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
# ─── GDELT 2.0 DOC API ──────────────────────────────────────────────────
|
2026-06-04 20:30:04 -04:00
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
GDELT_API = "https://api.gdeltproject.org/api/v2/doc/doc"
|
|
|
|
|
GDELT_DEFAULT_QUERY = '(unrest OR protest OR outage OR cyber OR "power outage")'
|
2026-06-04 20:30:04 -04:00
|
|
|
|
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
def gdelt_params(query: str = "", max_articles: int = 50) -> dict[str, str]:
|
|
|
|
|
"""DOC 2.0 query string (not the retired gdeltv2 ``search`` param)."""
|
|
|
|
|
q = (query or "").strip() or GDELT_DEFAULT_QUERY
|
|
|
|
|
return {
|
|
|
|
|
"query": q,
|
|
|
|
|
"mode": "ArtList",
|
2026-06-04 20:30:04 -04:00
|
|
|
"format": "json",
|
2026-08-28 21:49:05 -04:00
|
|
|
"maxrecords": str(int(max_articles)),
|
|
|
|
|
"timespan": "1d",
|
2026-06-04 20:30:04 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
def _parse_gdelt_seendate(value: object) -> str:
|
|
|
|
|
if isinstance(value, str) and len(value) >= 15:
|
|
|
|
|
try:
|
|
|
|
|
return datetime.strptime(value[:15], "%Y%m%dT%H%M%S").replace(
|
|
|
|
|
tzinfo=timezone.utc
|
|
|
|
|
).isoformat()
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_gdelt_articles(data: dict) -> list[dict]:
|
|
|
|
|
events = []
|
|
|
|
|
for article in data.get("articles") or []:
|
|
|
|
|
if not isinstance(article, dict):
|
|
|
|
|
continue
|
|
|
|
|
url = article.get("url")
|
|
|
|
|
if not url:
|
|
|
|
|
continue
|
|
|
|
|
events.append({
|
2026-06-04 20:30:04 -04:00
|
|
|
"source_type": "gdel-t2",
|
|
|
|
|
"title": article.get("title"),
|
2026-08-28 21:49:05 -04:00
|
|
|
"body": article.get("domain") or article.get("language"),
|
|
|
|
|
"url": url,
|
|
|
|
|
"location_name": article.get("sourcecountry"),
|
|
|
|
|
"source_timestamp": _parse_gdelt_seendate(article.get("seendate")),
|
|
|
|
|
"tags": [t for t in (article.get("language"), article.get("sourcecountry")) if t],
|
2026-06-04 20:30:04 -04:00
|
|
|
"raw": article,
|
2026-08-28 21:49:05 -04:00
|
|
|
})
|
|
|
|
|
return events
|
2026-06-04 20:30:04 -04:00
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
|
|
|
|
|
async def ingest_gdelt(query: str = "", max_articles: int = 50):
|
|
|
|
|
"""Fetch articles from the GDELT DOC 2.0 API."""
|
|
|
|
|
params = gdelt_params(query=query, max_articles=max_articles)
|
|
|
|
|
data: dict = {"articles": []}
|
|
|
|
|
async with httpx.AsyncClient(
|
|
|
|
|
timeout=60, headers=_ua_headers(), follow_redirects=True,
|
|
|
|
|
) as client:
|
|
|
|
|
try:
|
|
|
|
|
resp = await client.get(GDELT_API, params=params)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
data = resp.json()
|
|
|
|
|
except (httpx.TransportError, httpx.HTTPStatusError) as exc:
|
|
|
|
|
# gdeltproject.org certs have expired in the wild; HTTP fallback.
|
|
|
|
|
logger.warning("GDELT HTTPS failed (%s); retrying HTTP", exc)
|
|
|
|
|
http_url = GDELT_API.replace("https://", "http://", 1)
|
|
|
|
|
resp = await client.get(http_url, params=params)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
data = resp.json()
|
|
|
|
|
|
|
|
|
|
events = parse_gdelt_articles(data if isinstance(data, dict) else {})
|
|
|
|
|
for event in events:
|
|
|
|
|
await publish_event("events.gdelt", event)
|
|
|
|
|
logger.info("Ingested %d articles from GDELT", len(events))
|
|
|
|
|
return len(events)
|
2026-06-04 20:30:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_gdelt_tone(tone: str) -> float | None:
|
|
|
|
|
"""Parse GDELT tone string to a -1..1 sentiment score."""
|
|
|
|
|
try:
|
|
|
|
|
tone_float = float(tone)
|
|
|
|
|
return max(-1.0, min(1.0, tone_float / 4249.0)) # GDELT tone range
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Earthquake Ingestor (USGS) ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
USGS_API = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson"
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
def parse_usgs_feature(feature: dict) -> dict:
|
|
|
|
|
"""Map one USGS GeoJSON feature, keeping the stable event id."""
|
|
|
|
|
props = feature.get("properties") or {}
|
|
|
|
|
geometry = (feature.get("geometry") or {}).get("coordinates") or []
|
|
|
|
|
usgs_id = feature.get("id")
|
|
|
|
|
url = props.get("url") or (
|
|
|
|
|
f"https://earthquake.usgs.gov/earthquakes/eventpage/{usgs_id}" if usgs_id else None
|
|
|
|
|
)
|
|
|
|
|
raw = dict(props)
|
|
|
|
|
raw["usgs_id"] = usgs_id
|
|
|
|
|
return {
|
|
|
|
|
"source_type": "earthquake",
|
|
|
|
|
"title": props.get("title"),
|
|
|
|
|
"body": props.get("description"),
|
|
|
|
|
"url": url,
|
|
|
|
|
"location_lat": geometry[1] if len(geometry) > 1 else None,
|
|
|
|
|
"location_lon": geometry[0] if len(geometry) > 0 else None,
|
|
|
|
|
"location_name": props.get("place"),
|
|
|
|
|
"sentiment_label": "neutral",
|
|
|
|
|
"tags": [f"magnitude:{props.get('mag')}"] if props.get("mag") else [],
|
|
|
|
|
"source_timestamp": (
|
|
|
|
|
datetime.utcfromtimestamp(props.get("time", 0) / 1000)
|
|
|
|
|
.replace(tzinfo=timezone.utc)
|
|
|
|
|
.isoformat()
|
|
|
|
|
),
|
|
|
|
|
"raw": raw,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
async def ingest_earthquakes():
|
|
|
|
|
"""Fetch recent earthquakes from USGS."""
|
2026-08-28 21:49:05 -04:00
|
|
|
async with httpx.AsyncClient(timeout=30, headers=_ua_headers()) as client:
|
2026-06-04 20:30:04 -04:00
|
|
|
resp = await client.get(USGS_API)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
data = resp.json()
|
|
|
|
|
|
|
|
|
|
count = 0
|
|
|
|
|
for feature in data.get("features", []):
|
2026-08-28 21:49:05 -04:00
|
|
|
event = parse_usgs_feature(feature)
|
2026-06-04 20:30:04 -04:00
|
|
|
await publish_event("events.earthquake", event)
|
|
|
|
|
count += 1
|
|
|
|
|
|
|
|
|
|
logger.info("Ingested %d earthquake events", count)
|
|
|
|
|
return count
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
# ─── NASA EONET v3 ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
EONET_API = "https://eonet.gsfc.nasa.gov/api/v3/events"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_eonet_events(payload: dict) -> list[dict]:
|
|
|
|
|
events = []
|
|
|
|
|
for item in payload.get("events") or []:
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
continue
|
|
|
|
|
eid = item.get("id")
|
|
|
|
|
geoms = item.get("geometry") or []
|
|
|
|
|
point = None
|
|
|
|
|
for g in geoms:
|
|
|
|
|
if isinstance(g, dict) and g.get("type") == "Point":
|
|
|
|
|
point = g
|
|
|
|
|
if point is None:
|
|
|
|
|
continue
|
|
|
|
|
coords = point.get("coordinates") or []
|
|
|
|
|
if len(coords) < 2:
|
|
|
|
|
continue
|
|
|
|
|
lon, lat = float(coords[0]), float(coords[1])
|
|
|
|
|
cats = item.get("categories") or []
|
|
|
|
|
tags = []
|
|
|
|
|
for c in cats:
|
|
|
|
|
if isinstance(c, dict) and c.get("id"):
|
|
|
|
|
tags.append(str(c["id"]))
|
|
|
|
|
url = item.get("link") or (f"https://eonet.gsfc.nasa.gov/api/v3/events/{eid}" if eid else None)
|
|
|
|
|
ts = point.get("date") or datetime.now(timezone.utc).isoformat()
|
|
|
|
|
events.append({
|
|
|
|
|
"source_type": "disaster",
|
|
|
|
|
"title": item.get("title"),
|
|
|
|
|
"body": ", ".join(tags) if tags else None,
|
|
|
|
|
"url": url,
|
|
|
|
|
"location_lat": lat,
|
|
|
|
|
"location_lon": lon,
|
|
|
|
|
"location_name": item.get("title"),
|
|
|
|
|
"tags": tags,
|
|
|
|
|
"source_timestamp": ts,
|
|
|
|
|
"raw": {**item, "eonet_id": eid},
|
|
|
|
|
})
|
|
|
|
|
return events
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def ingest_eonet():
|
|
|
|
|
"""Volcanoes, storms, floods, drought — gaps USGS/FIRMS don't cover."""
|
|
|
|
|
async with httpx.AsyncClient(timeout=30, headers=_ua_headers()) as client:
|
|
|
|
|
resp = await client.get(EONET_API, params={"status": "open", "limit": 100})
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
data = resp.json()
|
|
|
|
|
events = parse_eonet_events(data if isinstance(data, dict) else {})
|
|
|
|
|
for event in events:
|
|
|
|
|
await publish_event("events.disaster", event)
|
|
|
|
|
logger.info("Ingested %d EONET events", len(events))
|
|
|
|
|
return len(events)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── CISA KEV ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
CISA_KEV_API = (
|
|
|
|
|
"https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_cisa_kev(payload: dict) -> list[dict]:
|
|
|
|
|
events = []
|
|
|
|
|
for row in payload.get("vulnerabilities") or []:
|
|
|
|
|
if not isinstance(row, dict):
|
|
|
|
|
continue
|
|
|
|
|
cve = row.get("cveID")
|
|
|
|
|
if not cve:
|
|
|
|
|
continue
|
|
|
|
|
title = row.get("vulnerabilityName") or cve
|
|
|
|
|
vendor = row.get("vendorProject") or ""
|
|
|
|
|
product = row.get("product") or ""
|
|
|
|
|
events.append({
|
|
|
|
|
"source_type": "disaster",
|
|
|
|
|
"title": f"{cve}: {title}",
|
|
|
|
|
"body": row.get("shortDescription") or f"{vendor} {product}".strip(),
|
|
|
|
|
"url": f"https://nvd.nist.gov/vuln/detail/{cve}",
|
|
|
|
|
"location_lat": None,
|
|
|
|
|
"location_lon": None,
|
|
|
|
|
"location_name": None,
|
|
|
|
|
"tags": ["cisa-kev", cve, "ransomware" if row.get("knownRansomwareCampaignUse") == "Known" else None],
|
|
|
|
|
"source_timestamp": row.get("dateAdded") or datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"raw": {**row, "cveID": cve},
|
|
|
|
|
})
|
|
|
|
|
events[-1]["tags"] = [t for t in events[-1]["tags"] if t]
|
|
|
|
|
return events
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def ingest_cisa_kev():
|
|
|
|
|
"""Exploited-in-the-wild CVEs. No fake map coords — ticker/events only."""
|
|
|
|
|
async with httpx.AsyncClient(timeout=30, headers=_ua_headers(), follow_redirects=True) as client:
|
|
|
|
|
resp = await client.get(CISA_KEV_API)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
data = resp.json()
|
|
|
|
|
events = parse_cisa_kev(data if isinstance(data, dict) else {})
|
|
|
|
|
for event in events:
|
|
|
|
|
await publish_event("events.disaster", event)
|
|
|
|
|
logger.info("Ingested %d CISA KEV rows", len(events))
|
|
|
|
|
return len(events)
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
# ─── Social Signals (Twitter/X-like placeholder) ────────────────────────
|
|
|
|
|
|
|
|
|
|
async def ingest_social_signals(query: str = "", max_items: int = 50):
|
|
|
|
|
"""Placeholder for social media signal ingestion.
|
|
|
|
|
|
|
|
|
|
In production, this would connect to Twitter API, Reddit, NewsAPI, etc.
|
|
|
|
|
For now, it publishes a heartbeat to signal the pipeline is active.
|
|
|
|
|
"""
|
|
|
|
|
event = {
|
|
|
|
|
"source_type": "social",
|
|
|
|
|
"title": f"Social signal scan: {query}",
|
|
|
|
|
"body": f"Scanned for '{query}' — placeholder connector",
|
|
|
|
|
"source_timestamp": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"tags": [query] if query else [],
|
|
|
|
|
"raw": {"query": query, "max_items": max_items, "connector": "placeholder"},
|
|
|
|
|
}
|
|
|
|
|
await publish_event("events.social", event)
|
|
|
|
|
logger.info("Social signal scan complete for '%s'", query)
|
|
|
|
|
return 1
|