2026-08-24 14:54:51 -04:00
|
|
|
|
"""Camera discovery scraper — finds publicly listed open IP cameras.
|
|
|
|
|
|
|
|
|
|
|
|
Ethics/scope (hard constraints, enforced in code):
|
|
|
|
|
|
* Only indexes cameras that appear in PUBLIC directories/lists.
|
|
|
|
|
|
* No credential brute-forcing, no login attempts of any kind.
|
|
|
|
|
|
* No active scanning — never probes private (RFC1918/loopback/link-local)
|
|
|
|
|
|
ranges; private-range URLs found in public lists are dropped.
|
|
|
|
|
|
|
|
|
|
|
|
Pipeline per cycle:
|
|
|
|
|
|
1. Fetch each configured public source page/list.
|
|
|
|
|
|
2. Parse cameras (Insecam-style HTML with embedded lat/lon, or plain-text
|
|
|
|
|
|
`url[|lat,lon|vendor|location]` lines).
|
|
|
|
|
|
3. Geocode missing coords via Nominatim at <=1 req/s.
|
|
|
|
|
|
4. Publish each camera to NATS (`events.camera`) for the shared ingester,
|
|
|
|
|
|
and upsert into the `cameras` table keyed by sha256(url_hash).
|
|
|
|
|
|
|
|
|
|
|
|
Snapshots are cached locally with a TTL; nothing is fetched more often than
|
|
|
|
|
|
the cache allows.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import ipaddress
|
|
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import re
|
|
|
|
|
|
import time
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
from camera_config import (
|
|
|
|
|
|
CAMERA_SOURCE_URLS, CAMERA_REQUEST_DELAY, CAMERA_MAX_PER_SOURCE,
|
|
|
|
|
|
NOMINATIM_URL, NOMINATIM_MIN_INTERVAL, USER_AGENT,
|
|
|
|
|
|
SNAPSHOT_CACHE_DIR, SNAPSHOT_TTL_SECONDS, SNAPSHOT_TIMEOUT,
|
feat(cameras): UDOT IBI 511 parser -> cameras table
Add parse_udot_ibi_page + scrape_udot_ibi for the UDOT IBI 511 traffic
camera feed (prod-ut.ibi511.com, no key). DataTables endpoint is POST
form-encoded and caps at 100 rows/page regardless of `length`; page walk
uses recordsTotal with a UDOT_IBI_MAX_PAGES (default 40) runaway cap.
Skip images[0].blocked/disabled, parse WKT POINT(lng lat) from
latLng.geography.wellKnownText, drop outside the Utah bbox
(lat 36.9-42.1, lon -114.2--108.9). discovery_source=udot, stable
source_url == snapshot_url == /map/Cctv/{id} (never scrape frames),
url_hash dedupe, OSINT_USER_AGENT + X-Requested-With header.
Unit tests: WKT lng/lat order, blocked/disabled skip, bbox drop,
malformed payload, missing WKT.
2026-08-31 21:34:50 -04:00
|
|
|
|
UDOT_IBI_URL, UDOT_IBI_BASE, UDOT_IBI_PAGE_SIZE, UDOT_IBI_MAX_PAGES,
|
2026-08-24 14:54:51 -04:00
|
|
|
|
)
|
|
|
|
|
|
from camera_models import cameras
|
|
|
|
|
|
from database import async_session
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("osint.camera_scraper")
|
|
|
|
|
|
|
|
|
|
|
|
# ── Private-range guard ────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-08-27 15:46:49 -04:00
|
|
|
|
_public_host_cache: dict[str, bool] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 14:54:51 -04:00
|
|
|
|
def is_public_url(url: str) -> bool:
|
|
|
|
|
|
"""True only if the URL host resolves to a globally-routable address.
|
|
|
|
|
|
|
|
|
|
|
|
Hostnames that cannot be resolved are treated as not-public (fail closed).
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
parsed = urlparse(url)
|
|
|
|
|
|
if parsed.scheme not in ("http", "https", "rtsp", "rtsps"):
|
|
|
|
|
|
return False
|
|
|
|
|
|
host = parsed.hostname
|
|
|
|
|
|
if not host:
|
|
|
|
|
|
return False
|
|
|
|
|
|
# Literal IPs: check directly.
|
|
|
|
|
|
try:
|
|
|
|
|
|
addr = ipaddress.ip_address(host)
|
|
|
|
|
|
return addr.is_global and not addr.is_private
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
2026-08-27 15:46:49 -04:00
|
|
|
|
if host in _public_host_cache:
|
|
|
|
|
|
return _public_host_cache[host]
|
2026-08-24 14:54:51 -04:00
|
|
|
|
# Private-name quick rejects before any DNS work.
|
|
|
|
|
|
if (host.endswith(".local") or host.endswith(".internal")
|
|
|
|
|
|
or host == "localhost" or re.fullmatch(r"10\..*|172\.(1[6-9]|2\d|3[01])\..*|192\.168\..*", host)):
|
2026-08-27 15:46:49 -04:00
|
|
|
|
_public_host_cache[host] = False
|
2026-08-24 14:54:51 -04:00
|
|
|
|
return False
|
|
|
|
|
|
import socket
|
2026-08-27 15:46:49 -04:00
|
|
|
|
ok = True
|
2026-08-24 14:54:51 -04:00
|
|
|
|
for info in socket.getaddrinfo(host, None):
|
|
|
|
|
|
addr = ipaddress.ip_address(info[4][0])
|
|
|
|
|
|
if not (addr.is_global and not addr.is_private):
|
2026-08-27 15:46:49 -04:00
|
|
|
|
ok = False
|
|
|
|
|
|
break
|
|
|
|
|
|
_public_host_cache[host] = ok
|
|
|
|
|
|
return ok
|
2026-08-24 14:54:51 -04:00
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Dedupe key ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def url_hash(url: str) -> str:
|
|
|
|
|
|
return hashlib.sha256(url.strip().lower().encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Politeness-limited fetcher ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class RateLimitedClient:
|
|
|
|
|
|
"""httpx client wrapper enforcing a per-host minimum request interval."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, min_delay: float = CAMERA_REQUEST_DELAY):
|
|
|
|
|
|
self._delay = min_delay
|
|
|
|
|
|
self._last: dict[str, float] = {}
|
|
|
|
|
|
self.client = httpx.AsyncClient(
|
2026-08-27 15:46:49 -04:00
|
|
|
|
timeout=60, follow_redirects=True,
|
2026-08-24 14:54:51 -04:00
|
|
|
|
headers={"User-Agent": USER_AGENT},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def get(self, url: str, **kw) -> httpx.Response:
|
|
|
|
|
|
host = urlparse(url).netloc
|
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
|
wait = self._last.get(host, 0.0) + self._delay - now
|
|
|
|
|
|
if wait > 0:
|
|
|
|
|
|
await asyncio.sleep(wait)
|
|
|
|
|
|
self._last[host] = time.monotonic()
|
|
|
|
|
|
return await self.client.get(url, **kw)
|
|
|
|
|
|
|
feat(cameras): UDOT IBI 511 parser -> cameras table
Add parse_udot_ibi_page + scrape_udot_ibi for the UDOT IBI 511 traffic
camera feed (prod-ut.ibi511.com, no key). DataTables endpoint is POST
form-encoded and caps at 100 rows/page regardless of `length`; page walk
uses recordsTotal with a UDOT_IBI_MAX_PAGES (default 40) runaway cap.
Skip images[0].blocked/disabled, parse WKT POINT(lng lat) from
latLng.geography.wellKnownText, drop outside the Utah bbox
(lat 36.9-42.1, lon -114.2--108.9). discovery_source=udot, stable
source_url == snapshot_url == /map/Cctv/{id} (never scrape frames),
url_hash dedupe, OSINT_USER_AGENT + X-Requested-With header.
Unit tests: WKT lng/lat order, blocked/disabled skip, bbox drop,
malformed payload, missing WKT.
2026-08-31 21:34:50 -04:00
|
|
|
|
async def post(self, url: str, **kw) -> httpx.Response:
|
|
|
|
|
|
host = urlparse(url).netloc
|
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
|
wait = self._last.get(host, 0.0) + self._delay - now
|
|
|
|
|
|
if wait > 0:
|
|
|
|
|
|
await asyncio.sleep(wait)
|
|
|
|
|
|
self._last[host] = time.monotonic()
|
|
|
|
|
|
return await self.client.post(url, **kw)
|
|
|
|
|
|
|
2026-08-24 14:54:51 -04:00
|
|
|
|
async def aclose(self):
|
|
|
|
|
|
await self.client.aclose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Nominatim geocoding (1 req/s politeness) ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class Geocoder:
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self._min_interval = NOMINATIM_MIN_INTERVAL
|
|
|
|
|
|
self._last = 0.0
|
|
|
|
|
|
self._cache: dict[str, tuple[float | None, float | None]] = {}
|
|
|
|
|
|
self._lock = asyncio.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
async def geocode(self, place: str) -> tuple[float | None, float | None]:
|
|
|
|
|
|
place = place.strip()
|
|
|
|
|
|
if not place:
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
if place in self._cache:
|
|
|
|
|
|
return self._cache[place]
|
|
|
|
|
|
async with self._lock:
|
|
|
|
|
|
wait = self._last + self._min_interval - time.monotonic()
|
|
|
|
|
|
if wait > 0:
|
|
|
|
|
|
await asyncio.sleep(wait)
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
|
|
|
|
r = await c.get(
|
|
|
|
|
|
f"{NOMINATIM_URL}/search",
|
|
|
|
|
|
params={"q": place, "format": "json", "limit": 1},
|
|
|
|
|
|
headers={"User-Agent": USER_AGENT},
|
|
|
|
|
|
)
|
|
|
|
|
|
r.raise_for_status()
|
|
|
|
|
|
data = r.json()
|
|
|
|
|
|
result = (
|
|
|
|
|
|
(float(data[0]["lat"]), float(data[0]["lon"]))
|
|
|
|
|
|
if data else (None, None)
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
logger.warning("geocode failed for %r", place, exc_info=True)
|
|
|
|
|
|
result = (None, None)
|
|
|
|
|
|
self._last = time.monotonic()
|
|
|
|
|
|
self._cache[place] = result
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Snapshot cache (local disk, TTL'd) ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
_snapshot_lock = asyncio.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_snapshot(url: str) -> bytes | None:
|
|
|
|
|
|
"""Fetch a snapshot image through the local TTL cache. Returns raw bytes."""
|
|
|
|
|
|
key = url_hash(url)[:24]
|
|
|
|
|
|
path = Path(SNAPSHOT_CACHE_DIR) / f"{key}.bin"
|
|
|
|
|
|
meta = Path(SNAPSHOT_CACHE_DIR) / f"{key}.meta"
|
|
|
|
|
|
async with _snapshot_lock:
|
|
|
|
|
|
try:
|
|
|
|
|
|
if path.exists() and meta.exists():
|
|
|
|
|
|
age = time.time() - path.stat().st_mtime
|
|
|
|
|
|
if age < SNAPSHOT_TTL_SECONDS:
|
|
|
|
|
|
return path.read_bytes()
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=SNAPSHOT_TIMEOUT, follow_redirects=True) as c:
|
|
|
|
|
|
r = await c.get(url, headers={"User-Agent": USER_AGENT})
|
|
|
|
|
|
if r.status_code != 200 or len(r.content) < 64:
|
|
|
|
|
|
return None
|
|
|
|
|
|
data = r.content
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
return None
|
|
|
|
|
|
async with _snapshot_lock:
|
|
|
|
|
|
try:
|
|
|
|
|
|
Path(SNAPSHOT_CACHE_DIR).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
path.write_bytes(data)
|
|
|
|
|
|
meta.write_text(json.dumps({"url": url, "fetched": time.time()}))
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
logger.warning("snapshot cache write failed", exc_info=True)
|
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Source parsers ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
# Insecam-style pages embed camera entries like:
|
|
|
|
|
|
# <a href="/view/..."> ... latitude: 48.85 / longitude: 2.35 ... title="Paris"
|
|
|
|
|
|
# We parse generically: find lat/lon pairs plus nearby snapshot <img> sources.
|
|
|
|
|
|
_LATLON_RE = re.compile(
|
|
|
|
|
|
r"(?:latitude|lat)[\"'\s:=]+(-?\d{1,2}\.\d+).{0,200}?(?:longitude|lon)[\"'\s:=]+(-?\d{1,3}\.\d+)",
|
|
|
|
|
|
re.I | re.S,
|
|
|
|
|
|
)
|
|
|
|
|
|
_IMG_RE = re.compile(r"<img[^>]+src=[\"']([^\"']+\.(?:jpg|jpeg|png|mjpeg))[\"']", re.I)
|
|
|
|
|
|
_TITLE_RE = re.compile(r"<title>([^<]+)</title>", re.I)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _vendor_from_url(url: str) -> str | None:
|
|
|
|
|
|
u = url.lower()
|
|
|
|
|
|
for vendor, marker in [
|
|
|
|
|
|
("hikvision", "hikvision"), ("dahua", "dahua"),
|
|
|
|
|
|
("axis", "axis-cgi"), ("foscam", "foscam"),
|
|
|
|
|
|
("tplink", "tplink"), ("tp-link", "tp-link"),
|
|
|
|
|
|
("mjpeg", "mjpeg"), ("rtsp", "rtsp://"),
|
|
|
|
|
|
]:
|
|
|
|
|
|
if marker in u:
|
|
|
|
|
|
return vendor
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_plain_list(text: str, source_name: str) -> list[dict]:
|
2026-08-24 21:03:33 -04:00
|
|
|
|
"""Parse camera URLs from a plain-text or markdown list.
|
|
|
|
|
|
|
|
|
|
|
|
Accepted line shapes:
|
|
|
|
|
|
* `url[|lat,lon|vendor|location_name]` (strict form)
|
|
|
|
|
|
* markdown bullets / bare lines that merely CONTAIN a URL
|
|
|
|
|
|
(e.g. `* http://1.2.3.4/mjpg/video.mjpg`) — coords/vendor unknown.
|
|
|
|
|
|
"""
|
2026-08-24 14:54:51 -04:00
|
|
|
|
cams = []
|
2026-08-24 21:03:33 -04:00
|
|
|
|
url_re = re.compile(r'(?:https?|rtsp)://[^\s\)\]>"\']+', re.I)
|
2026-08-24 14:54:51 -04:00
|
|
|
|
for line in text.splitlines():
|
|
|
|
|
|
line = line.strip()
|
|
|
|
|
|
if not line or line.startswith("#"):
|
|
|
|
|
|
continue
|
2026-08-24 21:03:33 -04:00
|
|
|
|
m = url_re.search(line)
|
|
|
|
|
|
if not m:
|
2026-08-24 14:54:51 -04:00
|
|
|
|
continue
|
2026-08-24 21:03:33 -04:00
|
|
|
|
# Reuse strict parsing when the URL is pipe-delimited with metadata;
|
|
|
|
|
|
# otherwise take just the URL and leave metadata empty.
|
|
|
|
|
|
if "|" in line:
|
|
|
|
|
|
parts = [p.strip() for p in line.split("|")]
|
|
|
|
|
|
url = parts[0]
|
|
|
|
|
|
lat = lon = vendor = loc = None
|
|
|
|
|
|
if len(parts) > 1 and "," in parts[1]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
la, lo = parts[1].split(",", 1)
|
|
|
|
|
|
lat, lon = float(la), float(lo)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if len(parts) > 2 and parts[2]:
|
|
|
|
|
|
vendor = parts[2]
|
|
|
|
|
|
if len(parts) > 3 and parts[3]:
|
|
|
|
|
|
loc = parts[3]
|
|
|
|
|
|
else:
|
|
|
|
|
|
url = m.group(0)
|
|
|
|
|
|
lat = lon = vendor = loc = None
|
2026-08-24 14:54:51 -04:00
|
|
|
|
cams.append({
|
|
|
|
|
|
"source_url": url,
|
|
|
|
|
|
"snapshot_url": url,
|
|
|
|
|
|
"discovery_source": source_name,
|
|
|
|
|
|
"location_lat": lat, "location_lon": lon,
|
|
|
|
|
|
"location_name": loc, "vendor": vendor,
|
|
|
|
|
|
})
|
|
|
|
|
|
return cams
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 15:46:49 -04:00
|
|
|
|
def parse_alertwest_json(text: str, source_name: str) -> list[dict]:
|
|
|
|
|
|
"""Parse ALERTWest / ALERTCalifornia getCameraDataByLoc JSON.
|
|
|
|
|
|
|
|
|
|
|
|
Official public JPEG stills (wildfire, DOT, FAA). Skip private + offline.
|
|
|
|
|
|
Image URL formula from their docs:
|
|
|
|
|
|
https://img.cdn.prod.alertwest.com/data/img/{cid}/{yyyy}/{mm}/{dd}/{img}
|
|
|
|
|
|
with yyyy/mm/dd taken from the epoch embedded in the filename.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(text)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return []
|
|
|
|
|
|
data = (payload or {}).get("data") or {}
|
|
|
|
|
|
locs = {loc.get("id"): loc for loc in (data.get("locs") or {}).get("data") or []}
|
|
|
|
|
|
cams_in = (data.get("cams") or {}).get("data") or []
|
|
|
|
|
|
epoch_re = re.compile(r"_(\d{10,})_\d+")
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
for cam in cams_in:
|
|
|
|
|
|
if cam.get("pv") not in (0, "0", None):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if cam.get("off") not in (0, "0", None):
|
|
|
|
|
|
continue
|
|
|
|
|
|
img = cam.get("img") or ""
|
|
|
|
|
|
cid = cam.get("id")
|
|
|
|
|
|
if not img or cid is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
m = epoch_re.search(img)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
dt = datetime.fromtimestamp(int(m.group(1)), timezone.utc)
|
|
|
|
|
|
except (OSError, ValueError, OverflowError):
|
|
|
|
|
|
continue
|
|
|
|
|
|
snap = (
|
|
|
|
|
|
f"https://img.cdn.prod.alertwest.com/data/img/"
|
|
|
|
|
|
f"{cid}/{dt:%Y}/{dt:%m}/{dt:%d}/{img}"
|
|
|
|
|
|
)
|
2026-08-27 15:54:54 -04:00
|
|
|
|
# Stable identity (the JPEG filename changes every capture).
|
|
|
|
|
|
ident = f"https://img.cdn.prod.alertwest.com/cam/{cid}"
|
2026-08-27 15:46:49 -04:00
|
|
|
|
loc = locs.get(cam.get("lid")) or {}
|
|
|
|
|
|
try:
|
|
|
|
|
|
lat = float(loc["lat"]) if loc.get("lat") is not None else None
|
|
|
|
|
|
lon = float(loc["lon"]) if loc.get("lon") is not None else None
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
lat = lon = None
|
|
|
|
|
|
bits = [cam.get("cn"), cam.get("co"), loc.get("st") or cam.get("st")]
|
|
|
|
|
|
name = ", ".join(str(b) for b in bits if b)
|
|
|
|
|
|
out.append({
|
2026-08-27 15:54:54 -04:00
|
|
|
|
"source_url": ident,
|
2026-08-27 15:46:49 -04:00
|
|
|
|
"snapshot_url": snap,
|
|
|
|
|
|
"discovery_source": source_name,
|
|
|
|
|
|
"location_lat": lat,
|
|
|
|
|
|
"location_lon": lon,
|
|
|
|
|
|
"location_name": name or None,
|
|
|
|
|
|
"vendor": cam.get("pr") or cam.get("cc"),
|
|
|
|
|
|
"device_type": cam.get("cc") or "cctv",
|
|
|
|
|
|
})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
def parse_caltrans_json(text: str, source_name: str) -> list[dict]:
|
|
|
|
|
|
"""Parse Caltrans CWWP2 cctvStatus JSON (districts 1–12, same schema).
|
|
|
|
|
|
|
|
|
|
|
|
Skip cameras that are not in service. Store the JPEG still as snapshot_url
|
|
|
|
|
|
(map thumbs) and the HLS playlist as source_url when present. Never RTSP.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(text)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return []
|
|
|
|
|
|
rows = payload.get("data") if isinstance(payload, dict) else None
|
|
|
|
|
|
if not isinstance(rows, list):
|
|
|
|
|
|
return []
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
cctv = (row or {}).get("cctv") or row or {}
|
|
|
|
|
|
loc = cctv.get("location") or {}
|
|
|
|
|
|
in_service = str(cctv.get("inService") or "").strip().lower()
|
|
|
|
|
|
if in_service not in ("true", "1", "yes"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
img = ((cctv.get("imageData") or {}).get("static") or {})
|
|
|
|
|
|
jpeg = (img.get("currentImageURL") or img.get("currentImageUrl") or "").strip()
|
|
|
|
|
|
hls = str((cctv.get("imageData") or {}).get("streamingVideoURL") or "").strip()
|
|
|
|
|
|
if hls.lower().startswith("rtsp://") or jpeg.lower().startswith("rtsp://"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not jpeg and not hls:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
lat = float(loc["latitude"]) if loc.get("latitude") not in (None, "") else None
|
|
|
|
|
|
lon = float(loc["longitude"]) if loc.get("longitude") not in (None, "") else None
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
lat = lon = None
|
|
|
|
|
|
bits = [
|
|
|
|
|
|
loc.get("locationName"),
|
|
|
|
|
|
loc.get("route") and f"SR-{loc.get('route')}",
|
|
|
|
|
|
loc.get("nearbyPlace") or loc.get("county"),
|
|
|
|
|
|
]
|
|
|
|
|
|
name = ", ".join(str(b) for b in bits if b)
|
|
|
|
|
|
ident = hls or jpeg
|
|
|
|
|
|
dtype = "hls" if hls else "http"
|
|
|
|
|
|
out.append({
|
|
|
|
|
|
"source_url": ident,
|
|
|
|
|
|
"snapshot_url": jpeg or hls,
|
|
|
|
|
|
"discovery_source": "caltrans",
|
|
|
|
|
|
"location_lat": lat,
|
|
|
|
|
|
"location_lon": lon,
|
|
|
|
|
|
"location_name": name or None,
|
|
|
|
|
|
"vendor": "Caltrans",
|
|
|
|
|
|
"device_type": dtype,
|
|
|
|
|
|
})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
feat(cameras): UDOT IBI 511 parser -> cameras table
Add parse_udot_ibi_page + scrape_udot_ibi for the UDOT IBI 511 traffic
camera feed (prod-ut.ibi511.com, no key). DataTables endpoint is POST
form-encoded and caps at 100 rows/page regardless of `length`; page walk
uses recordsTotal with a UDOT_IBI_MAX_PAGES (default 40) runaway cap.
Skip images[0].blocked/disabled, parse WKT POINT(lng lat) from
latLng.geography.wellKnownText, drop outside the Utah bbox
(lat 36.9-42.1, lon -114.2--108.9). discovery_source=udot, stable
source_url == snapshot_url == /map/Cctv/{id} (never scrape frames),
url_hash dedupe, OSINT_USER_AGENT + X-Requested-With header.
Unit tests: WKT lng/lat order, blocked/disabled skip, bbox drop,
malformed payload, missing WKT.
2026-08-31 21:34:50 -04:00
|
|
|
|
# ── UDOT IBI 511 ──────────────────────────────────────────────────────────
|
|
|
|
|
|
# Utah bbox (lat 36.9–42.1, lon -114.2–-108.9). WKT is `POINT (lng lat)`.
|
|
|
|
|
|
_UDOT_IBI_MIN_LAT, _UDOT_IBI_MAX_LAT = 36.9, 42.1
|
|
|
|
|
|
_UDOT_IBI_MIN_LON, _UDOT_IBI_MAX_LON = -114.2, -108.9
|
|
|
|
|
|
_UDOT_WKT_POINT_RE = re.compile(
|
|
|
|
|
|
r"POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)", re.I,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_udot_ibi_page(text: str, source_name: str = "udot") -> list[dict]:
|
|
|
|
|
|
"""Parse one UDOT IBI 511 DataTables camera page (`{"data": [...]}`).
|
|
|
|
|
|
|
|
|
|
|
|
Skips rows whose first image is `blocked` or `disabled`, and drops any
|
|
|
|
|
|
point outside the Utah bbox. The `/map/Cctv/{id}` URL is a stable identity
|
|
|
|
|
|
(always serves the latest frame), so it is stored as both source_url and
|
|
|
|
|
|
snapshot_url — we never scrape frames ourselves.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(text)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return []
|
|
|
|
|
|
rows = payload.get("data") if isinstance(payload, dict) else None
|
|
|
|
|
|
if not isinstance(rows, list):
|
|
|
|
|
|
return []
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
if not isinstance(row, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
cam_id = row.get("id")
|
|
|
|
|
|
images = row.get("images") or []
|
|
|
|
|
|
if cam_id is None or not images:
|
|
|
|
|
|
continue
|
|
|
|
|
|
img = images[0] or {}
|
|
|
|
|
|
if img.get("blocked") or img.get("disabled"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
lon = lat = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
wkt = (row.get("latLng") or {}).get("geography") or {}
|
|
|
|
|
|
wkt = wkt.get("wellKnownText") or ""
|
|
|
|
|
|
m = _UDOT_WKT_POINT_RE.match(str(wkt).strip())
|
|
|
|
|
|
if m:
|
|
|
|
|
|
lon, lat = float(m.group(1)), float(m.group(2))
|
|
|
|
|
|
except (AttributeError, TypeError, ValueError):
|
|
|
|
|
|
lon = lat = None
|
|
|
|
|
|
if lat is None or lon is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not (_UDOT_IBI_MIN_LAT <= lat <= _UDOT_IBI_MAX_LAT
|
|
|
|
|
|
and _UDOT_IBI_MIN_LON <= lon <= _UDOT_IBI_MAX_LON):
|
|
|
|
|
|
continue
|
|
|
|
|
|
snap = f"{UDOT_IBI_BASE}/map/Cctv/{cam_id}"
|
|
|
|
|
|
roadway, direction, location = (
|
|
|
|
|
|
row.get("roadway"), row.get("direction"), row.get("location"),
|
|
|
|
|
|
)
|
|
|
|
|
|
name = ", ".join(
|
|
|
|
|
|
str(b) for b in (roadway, direction, location)
|
|
|
|
|
|
if b and str(b).strip() and str(b).strip().lower() != "unknown"
|
|
|
|
|
|
) or None
|
|
|
|
|
|
out.append({
|
|
|
|
|
|
"source_url": snap,
|
|
|
|
|
|
"snapshot_url": snap,
|
|
|
|
|
|
"discovery_source": source_name,
|
|
|
|
|
|
"location_lat": lat,
|
|
|
|
|
|
"location_lon": lon,
|
|
|
|
|
|
"location_name": name,
|
|
|
|
|
|
"vendor": "UDOT",
|
|
|
|
|
|
"device_type": "http",
|
|
|
|
|
|
"raw": {
|
|
|
|
|
|
"udot_id": cam_id,
|
|
|
|
|
|
"agency": row.get("source"),
|
|
|
|
|
|
"source_id": row.get("sourceId"),
|
|
|
|
|
|
"roadway": roadway,
|
|
|
|
|
|
"direction": direction,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 15:54:54 -04:00
|
|
|
|
def parse_live_streams_geojson(text: str, source_name: str) -> list[dict]:
|
|
|
|
|
|
"""Parse willytop8/Live-Environment-Streams GeoJSON.
|
|
|
|
|
|
|
|
|
|
|
|
Only direct, playable URLs: HLS, YouTube, HTTP stills. Skip html_page
|
|
|
|
|
|
feeds that need token extraction or a headless browser.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(text)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return []
|
|
|
|
|
|
usable = {"hls", "youtube", "http_image"}
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
for feat in payload.get("features") or []:
|
|
|
|
|
|
props = feat.get("properties") or {}
|
|
|
|
|
|
ut = (props.get("url_type") or "").lower()
|
|
|
|
|
|
if ut not in usable:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if props.get("source_url_requires"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
url = (props.get("url") or "").strip()
|
|
|
|
|
|
if not url:
|
|
|
|
|
|
continue
|
|
|
|
|
|
coords = (feat.get("geometry") or {}).get("coordinates") or []
|
|
|
|
|
|
lon = lat = None
|
|
|
|
|
|
if len(coords) >= 2:
|
|
|
|
|
|
try:
|
|
|
|
|
|
lon, lat = float(coords[0]), float(coords[1])
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
lon = lat = None
|
|
|
|
|
|
dtype = "ip-cam" if ut == "http_image" else ut
|
|
|
|
|
|
out.append({
|
|
|
|
|
|
"source_url": url,
|
|
|
|
|
|
"snapshot_url": url,
|
|
|
|
|
|
"discovery_source": source_name,
|
|
|
|
|
|
"location_lat": lat,
|
|
|
|
|
|
"location_lon": lon,
|
|
|
|
|
|
"location_name": props.get("display_name") or props.get("name"),
|
|
|
|
|
|
"vendor": props.get("source_family"),
|
|
|
|
|
|
"device_type": dtype,
|
|
|
|
|
|
})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 14:54:51 -04:00
|
|
|
|
def parse_directory_html(html: str, base_url: str, source_name: str) -> list[dict]:
|
|
|
|
|
|
"""Generic Insecam-style directory parser."""
|
|
|
|
|
|
cams = []
|
|
|
|
|
|
imgs = _IMG_RE.findall(html)
|
|
|
|
|
|
for m in _LATLON_RE.finditer(html):
|
|
|
|
|
|
lat, lon = float(m.group(1)), float(m.group(2))
|
|
|
|
|
|
# nearest img tag after the match → best-effort snapshot endpoint
|
|
|
|
|
|
tail = html[m.end(): m.end() + 800]
|
|
|
|
|
|
img_m = _IMG_RE.search(tail)
|
|
|
|
|
|
snap = img_m.group(1) if img_m else (imgs[0] if imgs else None)
|
|
|
|
|
|
if snap and snap.startswith("/"):
|
|
|
|
|
|
from urllib.parse import urljoin
|
|
|
|
|
|
snap = urljoin(base_url, snap)
|
|
|
|
|
|
cams.append({
|
|
|
|
|
|
"source_url": base_url,
|
|
|
|
|
|
"snapshot_url": snap,
|
|
|
|
|
|
"discovery_source": source_name,
|
|
|
|
|
|
"location_lat": lat, "location_lon": lon,
|
|
|
|
|
|
"location_name": None, "vendor": None,
|
|
|
|
|
|
})
|
|
|
|
|
|
return cams
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 21:03:33 -04:00
|
|
|
|
# ── Batch IP geolocation (ip-api.com — free, 100 IPs per batch call) ───────
|
|
|
|
|
|
|
|
|
|
|
|
IPAPI_BATCH_URL = "http://ip-api.com/batch"
|
|
|
|
|
|
IPAPI_BATCH_SIZE = 100
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def geolocate_ips(ips: list[str]) -> dict[str, tuple[float, float]]:
|
|
|
|
|
|
"""Resolve public IPs → (lat, lon). Unresolvable IPs are simply absent."""
|
|
|
|
|
|
out: dict[str, tuple[float, float]] = {}
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
|
|
|
|
for i in range(0, len(ips), IPAPI_BATCH_SIZE):
|
|
|
|
|
|
chunk = ips[i: i + IPAPI_BATCH_SIZE]
|
|
|
|
|
|
try:
|
|
|
|
|
|
fields = "status,country,city,lat,lon,query"
|
|
|
|
|
|
r = await c.post(IPAPI_BATCH_URL, json=[
|
|
|
|
|
|
{"query": ip, "fields": fields} for ip in chunk
|
|
|
|
|
|
])
|
|
|
|
|
|
r.raise_for_status()
|
|
|
|
|
|
for row in r.json():
|
|
|
|
|
|
if (row.get("status") == "success"
|
|
|
|
|
|
and row.get("lat") is not None):
|
|
|
|
|
|
out[row["query"]] = (
|
|
|
|
|
|
float(row["lat"]), float(row["lon"]))
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
logger.warning("ip-api batch failed", exc_info=True)
|
|
|
|
|
|
if i + IPAPI_BATCH_SIZE < len(ips):
|
|
|
|
|
|
await asyncio.sleep(2) # stay well under the free rate limit
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 14:54:51 -04:00
|
|
|
|
async def scrape_source(client: RateLimitedClient, geo: Geocoder,
|
|
|
|
|
|
src_url: str) -> list[dict]:
|
|
|
|
|
|
"""Fetch one source and return normalized camera dicts (public only)."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = await client.get(src_url)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
logger.exception("failed to fetch source %s", src_url)
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
name = urlparse(src_url).netloc
|
|
|
|
|
|
ctype = resp.headers.get("content-type", "")
|
2026-08-27 15:46:49 -04:00
|
|
|
|
body = resp.text
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
if "cwwp2.dot.ca.gov" in src_url or "cctvStatus" in src_url:
|
|
|
|
|
|
cams = parse_caltrans_json(body, name)
|
|
|
|
|
|
elif ("getCameraDataByLoc" in src_url
|
2026-08-27 15:46:49 -04:00
|
|
|
|
or ("json" in ctype and '"locs"' in body[:4000] and '"cams"' in body[:8000])):
|
|
|
|
|
|
cams = parse_alertwest_json(body, name)
|
2026-08-27 15:54:54 -04:00
|
|
|
|
elif (src_url.endswith(".geojson") or src_url.endswith("/streams.geojson")
|
|
|
|
|
|
or '"FeatureCollection"' in body[:400]):
|
|
|
|
|
|
cams = parse_live_streams_geojson(body, name)
|
2026-08-27 15:46:49 -04:00
|
|
|
|
elif "html" in ctype:
|
|
|
|
|
|
cams = parse_directory_html(body, str(resp.url), name)
|
2026-08-24 14:54:51 -04:00
|
|
|
|
else:
|
2026-08-27 15:46:49 -04:00
|
|
|
|
cams = parse_plain_list(body, name)
|
2026-08-24 14:54:51 -04:00
|
|
|
|
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
seen_in_batch: set[str] = set()
|
2026-08-24 21:03:33 -04:00
|
|
|
|
# Batch-geolocate coordinate-less camera hosts via ip-api (one call per
|
|
|
|
|
|
# 100 IPs) instead of dropping them — raw-IP lists have no embedded coords.
|
|
|
|
|
|
need_geo: dict[str, str] = {}
|
2026-08-24 14:54:51 -04:00
|
|
|
|
for cam in cams[:CAMERA_MAX_PER_SOURCE]:
|
|
|
|
|
|
url = cam["source_url"]
|
|
|
|
|
|
h = url_hash(url)
|
|
|
|
|
|
if h in seen_in_batch:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen_in_batch.add(h)
|
|
|
|
|
|
if not is_public_url(url):
|
|
|
|
|
|
continue
|
2026-08-24 21:03:33 -04:00
|
|
|
|
if (cam["location_lat"] is None and cam["location_name"] is None):
|
|
|
|
|
|
host = urlparse(url).hostname or ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
ipaddress.ip_address(host)
|
|
|
|
|
|
need_geo.setdefault(host, url)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass # hostname-only: Nominatim fallback below handles it
|
|
|
|
|
|
geo_by_ip = await geolocate_ips(list(need_geo)) if need_geo else {}
|
|
|
|
|
|
|
|
|
|
|
|
for cam in cams:
|
|
|
|
|
|
if url_hash(cam["source_url"]) not in seen_in_batch:
|
|
|
|
|
|
continue # didn't survive dedupe/public-range filtering above
|
2026-08-24 14:54:51 -04:00
|
|
|
|
if cam["location_lat"] is None and cam["location_name"]:
|
|
|
|
|
|
cam["location_lat"], cam["location_lon"] = await geo.geocode(
|
|
|
|
|
|
cam["location_name"])
|
2026-08-24 21:03:33 -04:00
|
|
|
|
if (cam["location_lat"] is None and cam["location_lon"] is None):
|
|
|
|
|
|
host = urlparse(cam["source_url"]).hostname or ""
|
|
|
|
|
|
coords = geo_by_ip.get(host)
|
|
|
|
|
|
if coords is None:
|
|
|
|
|
|
continue # map display requires coordinates
|
|
|
|
|
|
cam["location_lat"], cam["location_lon"] = coords
|
|
|
|
|
|
cam["location_name"] = f"{host} (IP-geo)"
|
2026-08-24 14:54:51 -04:00
|
|
|
|
cam.setdefault("vendor", None)
|
|
|
|
|
|
if not cam.get("vendor"):
|
2026-08-24 21:03:33 -04:00
|
|
|
|
cam["vendor"] = _vendor_from_url(cam["snapshot_url"] or cam["source_url"])
|
2026-08-27 15:46:49 -04:00
|
|
|
|
if not cam.get("device_type"):
|
|
|
|
|
|
cam["device_type"] = (
|
|
|
|
|
|
"rtsp" if (cam["snapshot_url"] or "").startswith("rtsp") else "ip-cam"
|
|
|
|
|
|
)
|
|
|
|
|
|
cam.setdefault("raw", {"discovered_via": src_url})
|
2026-08-24 14:54:51 -04:00
|
|
|
|
out.append(cam)
|
|
|
|
|
|
logger.info("source %s yielded %d public cameras", src_url, len(out))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
feat(cameras): UDOT IBI 511 parser -> cameras table
Add parse_udot_ibi_page + scrape_udot_ibi for the UDOT IBI 511 traffic
camera feed (prod-ut.ibi511.com, no key). DataTables endpoint is POST
form-encoded and caps at 100 rows/page regardless of `length`; page walk
uses recordsTotal with a UDOT_IBI_MAX_PAGES (default 40) runaway cap.
Skip images[0].blocked/disabled, parse WKT POINT(lng lat) from
latLng.geography.wellKnownText, drop outside the Utah bbox
(lat 36.9-42.1, lon -114.2--108.9). discovery_source=udot, stable
source_url == snapshot_url == /map/Cctv/{id} (never scrape frames),
url_hash dedupe, OSINT_USER_AGENT + X-Requested-With header.
Unit tests: WKT lng/lat order, blocked/disabled skip, bbox drop,
malformed payload, missing WKT.
2026-08-31 21:34:50 -04:00
|
|
|
|
# ── UDOT IBI 511 paginated fetcher ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
async def scrape_udot_ibi(client: RateLimitedClient) -> list[dict]:
|
|
|
|
|
|
"""Page through the UDOT IBI 511 DataTables endpoint and normalize.
|
|
|
|
|
|
|
|
|
|
|
|
POSTs `start`/`length` form fields (server caps at 100 rows/page), walking
|
|
|
|
|
|
pages until `recordsTotal` is exhausted or UDOT_IBI_MAX_PAGES is hit.
|
|
|
|
|
|
"""
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
start = 0
|
|
|
|
|
|
for _ in range(UDOT_IBI_MAX_PAGES):
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
|
UDOT_IBI_URL,
|
|
|
|
|
|
data={
|
|
|
|
|
|
"start": str(start),
|
|
|
|
|
|
"length": str(UDOT_IBI_PAGE_SIZE),
|
|
|
|
|
|
"lang": "en-US",
|
|
|
|
|
|
},
|
|
|
|
|
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
body = resp.text
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
logger.exception("failed to fetch UDOT IBI page start=%d", start)
|
|
|
|
|
|
break
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(body)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
logger.warning("UDOT IBI non-JSON response at start=%d", start)
|
|
|
|
|
|
break
|
|
|
|
|
|
total = int(payload.get("recordsTotal") or 0)
|
|
|
|
|
|
rows = payload.get("data") or []
|
|
|
|
|
|
if not isinstance(rows, list) or not rows:
|
|
|
|
|
|
break
|
|
|
|
|
|
for cam in parse_udot_ibi_page(body, "udot"):
|
|
|
|
|
|
if cam["source_url"] in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(cam["source_url"])
|
|
|
|
|
|
out.append(cam)
|
|
|
|
|
|
if start + len(rows) >= total:
|
|
|
|
|
|
break
|
|
|
|
|
|
start += len(rows)
|
|
|
|
|
|
logger.info("UDOT IBI yielded %d cameras", len(out))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 14:54:51 -04:00
|
|
|
|
# ── Persistence ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
async def upsert_cameras(cams: list[dict]) -> int:
|
|
|
|
|
|
"""Insert-or-update cameras keyed by url_hash. Returns rows written."""
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
written = 0
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
for cam in cams:
|
|
|
|
|
|
values = {
|
|
|
|
|
|
"url_hash": url_hash(cam["source_url"]),
|
|
|
|
|
|
"source_url": cam["source_url"],
|
|
|
|
|
|
"snapshot_url": cam.get("snapshot_url"),
|
|
|
|
|
|
"discovery_source": cam["discovery_source"],
|
|
|
|
|
|
"location_lat": cam.get("location_lat"),
|
|
|
|
|
|
"location_lon": cam.get("location_lon"),
|
|
|
|
|
|
"location_name": cam.get("location_name"),
|
|
|
|
|
|
"vendor": cam.get("vendor"),
|
|
|
|
|
|
"device_type": cam.get("device_type"),
|
|
|
|
|
|
"last_seen": now,
|
|
|
|
|
|
"raw": cam.get("raw"),
|
|
|
|
|
|
}
|
|
|
|
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
|
|
|
|
stmt = pg_insert(cameras).values(**values)
|
|
|
|
|
|
stmt = stmt.on_conflict_do_update(
|
|
|
|
|
|
index_elements=[cameras.c.url_hash],
|
|
|
|
|
|
set_={
|
|
|
|
|
|
"last_seen": now,
|
|
|
|
|
|
"snapshot_url": values["snapshot_url"],
|
|
|
|
|
|
"location_lat": values["location_lat"],
|
|
|
|
|
|
"location_lon": values["location_lon"],
|
|
|
|
|
|
"location_name": values["location_name"],
|
|
|
|
|
|
"vendor": values["vendor"],
|
|
|
|
|
|
"device_type": values["device_type"],
|
|
|
|
|
|
"raw": values["raw"],
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
await session.execute(stmt)
|
|
|
|
|
|
written += 1
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
return written
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Cycle driver ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
async def run_cycle() -> int:
|
|
|
|
|
|
client = RateLimitedClient()
|
|
|
|
|
|
geo = Geocoder()
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
results = await asyncio.gather(
|
|
|
|
|
|
*(scrape_source(client, geo, s) for s in CAMERA_SOURCE_URLS),
|
feat(cameras): UDOT IBI 511 parser -> cameras table
Add parse_udot_ibi_page + scrape_udot_ibi for the UDOT IBI 511 traffic
camera feed (prod-ut.ibi511.com, no key). DataTables endpoint is POST
form-encoded and caps at 100 rows/page regardless of `length`; page walk
uses recordsTotal with a UDOT_IBI_MAX_PAGES (default 40) runaway cap.
Skip images[0].blocked/disabled, parse WKT POINT(lng lat) from
latLng.geography.wellKnownText, drop outside the Utah bbox
(lat 36.9-42.1, lon -114.2--108.9). discovery_source=udot, stable
source_url == snapshot_url == /map/Cctv/{id} (never scrape frames),
url_hash dedupe, OSINT_USER_AGENT + X-Requested-With header.
Unit tests: WKT lng/lat order, blocked/disabled skip, bbox drop,
malformed payload, missing WKT.
2026-08-31 21:34:50 -04:00
|
|
|
|
scrape_udot_ibi(client),
|
2026-08-24 14:54:51 -04:00
|
|
|
|
return_exceptions=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
all_cams: list[dict] = []
|
|
|
|
|
|
for r in results:
|
|
|
|
|
|
if isinstance(r, BaseException):
|
|
|
|
|
|
logger.error("scrape task failed: %s", r)
|
|
|
|
|
|
else:
|
|
|
|
|
|
all_cams.extend(r)
|
|
|
|
|
|
if all_cams:
|
|
|
|
|
|
total = await upsert_cameras(all_cams)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await client.aclose()
|
|
|
|
|
|
logger.info("camera cycle complete: %d cameras stored", total)
|
|
|
|
|
|
return total
|