feat(data-feeds): GDELT, Wikipedia, Tides, ATC

4 nouvelles sources open-data publiques, sans cle :

- gdelt : GDELT 2.0 events CSV.zip toutes les 15 min ; emet
  /event lat lon tone country eid + /batch n countries avg_tone.
  Dedup par GLOBALEVENTID, ring buffer 8192.
- wikimedia : EventStreams SSE firehose recentchange (toutes
  langues). Sample ~5% des edits + rate per second toutes
  les 5 s. Reconnect auto sur disconnect.
- tides : NOAA CO-OPS water level observe + predit pour une
  station (Boston par defaut, station code configurable) +
  phase lunaire calculee (Conway approx, sans dep). Poll 6 min.
- atc : LiveATC hub listeners scraping (KJFK/KLAX/KSFO/KORD/
  EGLL/LFPG par defaut). Polite 500ms entre hubs, total
  poll 5 min.
This commit is contained in:
L'électron rare
2026-05-13 19:17:36 +02:00
parent ae5d814128
commit 60112b0790
5 changed files with 302 additions and 0 deletions
+23
View File
@@ -119,6 +119,29 @@ poll_seconds = 3600
enabled = true
poll_seconds = 60
# -- GDELT (evenements monde 15-min) --------------------------------------
[feeds.gdelt]
enabled = true
poll_seconds = 900
# -- Wikipedia recent changes ---------------------------------------------
[feeds.wikimedia]
enabled = true
sample_rate = 0.05
rate_window_s = 5
# -- NOAA tides + lune ----------------------------------------------------
[feeds.tides]
enabled = true
station = "8443970"
poll_seconds = 360
# -- LiveATC hubs ---------------------------------------------------------
[feeds.atc]
enabled = true
hubs = ["KJFK", "KLAX", "KSFO", "KORD", "EGLL", "LFPG"]
poll_seconds = 300
[feeds.gcn]
enabled = false
client_id = ""
+60
View File
@@ -0,0 +1,60 @@
"""LiveATC feeds metadata — pas de pull audio, juste compteur de
streams actifs et indicateur d'activite par hub aeroport.
LiveATC.net expose un endpoint stats JSON simplifie pour quelques
aeroports majeurs (KJFK, KLAX, KSFO, EGLL, LFPG). On polle pour
chacun le nombre d'auditeurs courant (proxy pour 'activite ATC').
OSC out :
/data/atc/hub icao listeners
/data/atc/total total_listeners n_hubs
"""
from __future__ import annotations
import asyncio
import logging
import re
import httpx
LOG = logging.getLogger("feed.atc")
# LiveATC publie un page HTML par feed avec "Listeners: N" — on parse
# ca via regex au lieu d'un API officielle (pas disponible).
HUB_URL = "https://www.liveatc.net/search/?icao={icao}"
LISTENERS_RE = re.compile(r"Listeners:\s*<b>(\d+)</b>", re.I)
async def _fetch_hub(cli: httpx.AsyncClient, icao: str
) -> int:
r = await cli.get(HUB_URL.format(icao=icao),
headers={"User-Agent": "av-live-data-feeds/1.0"})
r.raise_for_status()
matches = LISTENERS_RE.findall(r.text)
if not matches:
return 0
return sum(int(m) for m in matches)
async def run(ctx) -> None:
cfg = ctx.cfg
hubs = cfg.get("hubs",
["KJFK", "KLAX", "KSFO", "KORD", "EGLL", "LFPG"])
period = float(cfg.get("poll_seconds", 300.0)) # 5 min
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
total = 0
active = 0
for icao in hubs:
try:
listeners = await _fetch_hub(cli, icao)
except Exception as e: # noqa: BLE001
LOG.warning("atc %s failed: %s", icao, e)
continue
ctx.send("hub", icao, float(listeners))
total += listeners
if listeners > 0:
active += 1
await asyncio.sleep(0.5) # polite scrape
ctx.send("total", float(total), float(active))
await asyncio.sleep(period)
+93
View File
@@ -0,0 +1,93 @@
"""GDELT Project — feed des evenements 'world' 15-min.
Source : GDELT 2.0 Events CSV ; updates toutes les 15 min.
On compte les evenements + extrait les top countries / themes du
dernier batch.
OSC out :
/data/gdelt/batch n_events n_countries avg_tone
/data/gdelt/event lat lon tone country_code root_event_id
"""
from __future__ import annotations
import asyncio
import collections
import logging
import time
import zipfile
from io import BytesIO
import httpx
LOG = logging.getLogger("feed.gdelt")
# GDELT v2 master file list ; on prend juste le dernier .export.CSV.zip
MASTER = "http://data.gdeltproject.org/gdeltv2/lastupdate.txt"
async def _fetch_latest_csv(cli: httpx.AsyncClient) -> list[list[str]]:
r = await cli.get(MASTER)
r.raise_for_status()
# 3 lignes : events, mentions, gkg ; on prend events (premiere ligne)
first = (r.text.strip().splitlines() or [""])[0].split(" ")
if len(first) < 3:
return []
url = first[2]
rz = await cli.get(url, follow_redirects=True)
rz.raise_for_status()
with zipfile.ZipFile(BytesIO(rz.content)) as zf:
name = zf.namelist()[0]
text = zf.read(name).decode("utf-8", errors="ignore")
return [line.split("\t") for line in text.splitlines() if line]
async def run(ctx) -> None:
cfg = ctx.cfg
period = float(cfg.get("poll_seconds", 900.0)) # 15 min
seen: collections.OrderedDict[str, None] = collections.OrderedDict()
SEEN_MAX = 8192
async with httpx.AsyncClient(timeout=60.0) as cli:
while True:
try:
rows = await _fetch_latest_csv(cli)
if not rows:
LOG.warning("gdelt: empty batch")
await asyncio.sleep(period)
continue
count = 0
tones = []
countries: collections.Counter[str] = collections.Counter()
# GDELT 2.0 events CSV : 61 colonnes.
# idx 0 = GLOBALEVENTID, 7 = Actor1CountryCode,
# 34 = AvgTone, 39 = ActionGeo_Lat, 40 = ActionGeo_Long
for row in rows:
if len(row) < 41:
continue
eid = row[0]
if not eid or eid in seen:
continue
seen[eid] = None
if len(seen) > SEEN_MAX:
seen.popitem(last=False)
count += 1
try:
tone = float(row[34] or "0")
except ValueError:
tone = 0.0
tones.append(tone)
cc = row[7].strip()[:3]
if cc:
countries[cc] += 1
try:
lat = float(row[39] or "0")
lon = float(row[40] or "0")
except ValueError:
lat = lon = 0.0
if lat or lon:
ctx.send("event", lat, lon, tone, cc, eid)
avg_tone = (sum(tones) / len(tones)) if tones else 0.0
ctx.send("batch", float(count),
float(len(countries)), float(avg_tone))
except Exception as e: # noqa: BLE001
LOG.warning("gdelt fetch failed: %s", e)
await asyncio.sleep(period)
+64
View File
@@ -0,0 +1,64 @@
"""NOAA tides (station configurable) + phase lunaire calculee.
NOAA CO-OPS API : observed water level + predicted, station codee
(defaut Boston). Phase lunaire algorithme Conway approx (sans dep).
OSC out :
/data/tides/level water_level_m predicted_m residual_m
/data/tides/moon phase_0_1 illum_0_1
"""
from __future__ import annotations
import asyncio
import logging
import math
import time
import httpx
LOG = logging.getLogger("feed.tides")
NOAA_URL = ("https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"
"?product={product}&application=AV-Live&format=json"
"&time_zone=gmt&datum=MLLW&units=metric"
"&date=latest&station={station}")
def _moon_phase(t: float) -> tuple[float, float]:
"""t epoch -> (phase 0..1 ou 0 = new, 0.5 = full ; illum 0..1).
Approx Conway, +-1 jour de precision suffisant."""
# Reference : 2000-01-06 18:14 UTC ~ new moon
new = 946755300.0
cycle = 29.530588853 * 86400.0
phase = ((t - new) % cycle) / cycle
illum = (1.0 - math.cos(2 * math.pi * phase)) / 2.0
return phase, illum
async def _fetch_level(cli: httpx.AsyncClient, station: str
) -> tuple[float, float]:
obs_url = NOAA_URL.format(product="water_level", station=station)
pred_url = NOAA_URL.format(product="predictions", station=station)
obs = await cli.get(obs_url)
pred = await cli.get(pred_url)
obs.raise_for_status()
pred.raise_for_status()
o = obs.json().get("data", [{}])[0]
p = pred.json().get("predictions", [{}])[0]
return float(o.get("v") or 0.0), float(p.get("v") or 0.0)
async def run(ctx) -> None:
cfg = ctx.cfg
station = str(cfg.get("station", "8443970")) # Boston by default
period = float(cfg.get("poll_seconds", 360.0)) # 6 min
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
obs, pred = await _fetch_level(cli, station)
ctx.send("level", obs, pred, obs - pred)
except Exception as e: # noqa: BLE001
LOG.warning("tides fetch failed: %s", e)
phase, illum = _moon_phase(time.time())
ctx.send("moon", float(phase), float(illum))
await asyncio.sleep(period)
+62
View File
@@ -0,0 +1,62 @@
"""Wikipedia recent changes — Wikimedia EventStreams SSE.
Firehose des modifications Wikipedia toutes langues confondues.
Tres bavard (~10-30 events/s) — on echantillonne et on emet une
fraction + des compteurs.
OSC out :
/data/wikimedia/edit lang title bot (sample)
/data/wikimedia/rate per_second
"""
from __future__ import annotations
import asyncio
import json
import logging
import random
import time
import httpx
LOG = logging.getLogger("feed.wikimedia")
URL = "https://stream.wikimedia.org/v2/stream/recentchange"
async def run(ctx) -> None:
cfg = ctx.cfg
sample = float(cfg.get("sample_rate", 0.05)) # emit 5% des edits
window = float(cfg.get("rate_window_s", 5.0))
while True:
try:
async with httpx.AsyncClient(timeout=None) as cli:
async with cli.stream("GET", URL,
headers={"Accept": "text/event-stream"}
) as r:
r.raise_for_status()
bucket = 0
bucket_start = time.monotonic()
async for line in r.aiter_lines():
if not line.startswith("data:"):
continue
try:
ev = json.loads(line[5:].strip())
except json.JSONDecodeError:
continue
bucket += 1
# Sample emit
if random.random() < sample:
lang = (ev.get("wiki") or "").replace("wiki", "")
title = str(ev.get("title", ""))[:64]
bot = 1.0 if ev.get("bot") else 0.0
ctx.send("edit", lang, title, bot)
# Periodic rate
now = time.monotonic()
if now - bucket_start >= window:
per_s = bucket / (now - bucket_start)
ctx.send("rate", float(per_s))
bucket = 0
bucket_start = now
except Exception as e: # noqa: BLE001
LOG.warning("wikimedia stream error: %s — reconnect 5s", e)
await asyncio.sleep(5.0)