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:
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
"¤t=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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user