186 lines
6.6 KiB
Python
186 lines
6.6 KiB
Python
"""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
|
|
|
|
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
|
|
|
|
# NATS connection
|
|
NATS_URLS = "nats://osint-nats.customer1.svc.cluster.local:4222"
|
|
|
|
|
|
async def publish_event(subject: str, event: dict):
|
|
"""Publish an event to NATS JetStream."""
|
|
nc = await nats.connect(NATS_URLS)
|
|
js = nc.jetstream()
|
|
await js.publish(subject, json.dumps(event).encode())
|
|
await nc.close()
|
|
logger.debug("Published event to %s", subject)
|
|
|
|
|
|
# ─── RSS Feed Ingestor ──────────────────────────────────────────────────
|
|
|
|
async def ingest_rss_feed(feed_url: str, source_id: str = None):
|
|
"""Fetch and parse an RSS feed, publish items to NATS."""
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
resp = await client.get(feed_url)
|
|
resp.raise_for_status()
|
|
feed = feedparser.parse(resp.text)
|
|
|
|
count = 0
|
|
for entry in feed.entries[:100]: # max 100 per run
|
|
event = {
|
|
"source_type": "rss",
|
|
"source_id": source_id,
|
|
"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": {
|
|
"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
|
|
|
|
|
|
# ─── GDELT 2.0 Ingestor ─────────────────────────────────────────────────
|
|
|
|
GDELT_API = "https://api.gdeltproject.org/gdeltv2"
|
|
|
|
|
|
async def ingest_gdelt(query: str = "", max_articles: int = 50):
|
|
"""Fetch articles from GDELT 2.0 API."""
|
|
params = {
|
|
"mode": "artlist",
|
|
"format": "json",
|
|
"maxrecords": max_articles,
|
|
"mode": "artlist",
|
|
}
|
|
if query:
|
|
params["search"] = query
|
|
|
|
async with httpx.AsyncClient(timeout=60) as client:
|
|
resp = await client.get(GDELT_API, params=params)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
count = 0
|
|
for article in data.get("articles", []):
|
|
event = {
|
|
"source_type": "gdel-t2",
|
|
"title": article.get("title"),
|
|
"body": article.get("articleBody"),
|
|
"url": article.get("url"),
|
|
"sentiment_score": _parse_gdelt_tone(article.get("Tone", "0")),
|
|
"location_lat": article.get("Latitude"),
|
|
"location_lon": article.get("Longitude"),
|
|
"location_name": article.get("Location"),
|
|
"source_timestamp": article.get("FirstCreated"),
|
|
"entities": [
|
|
{"name": e.get("Topic"), "type": "topic"}
|
|
for e in article.get("Mentions", [])
|
|
if e.get("Topic")
|
|
],
|
|
"raw": article,
|
|
}
|
|
await publish_event("events.gdelt", event)
|
|
count += 1
|
|
|
|
logger.info("Ingested %d articles from GDELT", count)
|
|
return count
|
|
|
|
|
|
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"
|
|
|
|
|
|
async def ingest_earthquakes():
|
|
"""Fetch recent earthquakes from USGS."""
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
resp = await client.get(USGS_API)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
count = 0
|
|
for feature in data.get("features", []):
|
|
props = feature.get("properties", {})
|
|
geometry = feature.get("geometry", {}).get("coordinates", [])
|
|
event = {
|
|
"source_type": "earthquake",
|
|
"title": props.get("title"),
|
|
"body": props.get("description"),
|
|
"url": props.get("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": props,
|
|
}
|
|
await publish_event("events.earthquake", event)
|
|
count += 1
|
|
|
|
logger.info("Ingested %d earthquake events", count)
|
|
return count
|
|
|
|
|
|
# ─── 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
|