| 1 | """Connectors for REAL, publicly-available data feeds. Every function returns normalized rows |
| 2 | that the aggregation pipeline ingests. No keys, no scraping, no TOS violations - these are open |
| 3 | government / academic / OSM APIs. Provenance (source + external_id) is preserved on every row. |
| 4 | |
| 5 | Fixed assets -> SensorAsset dicts (cameras, weather, radar, hydro, seismic) |
| 6 | Moving entities -> LiveEntity dicts (aircraft via ADS-B, satellites via TLE) |
| 7 | """ |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import json |
| 11 | import os |
| 12 | import re |
| 13 | from datetime import datetime, timezone |
| 14 | from urllib.parse import quote |
| 15 | |
| 16 | import httpx |
| 17 | |
| 18 | UA = {"User-Agent": "Sightline-OSINT/1.0 (public-data aggregation; research)"} |
| 19 | _OMM_CACHE = os.path.join(os.path.dirname(__file__), "..", "..", "sample_data", "sat_omm.json") |
| 20 | US_BBOX = (24.0, -125.0, 49.5, -66.0) |
| 21 | |
| 22 | STATES = ["AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA","HI","ID","IL","IN","IA","KS","KY", |
| 23 | "LA","ME","MD","MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ","NM","NY","NC","ND", |
| 24 | "OH","OK","OR","PA","RI","SC","SD","TN","TX","UT","VT","VA","WA","WV","WI","WY"] |
| 25 | |
| 26 | |
| 27 | def _get(url, **kw): |
| 28 | return httpx.get(url, headers=UA, timeout=kw.pop("timeout", 40), **kw) |
| 29 | |
| 30 | |
| 31 | def nyc_traffic_cameras() -> list[dict]: |
| 32 | """NYC DOT traffic cameras - REAL public live JPEG snapshots, no key. ~1,000+ cameras with |
| 33 | direct imageUrl that refreshes. The 'live feed' layer the ALPR-location layer can't provide.""" |
| 34 | r = _get("https://webcams.nyctmc.org/api/cameras", timeout=40) |
| 35 | if r.status_code != 200: |
| 36 | return [] |
| 37 | try: |
| 38 | items = r.json() |
| 39 | except Exception: |
| 40 | return [] |
| 41 | if isinstance(items, dict): |
| 42 | items = items.get("items") or items.get("cameras") or [] |
| 43 | out = [] |
| 44 | for c in items: |
| 45 | cid = c.get("id") |
| 46 | if not cid or c.get("latitude") is None: |
| 47 | continue |
| 48 | img = c.get("imageUrl") or f"https://webcams.nyctmc.org/api/cameras/{cid}/image" |
| 49 | out.append({"external_id": f"NYCDOT-{cid}", "name": c.get("name"), |
| 50 | "operator": "NYC DOT", "state": "NY", "area": c.get("area"), |
| 51 | "lat": c.get("latitude"), "lon": c.get("longitude"), |
| 52 | "online": bool(c.get("isOnline", True)), "image_url": img, |
| 53 | "live_source": "nyc_dot"}) |
| 54 | return out |
| 55 | |
| 56 | |
| 57 | def caltrans_cameras() -> list[dict]: |
| 58 | """Caltrans (California DOT) CCTV - REAL public live JPEG snapshots, no key, ~3,500 statewide |
| 59 | across 12 districts, each with lat/lon, county, route, direction.""" |
| 60 | out = [] |
| 61 | for d in range(1, 13): |
| 62 | url = f"https://cwwp2.dot.ca.gov/data/d{d}/cctv/cctvStatusD{d:02d}.json" |
| 63 | try: |
| 64 | r = _get(url, timeout=25) |
| 65 | if r.status_code != 200: |
| 66 | continue |
| 67 | for rec in r.json().get("data", []): |
| 68 | c = rec.get("cctv", {}) |
| 69 | loc = c.get("location", {}) |
| 70 | img = (c.get("imageData", {}).get("static", {}) or {}).get("currentImageURL") |
| 71 | lat, lon = loc.get("latitude"), loc.get("longitude") |
| 72 | if not img or not lat or not lon: |
| 73 | continue |
| 74 | try: |
| 75 | lat, lon = float(lat), float(lon) |
| 76 | except (TypeError, ValueError): |
| 77 | continue |
| 78 | if lat == 0 or lon == 0: |
| 79 | continue |
| 80 | out.append({"external_id": f"CALTRANS-D{d:02d}-{c.get('index') or loc.get('locationName','')[:20]}", |
| 81 | "name": loc.get("locationName") or loc.get("nearbyPlace"), |
| 82 | "operator": "Caltrans", "state": "CA", |
| 83 | "area": loc.get("county") or f"District {d}", |
| 84 | "lat": lat, "lon": lon, "online": True, "image_url": img, |
| 85 | "live_source": "caltrans"}) |
| 86 | except Exception: |
| 87 | continue |
| 88 | return out |
| 89 | |
| 90 | |
| 91 | def tripcheck_cameras() -> list[dict]: |
| 92 | """Oregon DOT TripCheck CCTV - REAL public live JPEG snapshots, no key, ~1,100 statewide. |
| 93 | Inventory is a public ArcGIS-style JSON; images refresh at /RoadCams/cams/<filename>.""" |
| 94 | try: |
| 95 | r = _get("https://tripcheck.com/Scripts/map/data/cctvinventory.js", timeout=40) |
| 96 | if r.status_code != 200: |
| 97 | return [] |
| 98 | feats = json.loads(r.text).get("features", []) |
| 99 | except Exception: |
| 100 | return [] |
| 101 | out = [] |
| 102 | for f in feats: |
| 103 | a = f.get("attributes", {}) |
| 104 | fn, lat, lon = a.get("filename"), a.get("latitude"), a.get("longitude") |
| 105 | if not fn or not lat or not lon: |
| 106 | continue |
| 107 | try: |
| 108 | lat, lon = float(lat), float(lon) |
| 109 | except (TypeError, ValueError): |
| 110 | continue |
| 111 | out.append({"external_id": f"TRIPCHECK-{a.get('cameraId')}-{a.get('publishedImageId')}", |
| 112 | "name": (a.get("title") or fn).strip(), |
| 113 | "operator": "Oregon DOT", "state": "OR", |
| 114 | "area": (a.get("route") or "").strip() or "Oregon", |
| 115 | "lat": lat, "lon": lon, "online": True, |
| 116 | "image_url": "https://tripcheck.com/RoadCams/cams/" + quote(fn), |
| 117 | "live_source": "tripcheck"}) |
| 118 | return out |
| 119 | |
| 120 | |
| 121 | _WKT_POINT = re.compile(r"POINT \((-?[\d.]+) (-?[\d.]+)\)") |
| 122 | _511_STATE = {"Florida": "FL", "Arizona": "AZ", "Vermont": "VT", "New Hampshire": "NH", |
| 123 | "Maine": "ME", "Massachusetts": "MA", "Connecticut": "CT", "Rhode Island": "RI", |
| 124 | "Georgia": "GA"} |
| 125 | |
| 126 | |
| 127 | def _ibi_511_cameras(host: str, prefix: str, operator: str, default_state: str, |
| 128 | live_source: str, max_pages: int = 60) -> list[dict]: |
| 129 | """Shared connector for IBI-platform 511 sites (fl511.com, az511.gov, newengland511.org...). |
| 130 | Public DataTables endpoint, no key; live snapshots at https://<host>/map/Cctv/<id>.""" |
| 131 | import time |
| 132 | out, seen, start, page_fails = [], set(), 0, 0 |
| 133 | for _ in range(max_pages): |
| 134 | rows = None |
| 135 | for attempt in range(3): |
| 136 | try: |
| 137 | r = httpx.post(f"https://{host}/List/GetData/Cameras", headers=UA, timeout=40, |
| 138 | data={"draw": "1", "start": str(start), "length": "100"}) |
| 139 | rows = r.json().get("data") or [] |
| 140 | break |
| 141 | except Exception: |
| 142 | time.sleep(1 + attempt) |
| 143 | if rows is None: |
| 144 | page_fails += 1 |
| 145 | if page_fails >= 2: |
| 146 | break |
| 147 | start += 100 |
| 148 | continue |
| 149 | page_fails = 0 |
| 150 | if not rows: |
| 151 | break |
| 152 | for d in rows: |
| 153 | wkt = ((d.get("latLng") or {}).get("geography") or {}).get("wellKnownText") or "" |
| 154 | m = _WKT_POINT.search(wkt) |
| 155 | img = next((i.get("imageUrl") for i in (d.get("images") or []) |
| 156 | if i.get("imageUrl") and not i.get("blocked")), None) |
| 157 | ext = f"{prefix}-{d.get('id')}" |
| 158 | if not m or not img or d.get("id") is None or ext in seen: |
| 159 | continue |
| 160 | seen.add(ext) |
| 161 | if img.startswith("/"): |
| 162 | img = f"https://{host}{img}" |
| 163 | out.append({"external_id": ext, |
| 164 | "name": d.get("location") or f"{d.get('roadway')} {d.get('direction') or ''}".strip(), |
| 165 | "operator": operator, |
| 166 | "state": _511_STATE.get(d.get("state"), default_state), |
| 167 | "area": d.get("roadway") or d.get("region") or default_state, |
| 168 | "lat": float(m.group(2)), "lon": float(m.group(1)), |
| 169 | "online": bool(d.get("visible", True)), "image_url": img, |
| 170 | "live_source": live_source}) |
| 171 | start += 100 |
| 172 | return out |
| 173 | |
| 174 | |
| 175 | def fl511_cameras() -> list[dict]: |
| 176 | """Florida FL511 CCTV - REAL public live JPEG snapshots, no key, ~4,800 statewide.""" |
| 177 | return _ibi_511_cameras("fl511.com", "FL511", "Florida DOT (FL511)", "FL", "fl511") |
| 178 | |
| 179 | |
| 180 | def az511_cameras() -> list[dict]: |
| 181 | """Arizona AZ511 CCTV - REAL public live JPEG snapshots, no key, ~600 statewide.""" |
| 182 | return _ibi_511_cameras("az511.gov", "AZ511", "Arizona DOT (AZ511)", "AZ", "az511") |
| 183 | |
| 184 | |
| 185 | def ne511_cameras() -> list[dict]: |
| 186 | """New England 511 CCTV (VT/NH/ME DOTs) - REAL public live JPEG snapshots, no key, ~400.""" |
| 187 | return _ibi_511_cameras("newengland511.org", "NE511", "New England 511", "VT", "ne511") |
| 188 | |
| 189 | |
| 190 | def nws_stations(state: str, cap: int = 120) -> list[dict]: |
| 191 | r = _get(f"https://api.weather.gov/stations?state={state}&limit={cap}") |
| 192 | if r.status_code != 200: |
| 193 | return [] |
| 194 | out = [] |
| 195 | for f in r.json().get("features", []): |
| 196 | p = f.get("properties", {}) |
| 197 | c = (f.get("geometry") or {}).get("coordinates") or [None, None] |
| 198 | out.append({"kind": "weather_station", "category": "weather", "source": "nws", |
| 199 | "external_id": p.get("stationIdentifier"), "name": p.get("name"), |
| 200 | "operator": "NOAA / NWS", "lat": c[1], "lon": c[0], "state": state, |
| 201 | "attributes": {"elevation_m": (p.get("elevation") or {}).get("value"), |
| 202 | "timezone": p.get("timeZone")}}) |
| 203 | return out |
| 204 | |
| 205 | |
| 206 | def nws_radar() -> list[dict]: |
| 207 | r = _get("https://api.weather.gov/radar/stations") |
| 208 | if r.status_code != 200: |
| 209 | return [] |
| 210 | out = [] |
| 211 | for f in r.json().get("features", []): |
| 212 | p = f.get("properties", {}) |
| 213 | c = (f.get("geometry") or {}).get("coordinates") or [None, None] |
| 214 | out.append({"kind": "weather_radar", "category": "weather", "source": "nws", |
| 215 | "external_id": p.get("id") or p.get("stationId"), |
| 216 | "name": p.get("name"), "operator": "NOAA / NWS", |
| 217 | "lat": c[1], "lon": c[0], "state": None, |
| 218 | "attributes": {"type": p.get("stationType"), "rda": (p.get("rda") or {})}}) |
| 219 | return out |
| 220 | |
| 221 | |
| 222 | def usgs_gauges(state: str, cap: int = 200) -> list[dict]: |
| 223 | r = _get(f"https://waterservices.usgs.gov/nwis/iv/?format=json&stateCd={state}" |
| 224 | f"¶meterCd=00060&siteStatus=active") |
| 225 | if r.status_code != 200: |
| 226 | return [] |
| 227 | out = [] |
| 228 | for ts in r.json().get("value", {}).get("timeSeries", [])[:cap]: |
| 229 | src = ts.get("sourceInfo", {}) |
| 230 | geo = (src.get("geoLocation") or {}).get("geogLocation") or {} |
| 231 | vals = (ts.get("values") or [{}])[0].get("value") or [] |
| 232 | latest = vals[-1]["value"] if vals else None |
| 233 | out.append({"kind": "stream_gauge", "category": "hydro", "source": "usgs", |
| 234 | "external_id": (src.get("siteCode") or [{}])[0].get("value"), |
| 235 | "name": src.get("siteName"), "operator": "USGS", |
| 236 | "lat": geo.get("latitude"), "lon": geo.get("longitude"), |
| 237 | "state": state, "attributes": {"discharge_cfs": latest}}) |
| 238 | return out |
| 239 | |
| 240 | |
| 241 | def usgs_quakes(min_mag: float = 1.0) -> list[dict]: |
| 242 | r = _get("https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson") |
| 243 | if r.status_code != 200: |
| 244 | return [] |
| 245 | out = [] |
| 246 | for f in r.json().get("features", []): |
| 247 | p, g = f.get("properties", {}), (f.get("geometry") or {}).get("coordinates") or [None, None, None] |
| 248 | if (p.get("mag") or 0) < min_mag: |
| 249 | continue |
| 250 | out.append({"kind": "seismic_event", "category": "seismic", "source": "usgs", |
| 251 | "external_id": f.get("id"), "name": p.get("place"), "operator": "USGS", |
| 252 | "lat": g[1], "lon": g[0], "state": None, |
| 253 | "attributes": {"magnitude": p.get("mag"), "depth_km": g[2], |
| 254 | "time": p.get("time"), "felt": p.get("felt")}}) |
| 255 | return out |
| 256 | |
| 257 | |
| 258 | def opensky_aircraft(bbox=US_BBOX) -> list[dict]: |
| 259 | la1, lo1, la2, lo2 = bbox |
| 260 | r = _get(f"https://opensky-network.org/api/states/all?lamin={la1}&lomin={lo1}" |
| 261 | f"&lamax={la2}&lomax={lo2}", timeout=60) |
| 262 | if r.status_code != 200: |
| 263 | return [] |
| 264 | now = datetime.now(timezone.utc) |
| 265 | out = [] |
| 266 | for s in (r.json().get("states") or []): |
| 267 | icao, callsign, country = s[0], (s[1] or "").strip(), s[2] |
| 268 | lon, lat, alt, on_ground, vel, heading, vrate = s[5], s[6], s[7], s[8], s[9], s[10], s[11] |
| 269 | if lat is None or lon is None: |
| 270 | continue |
| 271 | out.append({"kind": "aircraft", "source": "opensky", "identifier": icao, |
| 272 | "label": callsign or icao, "lat": lat, "lon": lon, |
| 273 | "altitude_m": alt, "heading": heading, "velocity": vel, |
| 274 | "origin_country": country, |
| 275 | "attributes": {"on_ground": on_ground, "vertical_rate": vrate, |
| 276 | "geo_altitude": s[13] if len(s) > 13 else None, |
| 277 | "squawk": s[14] if len(s) > 14 else None}}) |
| 278 | return out |
| 279 | |
| 280 | |
| 281 | def celestrak_satellites(cap: int = 800, group: str = "active") -> list[dict]: |
| 282 | """Active satellites from CelesTrak (OMM JSON). Positions propagated to 'now' with SGP4 via |
| 283 | sgp4.omm.initialize - real current sub-points, not placeholders. CelesTrak throttles repeat |
| 284 | pulls of the same GROUP per IP for ~2h; on throttle this returns [] gracefully.""" |
| 285 | try: |
| 286 | from sgp4.api import Satrec, jday |
| 287 | from sgp4 import omm |
| 288 | except Exception: |
| 289 | return [] |
| 290 | r = _get(f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=json", timeout=60) |
| 291 | if r.status_code != 200 or not r.headers.get("content-type", "").startswith("application/json"): |
| 292 | return [] |
| 293 | try: |
| 294 | elements = r.json() |
| 295 | except Exception: |
| 296 | return [] |
| 297 | now = datetime.now(timezone.utc) |
| 298 | jd, fr = jday(now.year, now.month, now.day, now.hour, now.minute, |
| 299 | now.second + now.microsecond / 1e6) |
| 300 | gmst = _gmst(jd + fr) |
| 301 | out = [] |
| 302 | for el in elements[:cap]: |
| 303 | try: |
| 304 | sat = Satrec() |
| 305 | omm.initialize(sat, el) |
| 306 | e, r_eci, _ = sat.sgp4(jd, fr) |
| 307 | if e != 0: |
| 308 | continue |
| 309 | lat, lon, alt = _eci_to_geodetic(r_eci, gmst) |
| 310 | out.append({"kind": "satellite", "source": "celestrak", |
| 311 | "identifier": str(el.get("NORAD_CAT_ID")), "label": el.get("OBJECT_NAME"), |
| 312 | "lat": lat, "lon": lon, "altitude_m": alt * 1000.0, |
| 313 | "heading": None, "velocity": None, "origin_country": None, |
| 314 | "attributes": {"inclination": el.get("INCLINATION"), |
| 315 | "period_min": (1440.0 / float(el["MEAN_MOTION"])) if el.get("MEAN_MOTION") else None, |
| 316 | "omm": el}}) |
| 317 | except Exception: |
| 318 | continue |
| 319 | return out |
| 320 | |
| 321 | |
| 322 | def _fetch_omm(group: str) -> list[dict]: |
| 323 | r = _get(f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=json", timeout=60) |
| 324 | if r.status_code != 200 or not r.headers.get("content-type", "").startswith("application/json"): |
| 325 | return [] |
| 326 | try: |
| 327 | els = r.json() |
| 328 | except Exception: |
| 329 | return [] |
| 330 | for e in els: |
| 331 | e["_group"] = group |
| 332 | return els |
| 333 | |
| 334 | |
| 335 | def imaging_satellites(cap_per_group: int = 400) -> list[dict]: |
| 336 | """REAL imaging / Earth-observation satellites ('cameras in orbit'): Planet SkySat & Dove + |
| 337 | Earth-resources birds, positions propagated to now via SGP4. CelesTrak throttles repeat pulls |
| 338 | per IP (~2h); orbital elements are cached so positions keep propagating locally regardless.""" |
| 339 | try: |
| 340 | from sgp4.api import Satrec, jday |
| 341 | from sgp4 import omm as _omm |
| 342 | except Exception: |
| 343 | return [] |
| 344 | |
| 345 | elements: list[dict] = [] |
| 346 | for group in ("planet", "resource"): |
| 347 | elements += _fetch_omm(group)[:cap_per_group] |
| 348 | if elements: |
| 349 | try: |
| 350 | os.makedirs(os.path.dirname(_OMM_CACHE), exist_ok=True) |
| 351 | with open(_OMM_CACHE, "w") as fh: |
| 352 | json.dump(elements, fh) |
| 353 | except Exception: |
| 354 | pass |
| 355 | elif os.path.exists(_OMM_CACHE): |
| 356 | try: |
| 357 | with open(_OMM_CACHE) as fh: |
| 358 | elements = json.load(fh) |
| 359 | except Exception: |
| 360 | elements = [] |
| 361 | if not elements: |
| 362 | return [] |
| 363 | |
| 364 | now = datetime.now(timezone.utc) |
| 365 | jd, fr = jday(now.year, now.month, now.day, now.hour, now.minute, |
| 366 | now.second + now.microsecond / 1e6) |
| 367 | gmst = _gmst(jd + fr) |
| 368 | seen, out = set(), [] |
| 369 | for el in elements: |
| 370 | nid = str(el.get("NORAD_CAT_ID")) |
| 371 | if nid in seen: |
| 372 | continue |
| 373 | try: |
| 374 | sat = Satrec() |
| 375 | _omm.initialize(sat, el) |
| 376 | e, r_eci, _ = sat.sgp4(jd, fr) |
| 377 | if e != 0: |
| 378 | continue |
| 379 | lat, lon, alt = _eci_to_geodetic(r_eci, gmst) |
| 380 | seen.add(nid) |
| 381 | out.append({"kind": "satellite_camera", "source": "celestrak", "identifier": nid, |
| 382 | "label": el.get("OBJECT_NAME"), "lat": lat, "lon": lon, |
| 383 | "altitude_m": alt * 1000.0, "heading": None, "velocity": None, |
| 384 | "origin_country": None, |
| 385 | "attributes": {"constellation": el.get("_group"), |
| 386 | "inclination": el.get("INCLINATION"), |
| 387 | "period_min": (1440.0 / float(el["MEAN_MOTION"])) if el.get("MEAN_MOTION") else None, |
| 388 | "omm": el}}) |
| 389 | except Exception: |
| 390 | continue |
| 391 | return out |
| 392 | |
| 393 | |
| 394 | def _gmst(jd_ut1: float) -> float: |
| 395 | import math |
| 396 | t = (jd_ut1 - 2451545.0) / 36525.0 |
| 397 | g = 280.46061837 + 360.98564736629 * (jd_ut1 - 2451545.0) + 0.000387933 * t * t |
| 398 | return math.radians(g % 360.0) |
| 399 | |
| 400 | |
| 401 | def _eci_to_geodetic(r_eci, gmst): |
| 402 | import math |
| 403 | x, y, z = r_eci |
| 404 | lon = math.degrees(math.atan2(y, x) - gmst) |
| 405 | lon = ((lon + 180) % 360) - 180 |
| 406 | r = math.sqrt(x * x + y * y) |
| 407 | lat = math.degrees(math.atan2(z, r)) |
| 408 | alt = math.sqrt(x * x + y * y + z * z) - 6371.0 |
| 409 | return lat, lon, alt |