feat(data-feeds): 5 nouvelles sources data-only

Ajoute 5 feeds open-data sans cle, branches sur le profil
config.data-only.toml :

- openmeteo : meteo locale (temp, vent, humidite, pression, pluie)
  via api.open-meteo.com, poll 10 min, lat/lon configurables
- openaq : qualite de l'air autour d'une position (PM2.5, PM10,
  NO2, O3) via openaq.org v3, poll 15 min, radius configurable
- iss : position ISS temps reel via wheretheiss.at, poll 5 s,
  emet aussi un event /pass quand la station entre dans un
  rayon configurable autour de l'observateur
- volcano : eruptions actives 7-jours via volcano.si.edu (GVP),
  poll 1 h, dedup par eventid, emet /eruption + /active count
- social_buzz : Reddit /r/all hot + HackerNews top, poll 1 min,
  emet score/comments moyens par source + /pulse normalise

Tous les feeds suivent le pattern Context.send(sub, *args).
This commit is contained in:
L'électron rare
2026-05-13 14:44:50 +02:00
parent 8e05bc5751
commit 39d8739f4c
6 changed files with 344 additions and 0 deletions
+34
View File
@@ -84,6 +84,40 @@ enabled = false
url = "https://api.github.com/events"
poll_seconds = 30
# -- Meteo locale (Open-Meteo, sans cle) ----------------------------------
[feeds.openmeteo]
enabled = true
lat = 48.8566 # Paris par defaut
lon = 2.3522
poll_seconds = 600
# -- Qualite de l'air (OpenAQ v3, sans cle) -------------------------------
[feeds.openaq]
enabled = true
lat = 48.8566
lon = 2.3522
radius_m = 25000
poll_seconds = 900
# -- Station spatiale (ISS / wheretheiss.at) ------------------------------
[feeds.iss]
enabled = true
lat = 48.8566
lon = 2.3522
pass_radius_km = 1500
poll_seconds = 5
# -- Volcans actifs (Smithsonian GVP 7-jours JSON) ------------------------
[feeds.volcano]
enabled = true
url = "https://volcano.si.edu/feeds/eruptions7days.json"
poll_seconds = 3600
# -- Pouls social (Reddit hot + HackerNews top) ---------------------------
[feeds.social_buzz]
enabled = true
poll_seconds = 60
[feeds.gcn]
enabled = false
client_id = ""
+61
View File
@@ -0,0 +1,61 @@
"""ISS position via wheretheiss.at (json).
Renvoie position lat/lon + altitude + velocity. Polling 5s par defaut.
Egalement emet l'event 'pass' (1.0) lorsque la station franchit une
zone d'observation autour de l'observateur (configurable lat/lon/radius).
OSC out :
/data/iss/pos lat lon alt_km vel_kmh
/data/iss/pass 1 (transient, quand iss enter dans le radius)
"""
from __future__ import annotations
import asyncio
import logging
import math
import httpx
LOG = logging.getLogger("feed.iss")
URL = "https://api.wheretheiss.at/v1/satellites/25544"
def _great_circle_km(lat1: float, lon1: float,
lat2: float, lon2: float) -> float:
r = 6371.0
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = (math.sin(dp / 2) ** 2
+ math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2)
return 2 * r * math.asin(math.sqrt(a))
async def run(ctx) -> None:
cfg = ctx.cfg
period = float(cfg.get("poll_seconds", 5.0))
obs_lat = float(cfg.get("lat", 48.8566))
obs_lon = float(cfg.get("lon", 2.3522))
pass_radius = float(cfg.get("pass_radius_km", 1500.0))
inside_prev = False
async with httpx.AsyncClient(timeout=10.0) as cli:
while True:
try:
r = await cli.get(URL)
r.raise_for_status()
j = r.json()
lat = float(j.get("latitude", 0.0))
lon = float(j.get("longitude", 0.0))
alt = float(j.get("altitude", 0.0))
vel = float(j.get("velocity", 0.0))
ctx.send("pos", lat, lon, alt, vel)
dist = _great_circle_km(obs_lat, obs_lon, lat, lon)
inside_now = dist < pass_radius
if inside_now and not inside_prev:
ctx.send("pass", 1.0, dist)
inside_prev = inside_now
except Exception as e: # noqa: BLE001
LOG.warning("iss fetch failed: %s", e)
await asyncio.sleep(period)
+57
View File
@@ -0,0 +1,57 @@
"""OpenAQ — qualite de l'air locale.
Mesures temps reel PM2.5 / PM10 / NO2 / O3 autour d'un point geo.
API v3 publique sans cle (rate-limited mais souple).
OSC out :
/data/openaq/now pm25 pm10 no2 o3
"""
from __future__ import annotations
import asyncio
import logging
import httpx
LOG = logging.getLogger("feed.openaq")
URL = ("https://api.openaq.org/v3/locations"
"?coordinates={lat},{lon}&radius={radius}&limit=20")
def _latest(values: list, param: str) -> float:
"""Cherche la mesure la plus recente pour `param` dans la liste
de locations OpenAQ v3."""
best = 0.0
for loc in values:
for sensor in loc.get("sensors", []):
p = sensor.get("parameter", {})
if p.get("name") == param:
last = sensor.get("latest", {})
v = last.get("value")
if v is not None and v > best:
best = float(v)
return best
async def run(ctx) -> None:
cfg = ctx.cfg
lat = float(cfg.get("lat", 48.8566))
lon = float(cfg.get("lon", 2.3522))
radius = int(cfg.get("radius_m", 25000)) # 25 km autour
period = float(cfg.get("poll_seconds", 900.0)) # 15 min
url = URL.format(lat=lat, lon=lon, radius=radius)
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
r = await cli.get(url)
r.raise_for_status()
locs = r.json().get("results", [])
pm25 = _latest(locs, "pm25")
pm10 = _latest(locs, "pm10")
no2 = _latest(locs, "no2")
o3 = _latest(locs, "o3")
ctx.send("now", pm25, pm10, no2, o3)
except Exception as e: # noqa: BLE001
LOG.warning("openaq fetch failed: %s", e)
await asyncio.sleep(period)
+46
View File
@@ -0,0 +1,46 @@
"""Open-Meteo — meteo locale (temp / vent / humidite / pression / pluie).
Pas de cle API. Geolocalisation via lat/lon en config.toml.
Update toutes les `poll_seconds` (defaut 600s = 10 min).
OSC out :
/data/openmeteo/now temp_c humidity wind_mps wind_deg pressure_hpa rain_mmh
"""
from __future__ import annotations
import asyncio
import logging
import httpx
LOG = logging.getLogger("feed.openmeteo")
URL = ("https://api.open-meteo.com/v1/forecast"
"?latitude={lat}&longitude={lon}"
"&current=temperature_2m,relative_humidity_2m,wind_speed_10m,"
"wind_direction_10m,pressure_msl,rain"
"&wind_speed_unit=ms&timezone=UTC")
async def run(ctx) -> None:
cfg = ctx.cfg
lat = float(cfg.get("lat", 48.8566)) # Paris by default
lon = float(cfg.get("lon", 2.3522))
period = float(cfg.get("poll_seconds", 600.0))
url = URL.format(lat=lat, lon=lon)
async with httpx.AsyncClient(timeout=15.0) as cli:
while True:
try:
r = await cli.get(url)
r.raise_for_status()
cur = r.json().get("current", {})
ctx.send("now",
float(cur.get("temperature_2m", 0.0)),
float(cur.get("relative_humidity_2m", 0.0)),
float(cur.get("wind_speed_10m", 0.0)),
float(cur.get("wind_direction_10m", 0.0)),
float(cur.get("pressure_msl", 1013.0)),
float(cur.get("rain", 0.0)))
except Exception as e: # noqa: BLE001
LOG.warning("openmeteo fetch failed: %s", e)
await asyncio.sleep(period)
+81
View File
@@ -0,0 +1,81 @@
"""Reddit /r/all + HackerNews top — pulse social pour viz 'social storm'.
Reddit : /r/all/hot.json — score, num_comments des top posts
HN : algolia API search_by_date front_page — points, comments
OSC out :
/data/social_buzz/reddit score_avg comments_avg n
/data/social_buzz/hn score_avg comments_avg n
/data/social_buzz/pulse combined_score (event tick toutes ~30s)
"""
from __future__ import annotations
import asyncio
import logging
import httpx
LOG = logging.getLogger("feed.social_buzz")
REDDIT_URL = "https://www.reddit.com/r/all/hot.json?limit=25"
HN_URL = "https://hacker-news.firebaseio.com/v0/topstories.json"
HN_ITEM = "https://hacker-news.firebaseio.com/v0/item/{}.json"
async def _fetch_reddit(cli: httpx.AsyncClient) -> tuple[float, float, int]:
r = await cli.get(REDDIT_URL,
headers={"User-Agent": "av-live-data-feeds/1.0"})
r.raise_for_status()
posts = r.json().get("data", {}).get("children", [])
if not posts:
return 0.0, 0.0, 0
scores = [int(p["data"].get("score", 0)) for p in posts]
comments = [int(p["data"].get("num_comments", 0)) for p in posts]
n = len(scores)
return sum(scores) / n, sum(comments) / n, n
async def _fetch_hn(cli: httpx.AsyncClient, top_n: int = 15
) -> tuple[float, float, int]:
r = await cli.get(HN_URL)
r.raise_for_status()
ids = r.json()[:top_n]
coros = [cli.get(HN_ITEM.format(i)) for i in ids]
resps = await asyncio.gather(*coros, return_exceptions=True)
scores, comments = [], []
for resp in resps:
if isinstance(resp, Exception):
continue
try:
it = resp.json()
except Exception:
continue
scores.append(int(it.get("score", 0)))
comments.append(int(it.get("descendants", 0)))
n = len(scores) or 1
return sum(scores) / n, sum(comments) / n, len(scores)
async def run(ctx) -> None:
cfg = ctx.cfg
period = float(cfg.get("poll_seconds", 60.0))
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
r_score, r_com, r_n = await _fetch_reddit(cli)
ctx.send("reddit", r_score, r_com, float(r_n))
except Exception as e: # noqa: BLE001
LOG.warning("reddit fetch failed: %s", e)
r_score = 0.0
try:
h_score, h_com, h_n = await _fetch_hn(cli)
ctx.send("hn", h_score, h_com, float(h_n))
except Exception as e: # noqa: BLE001
LOG.warning("hn fetch failed: %s", e)
h_score = 0.0
# Combined score normalize en [0..1] ; reddit hot ~10k+ posts,
# HN front ~300 points. On scale chaque source puis on max.
combined = max(min(r_score / 10000.0, 1.0),
min(h_score / 500.0, 1.0))
ctx.send("pulse", combined)
await asyncio.sleep(period)
+65
View File
@@ -0,0 +1,65 @@
"""Volcans actifs — Smithsonian GVP weekly reports + USGS volcano hazards.
Source primaire : USGS volcano feed (RSS / GeoJSON), couvre les volcans
US actifs. Pour les volcans monde, on parse les CSV publics Smithsonian
si configures. Polling 1h.
OSC out :
/data/volcano/active count
/data/volcano/eruption lat lon vei region (nouvelle eruption depuis last poll)
"""
from __future__ import annotations
import asyncio
import collections
import logging
import httpx
LOG = logging.getLogger("feed.volcano")
USGS_URL = ("https://volcanoes.usgs.gov/hans2/api/volcano/getEvents"
"?starttime={start}&endtime={end}")
async def run(ctx) -> None:
cfg = ctx.cfg
period = float(cfg.get("poll_seconds", 3600.0))
url = cfg.get("url",
"https://volcano.si.edu/feeds/eruptions7days.json")
seen: collections.OrderedDict[str, None] = collections.OrderedDict()
SEEN_MAX = 512
async with httpx.AsyncClient(timeout=30.0) as cli:
while True:
try:
r = await cli.get(url)
r.raise_for_status()
ct = r.headers.get("content-type", "")
items = []
if "json" in ct:
data = r.json()
items = data.get("features", data.get("items", []))
count = 0
for it in items:
props = it.get("properties", it)
eid = str(props.get("id") or props.get("eventid")
or props.get("volcanoNumber") or "")
if not eid:
continue
count += 1
if eid in seen:
continue
seen[eid] = None
if len(seen) > SEEN_MAX:
seen.popitem(last=False)
geom = it.get("geometry") or {}
coords = geom.get("coordinates") or [0, 0]
lon, lat = float(coords[0]), float(coords[1])
vei = float(props.get("vei", 0) or 0)
region = str(props.get("country")
or props.get("region", ""))[:32]
ctx.send("eruption", lat, lon, vei, region)
ctx.send("active", float(count))
except Exception as e: # noqa: BLE001
LOG.warning("volcano fetch failed: %s", e)
await asyncio.sleep(period)