fix: pipeline reliability — WS, summarizer, ingest, health
Stop the live HUD reconnect storm (nginx WS snippet + backoff), copy intel/nous_client into the summarizer image, and make event ingest idempotent on URL. GDELT uses the DOC API; NWS no longer sends bbox; FIRMS is one ON CONFLICT batch; GET /api/aircraft serves last-known. Health reports freshness without 503ing docker. EONET + CISA KEV added.
This commit is contained in:
parent
c6bfc50705
commit
d81ea68dec
26 changed files with 1169 additions and 115 deletions
115
alembic/versions/007_event_dedup.py
Normal file
115
alembic/versions/007_event_dedup.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""event_dedup + Timescale compression/retention
|
||||||
|
|
||||||
|
Revision ID: 007_event_dedup
|
||||||
|
Revises: 006_merge_heads
|
||||||
|
Create Date: 2026-08-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "007_event_dedup"
|
||||||
|
down_revision = "006_merge_heads"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS event_dedup (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Keep the earliest row per URL, drop the 10× USGS/camera dupes.
|
||||||
|
op.execute("""
|
||||||
|
DELETE FROM events a
|
||||||
|
USING events b
|
||||||
|
WHERE a.url IS NOT NULL AND a.url <> ''
|
||||||
|
AND a.url = b.url
|
||||||
|
AND (a.ingested_at, a.id) > (b.ingested_at, b.id)
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO event_dedup (url)
|
||||||
|
SELECT DISTINCT url FROM events
|
||||||
|
WHERE url IS NOT NULL AND url <> ''
|
||||||
|
ON CONFLICT (url) DO NOTHING
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Compression + retention. Policies no-op if Timescale rejects (fresh PG).
|
||||||
|
op.execute("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM add_compression_policy('events', INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE events SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'source_type',
|
||||||
|
timescaledb.compress_orderby = 'ingested_at DESC'
|
||||||
|
);
|
||||||
|
PERFORM add_compression_policy('events', INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM add_retention_policy('events', INTERVAL '180 days', if_not_exists => TRUE);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
NULL;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE fires SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'satellite',
|
||||||
|
timescaledb.compress_orderby = 'acq_time DESC'
|
||||||
|
);
|
||||||
|
PERFORM add_compression_policy('fires', INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
PERFORM add_retention_policy('fires', INTERVAL '90 days', if_not_exists => TRUE);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
NULL;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE aircraft_positions SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'hex',
|
||||||
|
timescaledb.compress_orderby = 'ts DESC'
|
||||||
|
);
|
||||||
|
PERFORM add_compression_policy('aircraft_positions', INTERVAL '1 day', if_not_exists => TRUE);
|
||||||
|
PERFORM add_retention_policy('aircraft_positions', INTERVAL '14 days', if_not_exists => TRUE);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
NULL;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE vessel_positions SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'mmsi',
|
||||||
|
timescaledb.compress_orderby = 'ts DESC'
|
||||||
|
);
|
||||||
|
PERFORM add_compression_policy('vessel_positions', INTERVAL '1 day', if_not_exists => TRUE);
|
||||||
|
PERFORM add_retention_policy('vessel_positions', INTERVAL '14 days', if_not_exists => TRUE);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
NULL;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS event_dedup")
|
||||||
|
|
@ -162,6 +162,16 @@ async def publish_fire_batch(points: list[dict]) -> int:
|
||||||
return len(points)
|
return len(points)
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_hotspots(points: list[dict]) -> int:
|
||||||
|
"""Write a FIRMS poll to Postgres in one ON CONFLICT batch.
|
||||||
|
|
||||||
|
NATS-per-row was 93k commits + geofence/correlation per hotspot.
|
||||||
|
"""
|
||||||
|
from ingestor import ingest_fire_rows
|
||||||
|
|
||||||
|
return await ingest_fire_rows(points)
|
||||||
|
|
||||||
|
|
||||||
async def ingest_fires(bbox: str | None = None) -> int:
|
async def ingest_fires(bbox: str | None = None) -> int:
|
||||||
"""Fetch the FIRMS hotspot CSV for an area and publish it to NATS.
|
"""Fetch the FIRMS hotspot CSV for an area and publish it to NATS.
|
||||||
|
|
||||||
|
|
@ -206,7 +216,7 @@ async def ingest_fires(bbox: str | None = None) -> int:
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
points = parse_firms_csv(text)
|
points = parse_firms_csv(text)
|
||||||
published = await publish_fire_batch(points)
|
published = await persist_hotspots(points)
|
||||||
total_published += published
|
total_published += published
|
||||||
logger.info(
|
logger.info(
|
||||||
"FIRMS: fetched %d hotspot(s) for bbox=%s (%s), published %d",
|
"FIRMS: fetched %d hotspot(s) for bbox=%s (%s), published %d",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from database import async_session
|
from database import async_session
|
||||||
from models import events as events_table
|
from models import events as events_table
|
||||||
from models import fires as fires_table
|
from models import fires as fires_table
|
||||||
|
from models import event_dedup as event_dedup_table
|
||||||
from config import NATS_URL
|
from config import NATS_URL
|
||||||
|
from sources import event_dedup_key
|
||||||
|
|
||||||
logger = logging.getLogger("osint.ingestor")
|
logger = logging.getLogger("osint.ingestor")
|
||||||
|
|
||||||
|
|
@ -115,6 +117,42 @@ async def ingest_fire_row(msg: dict) -> bool:
|
||||||
return inserted
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_fire_rows(msgs: list[dict]) -> int:
|
||||||
|
"""Bulk-insert FIRMS hotspots: one INSERT, one ON CONFLICT, one commit."""
|
||||||
|
rows = []
|
||||||
|
for msg in msgs:
|
||||||
|
row = _fire_row_from_msg(msg)
|
||||||
|
if row is not None:
|
||||||
|
rows.append(row)
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
async with async_session() as session:
|
||||||
|
stmt = (
|
||||||
|
pg_insert(fires_table)
|
||||||
|
.values(rows)
|
||||||
|
.on_conflict_do_nothing(constraint="pk_fires_natural_key")
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
inserted = int(result.rowcount or 0)
|
||||||
|
if inserted:
|
||||||
|
logger.info("bulk ingested %d/%d FIRMS hotspots", inserted, len(rows))
|
||||||
|
from live_layers import aircraft_last_known
|
||||||
|
from fire_aircraft import correlate_and_notify
|
||||||
|
from tracks import recent_markers
|
||||||
|
acs = list(aircraft_last_known.values()) or await recent_markers("aircraft")
|
||||||
|
if acs:
|
||||||
|
fires = [
|
||||||
|
{
|
||||||
|
"id": f"firms:{r['latitude']:.4f},{r['longitude']:.4f}",
|
||||||
|
"lat": r["latitude"], "lon": r["longitude"], "label": "FIRMS",
|
||||||
|
}
|
||||||
|
for r in rows[:500]
|
||||||
|
]
|
||||||
|
await correlate_and_notify(fires, acs)
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
async def ingest_event(msg: dict):
|
async def ingest_event(msg: dict):
|
||||||
"""Ingest a single event from NATS into PostgreSQL."""
|
"""Ingest a single event from NATS into PostgreSQL."""
|
||||||
# Active fire/hotspot messages carry a dedicated schema and land in the
|
# Active fire/hotspot messages carry a dedicated schema and land in the
|
||||||
|
|
@ -149,10 +187,26 @@ async def ingest_event(msg: dict):
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse timestamp if string
|
# Parse timestamp if string
|
||||||
if isinstance(event_row["source_timestamp"], str):
|
ts = event_row["source_timestamp"]
|
||||||
event_row["source_timestamp"] = datetime.fromisoformat(event_row["source_timestamp"])
|
if isinstance(ts, str):
|
||||||
|
ts = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||||
|
if isinstance(ts, datetime) and ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
event_row["source_timestamp"] = ts
|
||||||
|
|
||||||
|
key = event_dedup_key(event_row)
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
if key:
|
||||||
|
dedup = (
|
||||||
|
pg_insert(event_dedup_table)
|
||||||
|
.values(url=key)
|
||||||
|
.on_conflict_do_nothing(index_elements=["url"])
|
||||||
|
)
|
||||||
|
claimed = await session.execute(dedup)
|
||||||
|
if not claimed.rowcount:
|
||||||
|
await session.commit()
|
||||||
|
logger.info("skip duplicate event url=%s", key)
|
||||||
|
return None
|
||||||
result = await session.execute(events_table.insert().values(**event_row))
|
result = await session.execute(events_table.insert().values(**event_row))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
event_id = result.inserted_primary_key[0] # type: ignore[union-attr]
|
event_id = result.inserted_primary_key[0] # type: ignore[union-attr]
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ or AIS poll.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
@ -21,6 +22,8 @@ import httpx
|
||||||
|
|
||||||
from config import OSINT_USER_AGENT
|
from config import OSINT_USER_AGENT
|
||||||
|
|
||||||
|
logger = logging.getLogger("osint.live_layers")
|
||||||
|
|
||||||
MARKER_FIELDS = ("id", "lat", "lon", "heading", "speed", "label", "extra")
|
MARKER_FIELDS = ("id", "lat", "lon", "heading", "speed", "label", "extra")
|
||||||
|
|
||||||
ADSB_LOL_BASE = "https://api.adsb.lol"
|
ADSB_LOL_BASE = "https://api.adsb.lol"
|
||||||
|
|
@ -800,8 +803,24 @@ async def _get_json(url: str, params: dict | None = None) -> Any:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_aircraft(bbox: str, limit: int = DEFAULT_LIMIT) -> list[dict]:
|
async def fetch_aircraft(
|
||||||
|
bbox: str, limit: int = DEFAULT_LIMIT, persist: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Viewport ADS-B.
|
||||||
|
|
||||||
|
GET path (persist=False) serves in-memory last-known and never writes
|
||||||
|
tracks/geofences. Background refresh (persist=True) hits ADSB.lol and
|
||||||
|
then persist_aircraft_snapshot.
|
||||||
|
"""
|
||||||
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
||||||
|
if not persist:
|
||||||
|
cached = [
|
||||||
|
dict(v) for v in aircraft_last_known.values()
|
||||||
|
if v.get("lat") is not None and v.get("lon") is not None
|
||||||
|
]
|
||||||
|
filtered = filter_points_bbox(cached, minlon, minlat, maxlon, maxlat, limit)
|
||||||
|
if filtered:
|
||||||
|
return filtered
|
||||||
qminlon, qminlat, qmaxlon, qmaxlat = quantize_bbox(minlon, minlat, maxlon, maxlat)
|
qminlon, qminlat, qmaxlon, qmaxlat = quantize_bbox(minlon, minlat, maxlon, maxlat)
|
||||||
lat, lon, radius = bbox_center_radius_nm(qminlon, qminlat, qmaxlon, qmaxlat)
|
lat, lon, radius = bbox_center_radius_nm(qminlon, qminlat, qmaxlon, qmaxlat)
|
||||||
cache_key = f"adsb:{lat:.2f}:{lon:.2f}:{radius}"
|
cache_key = f"adsb:{lat:.2f}:{lon:.2f}:{radius}"
|
||||||
|
|
@ -811,12 +830,20 @@ async def fetch_aircraft(bbox: str, limit: int = DEFAULT_LIMIT) -> list[dict]:
|
||||||
return transform_adsb_lol(await _get_json(url))
|
return transform_adsb_lol(await _get_json(url))
|
||||||
|
|
||||||
rows = await _ttl_get(cache_key, 8.0, _load)
|
rows = await _ttl_get(cache_key, 8.0, _load)
|
||||||
|
for m in rows:
|
||||||
|
aircraft_last_known[str(m.get("id"))] = m
|
||||||
|
if persist:
|
||||||
|
await persist_aircraft_snapshot(rows)
|
||||||
|
return filter_points_bbox(rows, minlon, minlat, maxlon, maxlat, limit)
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_aircraft_snapshot(rows: list[dict]) -> None:
|
||||||
|
"""Track / geofence / WS / fire correlation — never on the GET path."""
|
||||||
from ws_manager import manager
|
from ws_manager import manager
|
||||||
from tracks import record_position
|
from tracks import record_position
|
||||||
from geofence import record_and_notify
|
from geofence import record_and_notify
|
||||||
aircraft_last_known.clear()
|
|
||||||
for m in rows:
|
for m in rows:
|
||||||
aircraft_last_known[str(m.get("id"))] = m
|
|
||||||
mlat, mlon = m.get("lat"), m.get("lon")
|
mlat, mlon = m.get("lat"), m.get("lon")
|
||||||
if mlat is None or mlon is None:
|
if mlat is None or mlon is None:
|
||||||
continue
|
continue
|
||||||
|
|
@ -830,7 +857,6 @@ async def fetch_aircraft(bbox: str, limit: int = DEFAULT_LIMIT) -> list[dict]:
|
||||||
if fire_last_known:
|
if fire_last_known:
|
||||||
from fire_aircraft import correlate_and_notify
|
from fire_aircraft import correlate_and_notify
|
||||||
await correlate_and_notify(fire_last_known, rows)
|
await correlate_and_notify(fire_last_known, rows)
|
||||||
return filter_points_bbox(rows, minlon, minlat, maxlon, maxlat, limit)
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_trains(bbox: str | None, limit: int = DEFAULT_LIMIT) -> list[dict]:
|
async def fetch_trains(bbox: str | None, limit: int = DEFAULT_LIMIT) -> list[dict]:
|
||||||
|
|
@ -964,13 +990,16 @@ async def fetch_weather_alerts(area: str | None, bbox: str | None) -> dict:
|
||||||
clip_box = None
|
clip_box = None
|
||||||
if area:
|
if area:
|
||||||
nws_params["area"] = area.upper()
|
nws_params["area"] = area.upper()
|
||||||
elif bbox:
|
if bbox:
|
||||||
clip_box = quantize_bbox(*parse_bbox(bbox))
|
clip_box = quantize_bbox(*parse_bbox(bbox))
|
||||||
nws_params["bbox"] = f"{clip_box[0]},{clip_box[1]},{clip_box[2]},{clip_box[3]}"
|
# NWS /alerts/active 400s on bbox= — never send it; clip locally.
|
||||||
nws_fc: dict = {"features": []}
|
nws_fc: dict = {"features": []}
|
||||||
|
nws_ok = True
|
||||||
try:
|
try:
|
||||||
nws_fc = await _get_json(NWS_ALERTS, nws_params)
|
nws_fc = await _get_json(NWS_ALERTS, nws_params)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning("NWS alerts fetch failed: %s", exc)
|
||||||
|
nws_ok = False
|
||||||
nws_fc = {"features": []}
|
nws_fc = {"features": []}
|
||||||
sbw_fc = await _ttl_get("iem:sbw", 45.0, _load_iem)
|
sbw_fc = await _ttl_get("iem:sbw", 45.0, _load_iem)
|
||||||
features = []
|
features = []
|
||||||
|
|
@ -988,11 +1017,15 @@ async def fetch_weather_alerts(area: str | None, bbox: str | None) -> dict:
|
||||||
if "event" not in props:
|
if "event" not in props:
|
||||||
props["event"] = props.get("ps") or "Storm-based warning"
|
props["event"] = props.get("ps") or "Storm-based warning"
|
||||||
features.append({**feat, "properties": slim_alert_properties(props)})
|
features.append({**feat, "properties": slim_alert_properties(props)})
|
||||||
merged = {"type": "FeatureCollection", "features": features}
|
merged = {"type": "FeatureCollection", "features": features, "nws_ok": nws_ok}
|
||||||
if clip_box:
|
if clip_box:
|
||||||
merged = clip_fc_to_bbox(merged, *clip_box)
|
clipped = clip_fc_to_bbox(merged, *clip_box)
|
||||||
|
clipped["nws_ok"] = nws_ok
|
||||||
|
merged = clipped
|
||||||
elif bbox:
|
elif bbox:
|
||||||
merged = clip_fc_to_bbox(merged, *parse_bbox(bbox))
|
clipped = clip_fc_to_bbox(merged, *parse_bbox(bbox))
|
||||||
|
clipped["nws_ok"] = nws_ok
|
||||||
|
merged = clipped
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
key = f"alerts:{area or ''}:{bbox_cell_key(bbox) if bbox else ''}"
|
key = f"alerts:{area or ''}:{bbox_cell_key(bbox) if bbox else ''}"
|
||||||
|
|
|
||||||
111
app/main.py
111
app/main.py
|
|
@ -72,12 +72,16 @@ async def _lifespan(app: FastAPI):
|
||||||
pass
|
pass
|
||||||
from config import AISSTREAM_IN_APP
|
from config import AISSTREAM_IN_APP
|
||||||
ais_task = None
|
ais_task = None
|
||||||
|
adsb_task = None
|
||||||
if AISSTREAM_IN_APP:
|
if AISSTREAM_IN_APP:
|
||||||
from ais_stream import run_ais_worker
|
from ais_stream import run_ais_worker
|
||||||
ais_task = asyncio.create_task(run_ais_worker())
|
ais_task = asyncio.create_task(run_ais_worker())
|
||||||
|
adsb_task = asyncio.create_task(_adsb_refresh_loop())
|
||||||
yield
|
yield
|
||||||
if ais_task is not None:
|
if ais_task is not None:
|
||||||
ais_task.cancel()
|
ais_task.cancel()
|
||||||
|
if adsb_task is not None:
|
||||||
|
adsb_task.cancel()
|
||||||
await close_http()
|
await close_http()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,6 +96,35 @@ app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|
||||||
|
async def _adsb_refresh_loop() -> None:
|
||||||
|
"""Poll ADSB.lol for connected viewports — never from GET /api/aircraft."""
|
||||||
|
from ws_manager import manager
|
||||||
|
from live_layers import fetch_aircraft
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
boxes = manager.viewports()
|
||||||
|
for box in boxes:
|
||||||
|
bbox = f"{box[0]},{box[1]},{box[2]},{box[3]}"
|
||||||
|
try:
|
||||||
|
await fetch_aircraft(bbox, persist=True)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("adsb_refresh_failed", error=str(exc))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("adsb_refresh_loop", error=str(exc))
|
||||||
|
await asyncio.sleep(8)
|
||||||
|
|
||||||
|
|
||||||
|
class CachedStaticFiles(StaticFiles):
|
||||||
|
"""Long-cache hashed/vendor Leaflet assets; index.html is served separately."""
|
||||||
|
|
||||||
|
async def get_response(self, path: str, scope):
|
||||||
|
resp = await super().get_response(path, scope)
|
||||||
|
if path.startswith("vendor/") or path.endswith((".js", ".css", ".woff2", ".png", ".svg")):
|
||||||
|
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def event_to_out(row: dict) -> EventOut:
|
def event_to_out(row: dict) -> EventOut:
|
||||||
|
|
@ -154,11 +187,65 @@ def alert_to_out(row: dict) -> AlertOut:
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
"""Health check with database connectivity."""
|
"""Liveness: process + DB. Pipeline freshness is in ``checks`` (HTTP 200
|
||||||
async with async_session() as session:
|
unless DB is down, so docker healthcheck does not restart a working HUD).
|
||||||
result = await session.execute(select(func.now()))
|
"""
|
||||||
db_time = result.scalar()
|
try:
|
||||||
return {"status": "ok", "db_time": db_time.isoformat() if db_time else None}
|
async with async_session() as session:
|
||||||
|
result = await session.execute(select(func.now()))
|
||||||
|
db_time = result.scalar()
|
||||||
|
checks = await _pipeline_checks(session)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("health_db_failed", error=str(exc))
|
||||||
|
return JSONResponse(
|
||||||
|
{"status": "down", "db_time": None, "checks": {"db": False}},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
checks["db"] = True
|
||||||
|
stale = [
|
||||||
|
name for name, val in checks.items()
|
||||||
|
if isinstance(val, dict) and val.get("ok") is False
|
||||||
|
]
|
||||||
|
status = "degraded" if stale else "ok"
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"db_time": db_time.isoformat() if db_time else None,
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/ready")
|
||||||
|
async def ready():
|
||||||
|
"""Readiness: 503 only when the database is unreachable."""
|
||||||
|
payload = await health()
|
||||||
|
if isinstance(payload, JSONResponse):
|
||||||
|
return payload
|
||||||
|
return JSONResponse(payload, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
|
async def _pipeline_checks(session) -> dict:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
checks: dict = {}
|
||||||
|
|
||||||
|
async def _age(sql: str, name: str, max_age_s: int) -> None:
|
||||||
|
try:
|
||||||
|
ts = (await session.execute(text(sql))).scalar()
|
||||||
|
except Exception:
|
||||||
|
checks[name] = {"ok": False, "age_s": None}
|
||||||
|
return
|
||||||
|
if ts is None:
|
||||||
|
checks[name] = {"ok": False, "age_s": None}
|
||||||
|
return
|
||||||
|
if getattr(ts, "tzinfo", None) is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
age = (now - ts).total_seconds()
|
||||||
|
checks[name] = {"ok": age <= max_age_s, "age_s": int(age)}
|
||||||
|
|
||||||
|
await _age("SELECT max(ingested_at) FROM events", "events", 20 * 60)
|
||||||
|
await _age("SELECT max(ingested_at) FROM fires", "fires", 30 * 60)
|
||||||
|
await _age("SELECT max(timestamp) FROM articles", "articles", 2 * 3600)
|
||||||
|
await _age("SELECT max(batch_timestamp) FROM article_summaries", "summaries", 2 * 3600)
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
def overlay_json(data, max_age: int) -> JSONResponse:
|
def overlay_json(data, max_age: int) -> JSONResponse:
|
||||||
|
|
@ -821,17 +908,19 @@ async def get_timeline(
|
||||||
"""Event timeline: counts and avg sentiment per time bucket."""
|
"""Event timeline: counts and avg sentiment per time bucket."""
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
# Use date_trunc for bucketing
|
bucket_s = bucket_hours * 3600
|
||||||
buckets = await session.execute(text(f"""
|
buckets = await session.execute(text("""
|
||||||
SELECT
|
SELECT
|
||||||
date_trunc('hour', source_timestamp) AS ts,
|
to_timestamp(
|
||||||
|
floor(extract(epoch FROM source_timestamp) / :bucket_s) * :bucket_s
|
||||||
|
) AT TIME ZONE 'UTC' AS ts,
|
||||||
COUNT(*) AS event_count,
|
COUNT(*) AS event_count,
|
||||||
COALESCE(AVG(sentiment_score), 0) AS avg_sentiment
|
COALESCE(AVG(sentiment_score), 0) AS avg_sentiment
|
||||||
FROM events
|
FROM events
|
||||||
WHERE source_timestamp >= :cutoff
|
WHERE source_timestamp >= :cutoff
|
||||||
GROUP BY ts
|
GROUP BY ts
|
||||||
ORDER BY ts
|
ORDER BY ts
|
||||||
"""), {"cutoff": cutoff})
|
"""), {"cutoff": cutoff, "bucket_s": bucket_s})
|
||||||
rows = buckets.mappings().all()
|
rows = buckets.mappings().all()
|
||||||
|
|
||||||
return [TimelinePoint(timestamp=r["ts"], event_count=r["event_count"],
|
return [TimelinePoint(timestamp=r["ts"], event_count=r["event_count"],
|
||||||
|
|
@ -1319,7 +1408,7 @@ async def list_aircraft(
|
||||||
ts = parse_timestamp(timestamp)
|
ts = parse_timestamp(timestamp)
|
||||||
if ts is not None:
|
if ts is not None:
|
||||||
return overlay_json(await fetch_positions_at("aircraft", ts, bbox, limit), 5)
|
return overlay_json(await fetch_positions_at("aircraft", ts, bbox, limit), 5)
|
||||||
return overlay_json(await fetch_aircraft(bbox, limit), 5)
|
return overlay_json(await fetch_aircraft(bbox, limit, persist=False), 5)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(422, str(exc)) from exc
|
raise HTTPException(422, str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -1550,7 +1639,7 @@ async def map_layer_times(
|
||||||
return {"layer": layer, **domain}
|
return {"layer": layer, **domain}
|
||||||
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", CachedStaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,15 @@ Index("ix_events_search_vector", events.c.search_vector, postgresql_using="gin")
|
||||||
# Spatial index on location
|
# Spatial index on location
|
||||||
Index("ix_events_location", events.c.location_lat, events.c.location_lon)
|
Index("ix_events_location", events.c.location_lat, events.c.location_lon)
|
||||||
|
|
||||||
|
# Timescale unique indexes must include the partition column, so URL
|
||||||
|
# idempotency lives on a regular table — not the events hypertable.
|
||||||
|
event_dedup = Table(
|
||||||
|
"event_dedup",
|
||||||
|
metadata,
|
||||||
|
Column("url", Text, primary_key=True),
|
||||||
|
Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Entities (people, organizations, locations of interest) ──────────────
|
# ── Entities (people, organizations, locations of interest) ──────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import sys
|
||||||
sys.path.insert(0, sys_path)
|
sys.path.insert(0, sys_path)
|
||||||
|
|
||||||
from config import NATS_URL, FIRMS_INTERVAL, FIRMS_DATASET, AISSTREAM_IN_INGEST # noqa: E402
|
from config import NATS_URL, FIRMS_INTERVAL, FIRMS_DATASET, AISSTREAM_IN_INGEST # noqa: E402
|
||||||
from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes # noqa: E402
|
from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_eonet, ingest_cisa_kev # noqa: E402
|
||||||
from fire_sources import ingest_fires # noqa: E402
|
from fire_sources import ingest_fires # noqa: E402
|
||||||
from ingestor import ingest_event, start_nats_consumer # noqa: E402
|
from ingestor import ingest_event, start_nats_consumer # noqa: E402
|
||||||
|
|
||||||
|
|
@ -37,6 +37,8 @@ INTERVAL = int(os.getenv("INGEST_INTERVAL", "300"))
|
||||||
GDELT_QUERY = os.getenv("GDELT_QUERY", "")
|
GDELT_QUERY = os.getenv("GDELT_QUERY", "")
|
||||||
ENABLE_QUAKES = os.getenv("INGEST_EARTHQUAKES", "1").lower() in ("1", "true", "yes")
|
ENABLE_QUAKES = os.getenv("INGEST_EARTHQUAKES", "1").lower() in ("1", "true", "yes")
|
||||||
ENABLE_FIRES = os.getenv("INGEST_FIRES", "1").lower() in ("1", "true", "yes")
|
ENABLE_FIRES = os.getenv("INGEST_FIRES", "1").lower() in ("1", "true", "yes")
|
||||||
|
ENABLE_EONET = os.getenv("INGEST_EONET", "1").lower() in ("1", "true", "yes")
|
||||||
|
ENABLE_KEV = os.getenv("INGEST_KEV", "1").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
NATS_STREAM = "events"
|
NATS_STREAM = "events"
|
||||||
|
|
||||||
|
|
@ -62,6 +64,18 @@ async def producer_loop() -> None:
|
||||||
logger.info("USGS -> %d events", q)
|
logger.info("USGS -> %d events", q)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.exception("USGS fetch failed")
|
logger.exception("USGS fetch failed")
|
||||||
|
if ENABLE_EONET:
|
||||||
|
try:
|
||||||
|
n = await ingest_eonet()
|
||||||
|
logger.info("EONET -> %d events", n)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("EONET fetch failed")
|
||||||
|
if ENABLE_KEV:
|
||||||
|
try:
|
||||||
|
k = await ingest_cisa_kev()
|
||||||
|
logger.info("CISA KEV -> %d events", k)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("CISA KEV fetch failed")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.exception("producer cycle error")
|
logger.exception("producer cycle error")
|
||||||
await asyncio.sleep(INTERVAL)
|
await asyncio.sleep(INTERVAL)
|
||||||
|
|
|
||||||
294
app/sources.py
294
app/sources.py
|
|
@ -11,7 +11,7 @@ import httpx
|
||||||
import feedparser
|
import feedparser
|
||||||
import nats
|
import nats
|
||||||
|
|
||||||
from config import NATS_URL
|
from config import NATS_URL, OSINT_USER_AGENT
|
||||||
from upstream_cache import rss_cache
|
from upstream_cache import rss_cache
|
||||||
|
|
||||||
logger = logging.getLogger("osint.sources")
|
logger = logging.getLogger("osint.sources")
|
||||||
|
|
@ -26,26 +26,46 @@ def _parse_rfc822(date_str: object) -> str | None:
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# NATS connection
|
|
||||||
NATS_URLS = NATS_URL
|
NATS_URLS = NATS_URL
|
||||||
|
_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()
|
||||||
|
|
||||||
|
|
||||||
async def publish_event(subject: str, event: dict):
|
async def publish_event(subject: str, event: dict):
|
||||||
"""Publish an event to NATS JetStream."""
|
"""Publish an event to NATS JetStream."""
|
||||||
nc = await nats.connect(NATS_URLS)
|
js = await _jetstream()
|
||||||
js = nc.jetstream()
|
|
||||||
await js.publish(subject, json.dumps(event).encode())
|
await js.publish(subject, json.dumps(event).encode())
|
||||||
await nc.close()
|
|
||||||
logger.debug("Published event to %s", subject)
|
logger.debug("Published event to %s", subject)
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
# ─── RSS Feed Ingestor ──────────────────────────────────────────────────
|
# ─── RSS Feed Ingestor ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async def ingest_rss_feed(feed_url: str, source_id: str | None = None):
|
async def ingest_rss_feed(feed_url: str, source_id: str | None = None):
|
||||||
"""Fetch and parse an RSS feed, publish items to NATS."""
|
"""Fetch and parse an RSS feed, publish items to NATS."""
|
||||||
text = rss_cache.get(feed_url)
|
text = rss_cache.get(feed_url)
|
||||||
if text is None:
|
if text is None:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30, headers=_ua_headers()) as client:
|
||||||
resp = await client.get(feed_url)
|
resp = await client.get(feed_url)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
text = resp.text
|
text = resp.text
|
||||||
|
|
@ -76,51 +96,80 @@ async def ingest_rss_feed(feed_url: str, source_id: str | None = None):
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
# ─── GDELT 2.0 Ingestor ─────────────────────────────────────────────────
|
# ─── GDELT 2.0 DOC API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
GDELT_API = "https://api.gdeltproject.org/gdeltv2"
|
GDELT_API = "https://api.gdeltproject.org/api/v2/doc/doc"
|
||||||
|
GDELT_DEFAULT_QUERY = '(unrest OR protest OR outage OR cyber OR "power outage")'
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
"format": "json",
|
||||||
|
"maxrecords": str(int(max_articles)),
|
||||||
|
"timespan": "1d",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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({
|
||||||
|
"source_type": "gdel-t2",
|
||||||
|
"title": article.get("title"),
|
||||||
|
"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],
|
||||||
|
"raw": article,
|
||||||
|
})
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
async def ingest_gdelt(query: str = "", max_articles: int = 50):
|
async def ingest_gdelt(query: str = "", max_articles: int = 50):
|
||||||
"""Fetch articles from GDELT 2.0 API."""
|
"""Fetch articles from the GDELT DOC 2.0 API."""
|
||||||
params = {
|
params = gdelt_params(query=query, max_articles=max_articles)
|
||||||
"mode": "artlist",
|
data: dict = {"articles": []}
|
||||||
"format": "json",
|
async with httpx.AsyncClient(
|
||||||
"maxrecords": max_articles,
|
timeout=60, headers=_ua_headers(), follow_redirects=True,
|
||||||
"mode": "artlist",
|
) as client:
|
||||||
}
|
try:
|
||||||
if query:
|
resp = await client.get(GDELT_API, params=params)
|
||||||
params["search"] = query
|
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()
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=60) as client:
|
events = parse_gdelt_articles(data if isinstance(data, dict) else {})
|
||||||
resp = await client.get(GDELT_API, params=params)
|
for event in events:
|
||||||
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)
|
await publish_event("events.gdelt", event)
|
||||||
count += 1
|
logger.info("Ingested %d articles from GDELT", len(events))
|
||||||
|
return len(events)
|
||||||
logger.info("Ingested %d articles from GDELT", count)
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_gdelt_tone(tone: str) -> float | None:
|
def _parse_gdelt_tone(tone: str) -> float | None:
|
||||||
|
|
@ -137,34 +186,45 @@ def _parse_gdelt_tone(tone: str) -> float | None:
|
||||||
USGS_API = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson"
|
USGS_API = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson"
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def ingest_earthquakes():
|
async def ingest_earthquakes():
|
||||||
"""Fetch recent earthquakes from USGS."""
|
"""Fetch recent earthquakes from USGS."""
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30, headers=_ua_headers()) as client:
|
||||||
resp = await client.get(USGS_API)
|
resp = await client.get(USGS_API)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for feature in data.get("features", []):
|
for feature in data.get("features", []):
|
||||||
props = feature.get("properties", {})
|
event = parse_usgs_feature(feature)
|
||||||
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)
|
await publish_event("events.earthquake", event)
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
|
|
@ -172,6 +232,110 @@ async def ingest_earthquakes():
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 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)
|
||||||
|
|
||||||
|
|
||||||
# ─── Social Signals (Twitter/X-like placeholder) ────────────────────────
|
# ─── Social Signals (Twitter/X-like placeholder) ────────────────────────
|
||||||
|
|
||||||
async def ingest_social_signals(query: str = "", max_items: int = 50):
|
async def ingest_social_signals(query: str = "", max_items: int = 50):
|
||||||
|
|
|
||||||
|
|
@ -1149,9 +1149,12 @@ async function checkHealth() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${API}/api/health`);
|
const r = await fetch(`${API}/api/health`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (r.ok) {
|
if (r.ok && d && d.status === 'ok') {
|
||||||
dot.className = 'dot ok';
|
dot.className = 'dot ok';
|
||||||
txt.textContent = 'SYSTEMS OK';
|
txt.textContent = 'SYSTEMS OK';
|
||||||
|
} else if (r.ok) {
|
||||||
|
dot.className = 'dot bad';
|
||||||
|
txt.textContent = 'DEGRADED';
|
||||||
} else {
|
} else {
|
||||||
dot.className = 'dot bad';
|
dot.className = 'dot bad';
|
||||||
txt.textContent = 'DEGRADED';
|
txt.textContent = 'DEGRADED';
|
||||||
|
|
@ -1307,8 +1310,7 @@ function initMarketTicker() {
|
||||||
loadMarketSymbols();
|
loadMarketSymbols();
|
||||||
buildMarketTrack(true);
|
buildMarketTrack(true);
|
||||||
setMarketTag('standby');
|
setMarketTag('standby');
|
||||||
probeMarket();
|
// GET /api/market is not wired — do not 404-poll every 15s.
|
||||||
setInterval(probeMarket, MARKET_POLL_MS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════ NEWS FEED + TICKER ═══════════════ */
|
/* ═══════════════ NEWS FEED + TICKER ═══════════════ */
|
||||||
|
|
@ -1813,6 +1815,7 @@ function bboxCell() {
|
||||||
return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom();
|
return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom();
|
||||||
}
|
}
|
||||||
let liveWs = null;
|
let liveWs = null;
|
||||||
|
let wsRetryMs = 1000;
|
||||||
const FF_ICAO = new Set(['AT802','AT8T','AT8P','C130','C30J','C130J','DC10','MD10','MD87','S64','UH60','S70','CL415','CL215','B350','OV10','C208']);
|
const FF_ICAO = new Set(['AT802','AT8T','AT8P','C130','C30J','C130J','DC10','MD10','MD87','S64','UH60','S70','CL415','CL215','B350','OV10','C208']);
|
||||||
let firefighterHex = new Set();
|
let firefighterHex = new Set();
|
||||||
let dvrTs = null;
|
let dvrTs = null;
|
||||||
|
|
@ -1876,15 +1879,25 @@ function acColor(p) {
|
||||||
}
|
}
|
||||||
function connectLiveWs() {
|
function connectLiveWs() {
|
||||||
if (liveWs && (liveWs.readyState === 0 || liveWs.readyState === 1)) return;
|
if (liveWs && (liveWs.readyState === 0 || liveWs.readyState === 1)) return;
|
||||||
try { liveWs = new WebSocket(liveWsUrl()); } catch (e) { return; }
|
try { liveWs = new WebSocket(liveWsUrl()); } catch (e) {
|
||||||
liveWs.onopen = () => sendLiveViewport();
|
const delay = wsRetryMs;
|
||||||
|
wsRetryMs = Math.min(wsRetryMs * 2, 60000);
|
||||||
|
setTimeout(connectLiveWs, delay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
liveWs.onopen = () => { wsRetryMs = 1000; sendLiveViewport(); };
|
||||||
liveWs.onmessage = (ev) => {
|
liveWs.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(ev.data);
|
const msg = JSON.parse(ev.data);
|
||||||
applyLiveMarker(msg.type, msg.payload);
|
applyLiveMarker(msg.type, msg.payload);
|
||||||
} catch (e) { /* ignore malformed */ }
|
} catch (e) { /* ignore malformed */ }
|
||||||
};
|
};
|
||||||
liveWs.onclose = () => { liveWs = null; setTimeout(connectLiveWs, 4000); };
|
liveWs.onclose = () => {
|
||||||
|
liveWs = null;
|
||||||
|
const delay = wsRetryMs;
|
||||||
|
wsRetryMs = Math.min(wsRetryMs * 2, 60000);
|
||||||
|
setTimeout(connectLiveWs, delay);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
function overlayFetch(url) {
|
function overlayFetch(url) {
|
||||||
return fetch(url, overlayAbort ? { signal: overlayAbort.signal } : {});
|
return fetch(url, overlayAbort ? { signal: overlayAbort.signal } : {});
|
||||||
|
|
@ -2022,12 +2035,16 @@ async function initMap() {
|
||||||
trainsOn = document.getElementById('lp-trains-on').checked;
|
trainsOn = document.getElementById('lp-trains-on').checked;
|
||||||
vesselsOn = document.getElementById('lp-vessels-on').checked;
|
vesselsOn = document.getElementById('lp-vessels-on').checked;
|
||||||
stormsOn = document.getElementById('lp-storms-on').checked;
|
stormsOn = document.getElementById('lp-storms-on').checked;
|
||||||
if (firesOn) loadFires();
|
|
||||||
if (camsOn) loadCams();
|
|
||||||
if (blipsOn) loadBlips();
|
|
||||||
if (newsOn) loadNewsPins();
|
|
||||||
refreshLiveOverlays();
|
|
||||||
connectLiveWs();
|
connectLiveWs();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (firesOn) loadFires();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (camsOn) loadCams();
|
||||||
|
if (blipsOn) loadBlips();
|
||||||
|
if (newsOn) loadNewsPins();
|
||||||
|
refreshLiveOverlays();
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
hint.textContent = `Failed to load map layers: ${e.message || e}`;
|
hint.textContent = `Failed to load map layers: ${e.message || e}`;
|
||||||
console.error('Map init failed', e);
|
console.error('Map init failed', e);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ class ConnectionManager:
|
||||||
def viewport_of(self, client_id: str) -> BBox | None:
|
def viewport_of(self, client_id: str) -> BBox | None:
|
||||||
return self._viewports.get(client_id)
|
return self._viewports.get(client_id)
|
||||||
|
|
||||||
|
def viewports(self) -> list[BBox]:
|
||||||
|
return list(self._viewports.values())
|
||||||
|
|
||||||
def has_clients(self) -> bool:
|
def has_clients(self) -> bool:
|
||||||
return bool(self._queues)
|
return bool(self._queues)
|
||||||
|
|
||||||
|
|
|
||||||
23
deploy/osint-ws.nginx.conf
Normal file
23
deploy/osint-ws.nginx.conf
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# osint.rpi.local — WebSocket upgrade for /ws/live
|
||||||
|
#
|
||||||
|
# GitOps: this file is the source of truth. On the Pi:
|
||||||
|
# sudo cp deploy/osint-ws.nginx.conf /etc/nginx/snippets/osint-ws.conf
|
||||||
|
# then `include snippets/osint-ws.conf;` inside the osint.rpi.local server
|
||||||
|
# block (before `location /`), `nginx -t && systemctl reload nginx`.
|
||||||
|
#
|
||||||
|
# Without these headers nginx proxies GET /ws/live as HTTP/1.0 → FastAPI 404
|
||||||
|
# and the HUD reconnects every few seconds.
|
||||||
|
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
32
news/scraper/newsScraper/feed_util.py
Normal file
32
news/scraper/newsScraper/feed_util.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Feed helpers shared by the news spider (no Scrapy import)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
AUDIO_EXT = (".mp3", ".m4a", ".ogg", ".wav", ".aac", ".flac", ".opus")
|
||||||
|
|
||||||
|
|
||||||
|
def is_audio_url(url: str) -> bool:
|
||||||
|
path = urlparse(url or "").path.lower()
|
||||||
|
return any(path.endswith(ext) for ext in AUDIO_EXT)
|
||||||
|
|
||||||
|
|
||||||
|
def article_timestamp(pub_date: str | None) -> datetime.datetime:
|
||||||
|
"""Prefer the feed's pubDate/published; fall back to now (UTC)."""
|
||||||
|
if pub_date:
|
||||||
|
try:
|
||||||
|
return parsedate_to_datetime(pub_date).astimezone(datetime.timezone.utc)
|
||||||
|
except (TypeError, ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
raw = pub_date.replace("Z", "+00:00")
|
||||||
|
ts = datetime.datetime.fromisoformat(raw)
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
return ts
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
# Don't forget to add your pipeline to the ITEM_PIPELINES setting
|
# Don't forget to add your pipeline to the ITEM_PIPELINES setting
|
||||||
# See: https://docs.scrapy.org/en/latest/topics/item-pipeline.html
|
# See: https://docs.scrapy.org/en/latest/topics/item-pipeline.html
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import os
|
import os
|
||||||
from scrapy.exceptions import DropItem
|
from scrapy.exceptions import DropItem
|
||||||
|
|
@ -53,8 +53,10 @@ class PostgresPipeline:
|
||||||
self.connection.commit()
|
self.connection.commit()
|
||||||
|
|
||||||
def process_item(self, item, spider):
|
def process_item(self, item, spider):
|
||||||
if item ['url'] in self.seen_urls:
|
url = item['url']
|
||||||
raise DropItem()
|
if url in self.seen_urls:
|
||||||
|
raise DropItem(f"Duplicate URL (in-memory): {url}")
|
||||||
|
self.seen_urls.add(url)
|
||||||
try:
|
try:
|
||||||
self.cur.execute("""
|
self.cur.execute("""
|
||||||
INSERT INTO articles (title, url, content, domain, timestamp)
|
INSERT INTO articles (title, url, content, domain, timestamp)
|
||||||
|
|
@ -68,15 +70,16 @@ class PostgresPipeline:
|
||||||
item['timestamp']
|
item['timestamp']
|
||||||
))
|
))
|
||||||
if self.cur.rowcount == 0:
|
if self.cur.rowcount == 0:
|
||||||
e = DropItem("Duplicate URL (database conflict)")
|
raise DropItem(f"Duplicate URL (database): {url}")
|
||||||
e.log_level = logging.DEBUG
|
|
||||||
raise e
|
|
||||||
self.connection.commit()
|
self.connection.commit()
|
||||||
return item
|
return item
|
||||||
|
except DropItem:
|
||||||
|
self.connection.rollback()
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
spider.logger.error(f"Error saving to Postgres: {e}")
|
spider.logger.error(f"Error saving to Postgres: {e}")
|
||||||
self.connection.rollback()
|
self.connection.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def close_spider(self, spider):
|
def close_spider(self, spider):
|
||||||
self.cur.close()
|
self.cur.close()
|
||||||
|
|
@ -88,5 +91,3 @@ from itemadapter import ItemAdapter
|
||||||
class NewsscraperPipeline:
|
class NewsscraperPipeline:
|
||||||
def process_item(self, item, spider):
|
def process_item(self, item, spider):
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ from urllib.parse import urljoin, urlparse
|
||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from newsScraper.feed_util import article_timestamp, is_audio_url
|
||||||
|
|
||||||
|
|
||||||
class NewsRSSSpider(Spider):
|
class NewsRSSSpider(Spider):
|
||||||
"""Crawl the curated news sources in urls.txt and extract articles.
|
"""Crawl the curated news sources in urls.txt and extract articles.
|
||||||
|
|
@ -87,6 +89,8 @@ class NewsRSSSpider(Spider):
|
||||||
or node.xpath('updated/text()').get()
|
or node.xpath('updated/text()').get()
|
||||||
)
|
)
|
||||||
if link:
|
if link:
|
||||||
|
if is_audio_url(link):
|
||||||
|
continue
|
||||||
yield scrapy.Request(
|
yield scrapy.Request(
|
||||||
link,
|
link,
|
||||||
callback=self.parse_article,
|
callback=self.parse_article,
|
||||||
|
|
@ -112,5 +116,5 @@ class NewsRSSSpider(Spider):
|
||||||
'url': response.url,
|
'url': response.url,
|
||||||
'text': pure_text,
|
'text': pure_text,
|
||||||
'domain': urlparse(response.url).netloc,
|
'domain': urlparse(response.url).netloc,
|
||||||
'timestamp': datetime.datetime.now().isoformat()
|
'timestamp': article_timestamp(response.meta.get('date')).isoformat()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY summarizer.py run_news_summarizer.py ./
|
COPY summarizer.py run_news_summarizer.py intel.py nous_client.py ./
|
||||||
|
|
||||||
# Security: run as a non-privileged user.
|
# Security: run as a non-privileged user.
|
||||||
RUN useradd -m summarizer_user
|
RUN useradd -m summarizer_user
|
||||||
|
|
|
||||||
3
pytest.ini
Normal file
3
pytest.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
21
tests/test_cache_and_timeline.py
Normal file
21
tests/test_cache_and_timeline.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Timeline bucket_hours + static Cache-Control."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_uses_bucket_hours():
|
||||||
|
src = (ROOT / "app/main.py").read_text()
|
||||||
|
fn = src.split("async def get_timeline")[1].split("async def sentiment_by_source")[0]
|
||||||
|
assert "bucket_hours" in fn
|
||||||
|
assert "date_trunc('hour'" not in fn or "bucket" in fn.lower()
|
||||||
|
# Must not ignore the query param.
|
||||||
|
assert ":bucket" in fn or "bucket_hours" in fn.split("text(")[1][:800]
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_vendor_cache_control():
|
||||||
|
src = (ROOT / "app/main.py").read_text()
|
||||||
|
assert "max-age=31536000" in src or "immutable" in src.lower()
|
||||||
133
tests/test_event_ingest.py
Normal file
133
tests/test_event_ingest.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""Generic event ingest: idempotency, USGS ids, GDELT DOC URL."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_dedup_key_prefers_url():
|
||||||
|
from sources import event_dedup_key
|
||||||
|
|
||||||
|
assert event_dedup_key({"url": "https://earthquake.usgs.gov/earthquakes/eventpage/ci1"}) == (
|
||||||
|
"https://earthquake.usgs.gov/earthquakes/eventpage/ci1"
|
||||||
|
)
|
||||||
|
assert event_dedup_key({"url": " "}) is None
|
||||||
|
assert event_dedup_key({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_usgs_feature_keeps_id_and_url():
|
||||||
|
from sources import parse_usgs_feature
|
||||||
|
|
||||||
|
feature = {
|
||||||
|
"id": "ci39818991",
|
||||||
|
"properties": {
|
||||||
|
"title": "M 2.1 - 5 km W of",
|
||||||
|
"url": "https://earthquake.usgs.gov/earthquakes/eventpage/ci39818991",
|
||||||
|
"place": "5 km W of",
|
||||||
|
"mag": 2.1,
|
||||||
|
"time": 1_700_000_000_000,
|
||||||
|
},
|
||||||
|
"geometry": {"coordinates": [-118.5, 34.1, 10.0]},
|
||||||
|
}
|
||||||
|
event = parse_usgs_feature(feature)
|
||||||
|
assert event["url"] == "https://earthquake.usgs.gov/earthquakes/eventpage/ci39818991"
|
||||||
|
assert event["raw"]["usgs_id"] == "ci39818991"
|
||||||
|
assert event["location_lat"] == 34.1
|
||||||
|
assert event["location_lon"] == -118.5
|
||||||
|
assert event["source_type"] == "earthquake"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gdelt_uses_doc_api_and_query_param():
|
||||||
|
from sources import GDELT_API, gdelt_params
|
||||||
|
|
||||||
|
assert GDELT_API == "https://api.gdeltproject.org/api/v2/doc/doc"
|
||||||
|
params = gdelt_params(query="unrest", max_articles=50)
|
||||||
|
assert params["query"] == "unrest"
|
||||||
|
assert "search" not in params
|
||||||
|
assert params["mode"] == "ArtList"
|
||||||
|
assert params["format"] == "json"
|
||||||
|
assert int(params["maxrecords"]) == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_gdelt_default_query_when_empty():
|
||||||
|
from sources import gdelt_params
|
||||||
|
|
||||||
|
params = gdelt_params(query="", max_articles=25)
|
||||||
|
assert params["query"]
|
||||||
|
assert "unrest" in params["query"].lower() or "cyber" in params["query"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_gdelt_articles_maps_doc_payload():
|
||||||
|
from sources import parse_gdelt_articles
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"articles": [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/a",
|
||||||
|
"title": "Outage",
|
||||||
|
"seendate": "20240101T120000Z",
|
||||||
|
"domain": "example.com",
|
||||||
|
"language": "English",
|
||||||
|
"sourcecountry": "US",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
events = parse_gdelt_articles(payload)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["source_type"] == "gdel-t2"
|
||||||
|
assert events[0]["url"] == "https://example.com/a"
|
||||||
|
assert events[0]["title"] == "Outage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_event_skips_duplicate_url(monkeypatch):
|
||||||
|
"""Second insert with the same url must not hit events_table.insert."""
|
||||||
|
from ingestor import ingest_event
|
||||||
|
|
||||||
|
calls = {"insert": 0, "dedup": 0}
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
rowcount = 1
|
||||||
|
inserted_primary_key = ["evt-1"]
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
async def execute(self, stmt):
|
||||||
|
sql = str(stmt).lower()
|
||||||
|
if "event_dedup" in sql or "on conflict" in sql:
|
||||||
|
calls["dedup"] += 1
|
||||||
|
self_result = _Result()
|
||||||
|
if calls["dedup"] > 1:
|
||||||
|
self_result.rowcount = 0
|
||||||
|
return self_result
|
||||||
|
calls["insert"] += 1
|
||||||
|
return _Result()
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
import ingestor
|
||||||
|
|
||||||
|
monkeypatch.setattr(ingestor, "async_session", lambda: _Session())
|
||||||
|
|
||||||
|
msg = {
|
||||||
|
"source_type": "earthquake",
|
||||||
|
"title": "M 2.1",
|
||||||
|
"url": "https://earthquake.usgs.gov/earthquakes/eventpage/ci1",
|
||||||
|
"source_timestamp": datetime(2026, 1, 1, tzinfo=timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
first = await ingest_event(msg)
|
||||||
|
second = await ingest_event(msg)
|
||||||
|
return first, second
|
||||||
|
|
||||||
|
first, second = asyncio.run(run())
|
||||||
|
assert first is not None
|
||||||
|
assert second is None
|
||||||
|
assert calls["insert"] == 1
|
||||||
|
|
@ -169,3 +169,47 @@ def test_ingest_fire_row_correlates_from_hypertable_when_last_known_empty(monkey
|
||||||
assert len(correlated) == 1
|
assert len(correlated) == 1
|
||||||
assert correlated[0][1][0]["id"] == "acf001"
|
assert correlated[0][1][0]["id"] == "acf001"
|
||||||
assert correlated[0][0][0]["lat"] == 39.45678
|
assert correlated[0][0][0]["lat"] == 39.45678
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_fire_rows_one_execute_one_commit(monkeypatch):
|
||||||
|
"""93k FIRMS points must not be 93k commits."""
|
||||||
|
from ingestor import ingest_fire_rows
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self):
|
||||||
|
self.executes = 0
|
||||||
|
self.commits = 0
|
||||||
|
self.rowcount = 3
|
||||||
|
|
||||||
|
async def execute(self, *a, **k):
|
||||||
|
self.executes += 1
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
session = _Session()
|
||||||
|
import ingestor
|
||||||
|
monkeypatch.setattr(ingestor, "async_session", lambda: session)
|
||||||
|
|
||||||
|
msgs = [
|
||||||
|
make_fire_msg(latitude=39.1 + i * 0.01, longitude=-121.1)
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def no_corr(*a, **k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr("fire_aircraft.correlate_and_notify", no_corr)
|
||||||
|
monkeypatch.setattr("geofence.record_and_notify", no_corr)
|
||||||
|
|
||||||
|
inserted = asyncio.run(ingest_fire_rows(msgs))
|
||||||
|
assert inserted == 3
|
||||||
|
assert session.executes == 1
|
||||||
|
assert session.commits == 1
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ def test_ingest_fires_uses_keystore_key(monkeypatch):
|
||||||
published.extend(points)
|
published.extend(points)
|
||||||
return len(points)
|
return len(points)
|
||||||
|
|
||||||
monkeypatch.setattr("fire_sources.publish_fire_batch", fake_publish)
|
monkeypatch.setattr("fire_sources.persist_hotspots", fake_publish)
|
||||||
|
|
||||||
assert asyncio.run(ingest_fires()) == 10 # NOAA-20 + NOAA-21 dual-write
|
assert asyncio.run(ingest_fires()) == 10 # NOAA-20 + NOAA-21 dual-write
|
||||||
assert "a" * 32 in captured["url"]
|
assert "a" * 32 in captured["url"]
|
||||||
|
|
|
||||||
44
tests/test_frontend_reliability.py
Normal file
44
tests/test_frontend_reliability.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""HUD load-time: no market 404 poll, deferred overlays, WS backoff, nginx snippet."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
HTML = (ROOT / "app/static/index.html").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarizer_dockerfile_copies_intel_modules():
|
||||||
|
df = (ROOT / "news/summerizer/Dockerfile").read_text()
|
||||||
|
assert "intel.py" in df
|
||||||
|
assert "nous_client.py" in df
|
||||||
|
|
||||||
|
|
||||||
|
def test_nginx_ws_snippet_has_upgrade_headers():
|
||||||
|
conf = (ROOT / "deploy/osint-ws.nginx.conf").read_text()
|
||||||
|
assert "proxy_http_version 1.1" in conf
|
||||||
|
assert "Upgrade" in conf
|
||||||
|
assert "Connection" in conf
|
||||||
|
assert "/ws/" in conf
|
||||||
|
|
||||||
|
|
||||||
|
def test_market_ticker_does_not_poll_unwired_endpoint():
|
||||||
|
assert "setInterval(probeMarket" not in HTML
|
||||||
|
assert "initMarketTicker()" not in HTML or "probeMarket();" not in HTML.split("function initMarketTicker")[1][:400]
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_defers_nonessential_overlays():
|
||||||
|
init = HTML.split("function initMap")[1].split("function readMapPrefs")[0]
|
||||||
|
# Must not fire all four DB loads + live overlays in the same tick.
|
||||||
|
assert "setTimeout" in init or "requestAnimationFrame" in init
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_reconnect_uses_backoff():
|
||||||
|
assert "setTimeout(connectLiveWs, 4000)" not in HTML
|
||||||
|
ws = HTML.split("function connectLiveWs")[1][:1200]
|
||||||
|
assert "backoff" in ws.lower() or "wsRetry" in ws or "wsDelay" in ws
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_health_treats_degraded_status():
|
||||||
|
fn = HTML.split("async function checkHealth")[1].split("/* ═══════════════ NAV")[0]
|
||||||
|
assert "degraded" in fn.lower() or "d.status" in fn
|
||||||
33
tests/test_health_ready.py
Normal file
33
tests/test_health_ready.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Liveness stays up; readiness/freshness is explicit."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
BASE = "http://test"
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(path: str) -> httpx.Response:
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
|
||||||
|
return await client.get(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_includes_checks_even_when_db_ok(monkeypatch):
|
||||||
|
"""HUD must be able to show degraded without docker killing the container."""
|
||||||
|
body = asyncio.run(_get("/api/health")).json()
|
||||||
|
assert "status" in body
|
||||||
|
assert "checks" in body
|
||||||
|
assert "db" in body["checks"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_endpoint_exists():
|
||||||
|
resp = asyncio.run(_get("/api/ready"))
|
||||||
|
assert resp.status_code in (200, 503)
|
||||||
|
body = resp.json()
|
||||||
|
assert "checks" in body
|
||||||
|
assert "status" in body
|
||||||
|
|
@ -472,3 +472,103 @@ def test_transform_ais_position_decodes_navstat():
|
||||||
assert row is not None
|
assert row is not None
|
||||||
assert row["extra"]["nav"] == "moored"
|
assert row["extra"]["nav"] == "moored"
|
||||||
assert row["extra"]["navstat"] == 5
|
assert row["extra"]["navstat"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_nws_alerts_does_not_send_bbox_param(monkeypatch):
|
||||||
|
"""api.weather.gov/alerts/active 400s on bbox — clip locally instead."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from live_layers import fetch_weather_alerts, _cache
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def fake_get(url, params=None):
|
||||||
|
seen.append((url, dict(params or {})))
|
||||||
|
if "weather.gov" in url:
|
||||||
|
return {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": {"event": "Tornado Warning", "severity": "Extreme"},
|
||||||
|
"geometry": {"type": "Point", "coordinates": [-78.7, 35.8]},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
return {"type": "FeatureCollection", "features": []}
|
||||||
|
|
||||||
|
monkeypatch.setattr("live_layers._get_json", fake_get)
|
||||||
|
_cache.clear()
|
||||||
|
fc = asyncio.run(fetch_weather_alerts(None, "-79.0,35.5,-78.0,36.0"))
|
||||||
|
nws_calls = [p for u, p in seen if "weather.gov" in u]
|
||||||
|
assert nws_calls, "NWS should still be fetched"
|
||||||
|
assert "bbox" not in nws_calls[0]
|
||||||
|
assert fc.get("nws_ok") is True
|
||||||
|
assert len(fc["features"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_nws_alerts_failure_is_flagged(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from live_layers import fetch_weather_alerts, _cache
|
||||||
|
|
||||||
|
async def fake_get(url, params=None):
|
||||||
|
if "weather.gov" in url:
|
||||||
|
raise RuntimeError("400 Bad Request")
|
||||||
|
return {"type": "FeatureCollection", "features": []}
|
||||||
|
|
||||||
|
monkeypatch.setattr("live_layers._get_json", fake_get)
|
||||||
|
_cache.clear()
|
||||||
|
fc = asyncio.run(fetch_weather_alerts(None, None))
|
||||||
|
assert fc.get("nws_ok") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_aircraft_get_path_does_not_persist(monkeypatch):
|
||||||
|
"""GET /api/aircraft must serve last-known without track/geofence writes."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from live_layers import (
|
||||||
|
aircraft_last_known, fetch_aircraft, persist_aircraft_snapshot, _cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
aircraft_last_known.clear()
|
||||||
|
aircraft_last_known["abc"] = {
|
||||||
|
"id": "abc", "lat": 35.8, "lon": -78.7, "heading": 90, "speed": 400,
|
||||||
|
"label": "ABC", "extra": {},
|
||||||
|
}
|
||||||
|
writes = {"n": 0}
|
||||||
|
|
||||||
|
async def boom(*a, **k):
|
||||||
|
writes["n"] += 1
|
||||||
|
raise AssertionError("GET path must not persist")
|
||||||
|
|
||||||
|
monkeypatch.setattr("tracks.record_position", boom)
|
||||||
|
monkeypatch.setattr("geofence.record_and_notify", boom)
|
||||||
|
_cache.clear()
|
||||||
|
rows = asyncio.run(fetch_aircraft("-79,35,-78,36", persist=False))
|
||||||
|
assert writes["n"] == 0
|
||||||
|
assert any(r["id"] == "abc" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_aircraft_snapshot_writes_tracks(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from live_layers import persist_aircraft_snapshot
|
||||||
|
|
||||||
|
recorded = []
|
||||||
|
|
||||||
|
async def fake_record(kind, marker):
|
||||||
|
recorded.append((kind, marker["id"]))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fake_gf(**kw):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr("tracks.record_position", fake_record)
|
||||||
|
monkeypatch.setattr("geofence.record_and_notify", fake_gf)
|
||||||
|
monkeypatch.setattr("ws_manager.manager.has_clients", lambda: False)
|
||||||
|
|
||||||
|
rows = [{
|
||||||
|
"id": "abc", "lat": 35.8, "lon": -78.7, "heading": 90, "speed": 400,
|
||||||
|
"label": "ABC", "extra": {},
|
||||||
|
}]
|
||||||
|
asyncio.run(persist_aircraft_snapshot(rows))
|
||||||
|
assert recorded == [("aircraft", "abc")]
|
||||||
|
|
|
||||||
66
tests/test_new_sources.py
Normal file
66
tests/test_new_sources.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""NASA EONET + CISA KEV parsers (no network)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_eonet_keeps_stable_ids_and_points():
|
||||||
|
from sources import parse_eonet_events
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": "EONET_6363",
|
||||||
|
"title": "Etna Volcano",
|
||||||
|
"categories": [{"id": "volcanoes", "title": "Volcanoes"}],
|
||||||
|
"geometry": [
|
||||||
|
{"date": "2024-01-01T00:00:00Z", "type": "Point", "coordinates": [15.0, 37.7]},
|
||||||
|
],
|
||||||
|
"link": "https://eonet.gsfc.nasa.gov/api/v3/events/EONET_6363",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "EONET_skip",
|
||||||
|
"title": "No geometry",
|
||||||
|
"categories": [],
|
||||||
|
"geometry": [],
|
||||||
|
"link": "https://eonet.gsfc.nasa.gov/api/v3/events/EONET_skip",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
events = parse_eonet_events(payload)
|
||||||
|
assert len(events) == 1
|
||||||
|
ev = events[0]
|
||||||
|
assert ev["url"] == "https://eonet.gsfc.nasa.gov/api/v3/events/EONET_6363"
|
||||||
|
assert ev["source_type"] == "disaster"
|
||||||
|
assert ev["location_lat"] == 37.7
|
||||||
|
assert ev["location_lon"] == 15.0
|
||||||
|
assert ev["raw"]["eonet_id"] == "EONET_6363"
|
||||||
|
assert "volcanoes" in ev["tags"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_cisa_kev_emits_cve_url_no_coords():
|
||||||
|
from sources import parse_cisa_kev
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"vulnerabilities": [
|
||||||
|
{
|
||||||
|
"cveID": "CVE-2024-1234",
|
||||||
|
"vendorProject": "Acme",
|
||||||
|
"product": "Widget",
|
||||||
|
"vulnerabilityName": "RCE",
|
||||||
|
"dateAdded": "2024-06-01",
|
||||||
|
"shortDescription": "Remote code execution",
|
||||||
|
"requiredAction": "Apply updates",
|
||||||
|
"dueDate": "2024-06-22",
|
||||||
|
"knownRansomwareCampaignUse": "Known",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
events = parse_cisa_kev(payload)
|
||||||
|
assert len(events) == 1
|
||||||
|
ev = events[0]
|
||||||
|
assert ev["url"] == "https://nvd.nist.gov/vuln/detail/CVE-2024-1234"
|
||||||
|
assert ev["location_lat"] is None
|
||||||
|
assert ev["location_lon"] is None
|
||||||
|
assert "cisa-kev" in ev["tags"]
|
||||||
|
assert "CVE-2024-1234" in ev["tags"]
|
||||||
|
assert ev["raw"]["cveID"] == "CVE-2024-1234"
|
||||||
42
tests/test_news_scraper.py
Normal file
42
tests/test_news_scraper.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""News spider/pipeline: skip audio, use pubDate, don't log dupes as errors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_SCRAPER = ROOT / "news/scraper"
|
||||||
|
if str(_SCRAPER) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRAPER))
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_audio_enclosure():
|
||||||
|
from newsScraper.feed_util import is_audio_url
|
||||||
|
|
||||||
|
assert is_audio_url("https://cdn.example/podcast.mp3") is True
|
||||||
|
assert is_audio_url("https://cdn.example/show.m4a?x=1") is True
|
||||||
|
assert is_audio_url("https://www.example.com/world/story") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_article_timestamp_prefers_pubdate():
|
||||||
|
from newsScraper.feed_util import article_timestamp
|
||||||
|
|
||||||
|
ts = article_timestamp("Tue, 01 Apr 2025 12:00:00 GMT")
|
||||||
|
assert ts.tzinfo is not None
|
||||||
|
assert ts.year == 2025
|
||||||
|
assert ts.month == 4
|
||||||
|
assert ts.day == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_does_not_wrap_dropitem_as_error():
|
||||||
|
src = (ROOT / "news/scraper/newsScraper/pipelines.py").read_text()
|
||||||
|
assert "except DropItem" in src
|
||||||
|
assert "seen_urls.add" in src or "self.seen_urls.add" in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_spider_skips_audio_before_request():
|
||||||
|
src = (ROOT / "news/scraper/newsScraper/spiders/news_spider.py").read_text()
|
||||||
|
assert "is_audio_url" in src
|
||||||
|
assert "article_timestamp" in src
|
||||||
|
assert "datetime.datetime.now()" not in src
|
||||||
|
|
@ -55,7 +55,7 @@ def test_ingest_fires_hits_http_once_within_ttl(monkeypatch):
|
||||||
async def fake_publish(points):
|
async def fake_publish(points):
|
||||||
return len(points)
|
return len(points)
|
||||||
|
|
||||||
monkeypatch.setattr("fire_sources.publish_fire_batch", fake_publish)
|
monkeypatch.setattr("fire_sources.persist_hotspots", fake_publish)
|
||||||
|
|
||||||
assert asyncio.run(ingest_fires()) == 5
|
assert asyncio.run(ingest_fires()) == 5
|
||||||
assert asyncio.run(ingest_fires()) == 5
|
assert asyncio.run(ingest_fires()) == 5
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue