civil_protection: aggiunge allerte regionali Emilia-Romagna (Arpae)
Il bollettino nazionale Protezione Civile copre solo idraulico/idrogeologico/ temporali, quindi le allerte per temperature estreme non venivano notificate. - Aggiunta fonte regionale Arpae via API get-stato-allerta (documento valido ora + preavviso giorno successivo) per le categorie assenti dal nazionale: temperature estreme, vento, neve, stato del mare, mareggiate, pioggia che gela - Fonte regionale indipendente: parte anche se il bollettino nazionale fallisce - Firma anti-spam (dpc_state.json) estesa ai documenti regionali - Corretto il regex dei rischi su piu' parole (es. "TEMPERATURE ESTREME") Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,6 +28,10 @@ DEBUG = os.environ.get("DEBUG", "0").strip() == "1"
|
|||||||
|
|
||||||
BULLETIN_URL = "https://mappe.protezionecivile.gov.it/it/mappe-rischi/bollettino-di-criticita/"
|
BULLETIN_URL = "https://mappe.protezionecivile.gov.it/it/mappe-rischi/bollettino-di-criticita/"
|
||||||
|
|
||||||
|
# Allerta meteo regionale Emilia-Romagna (Arpae): copre categorie assenti dal
|
||||||
|
# bollettino nazionale (temperature estreme, vento, neve, mareggiate, ecc.).
|
||||||
|
REGIONAL_ALERT_URL = "https://allertameteo.regione.emilia-romagna.it/o/get-stato-allerta"
|
||||||
|
|
||||||
# Chat IDs
|
# Chat IDs
|
||||||
TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"]
|
TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"]
|
||||||
|
|
||||||
@@ -48,10 +52,20 @@ TARGET_ZONES = {
|
|||||||
"EMR-D1": "Pianura bolognese",
|
"EMR-D1": "Pianura bolognese",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Mappa codice zona regionale Arpae -> nome leggibile (deriva da TARGET_ZONES,
|
||||||
|
# togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese").
|
||||||
|
REGIONAL_TARGET_ZONES = {code.split("-")[-1]: name for code, name in TARGET_ZONES.items()}
|
||||||
|
|
||||||
RISK_ICON = {
|
RISK_ICON = {
|
||||||
"IDRAULICO": "💧",
|
"IDRAULICO": "💧",
|
||||||
"IDROGEOLOGICO": "⛰️",
|
"IDROGEOLOGICO": "⛰️",
|
||||||
"TEMPORALI": "⚡",
|
"TEMPORALI": "⚡",
|
||||||
|
"TEMPERATURE ESTREME": "🌡️",
|
||||||
|
"VENTO": "🌬️",
|
||||||
|
"NEVE": "❄️",
|
||||||
|
"STATO DEL MARE": "🌊",
|
||||||
|
"MAREGGIATE": "🌊",
|
||||||
|
"PIOGGIA CHE GELA": "🧊",
|
||||||
}
|
}
|
||||||
|
|
||||||
COLOR_ICON = {
|
COLOR_ICON = {
|
||||||
@@ -60,6 +74,25 @@ COLOR_ICON = {
|
|||||||
"ROSSA": "🔴",
|
"ROSSA": "🔴",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Categorie di rischio prese SOLO dalla fonte regionale (quelle che il bollettino
|
||||||
|
# nazionale non pubblica). Idraulico/idrogeologico/temporali restano gestiti dal
|
||||||
|
# bollettino nazionale per evitare doppioni.
|
||||||
|
REGIONAL_RISK_LABELS = {
|
||||||
|
"temperature_estreme": "TEMPERATURE ESTREME",
|
||||||
|
"vento": "VENTO",
|
||||||
|
"neve": "NEVE",
|
||||||
|
"stato_mare": "STATO DEL MARE",
|
||||||
|
"mareggiate": "MAREGGIATE",
|
||||||
|
"ghiaccio_pioggia_gela": "PIOGGIA CHE GELA",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Codici colore Arpae -> etichette interne (green/null = nessuna allerta).
|
||||||
|
REGIONAL_COLOR_MAP = {
|
||||||
|
"yellow": "GIALLA",
|
||||||
|
"orange": "ARANCIONE",
|
||||||
|
"red": "ROSSA",
|
||||||
|
}
|
||||||
|
|
||||||
HTTP_HEADERS = {"User-Agent": "rpi-dpc-bollettino/1.2"}
|
HTTP_HEADERS = {"User-Agent": "rpi-dpc-bollettino/1.2"}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -222,7 +255,7 @@ def html_to_lines(html_text: str) -> list[str]:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
RISK_HEADER_RE = re.compile(
|
RISK_HEADER_RE = re.compile(
|
||||||
r"^(ORDINARIA|MODERATA|ELEVATA)\s+CRITICITA'\s+PER\s+RISCHIO\s+([A-ZÀ-Ü]+)\s*/\s*ALLERTA\s+(GIALLA|ARANCIONE|ROSSA)\s*:\s*$"
|
r"^(ORDINARIA|MODERATA|ELEVATA)\s+CRITICITA'\s+PER\s+RISCHIO\s+([A-ZÀ-Ü]+(?:\s+[A-ZÀ-Ü]+)*)\s*/\s*ALLERTA\s+(GIALLA|ARANCIONE|ROSSA)\s*:\s*$"
|
||||||
)
|
)
|
||||||
DAY_START_RE = re.compile(r"^Per la giornata di (oggi|domani),\s*(.+?):\s*$", re.IGNORECASE)
|
DAY_START_RE = re.compile(r"^Per la giornata di (oggi|domani),\s*(.+?):\s*$", re.IGNORECASE)
|
||||||
TITLE_RE = re.compile(r"^Bollettino di Criticità del (.+?) ore (\d{1,2}:\d{2})", re.IGNORECASE)
|
TITLE_RE = re.compile(r"^Bollettino di Criticità del (.+?) ore (\d{1,2}:\d{2})", re.IGNORECASE)
|
||||||
@@ -293,8 +326,66 @@ def has_any_alert(parsed: dict) -> bool:
|
|||||||
day = parsed.get("days", {}).get(day_key, {})
|
day = parsed.get("days", {}).get(day_key, {})
|
||||||
if day.get("alerts"):
|
if day.get("alerts"):
|
||||||
return True
|
return True
|
||||||
|
if parsed.get("regional"):
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Allerta regionale Emilia-Romagna (Arpae)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def fetch_regional_state(data: Optional[str] = None) -> dict:
|
||||||
|
"""Stato allertamento regionale. `data` opzionale: 'YYYY-MM-DD HH:mm'."""
|
||||||
|
params = {"data": data} if data else None
|
||||||
|
r = requests.get(REGIONAL_ALERT_URL, params=params, headers=HTTP_HEADERS, timeout=25)
|
||||||
|
r.raise_for_status()
|
||||||
|
r.encoding = "utf-8"
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def parse_regional_doc(data: dict) -> dict:
|
||||||
|
"""Estrae le allerte (solo categorie REGIONAL_RISK_LABELS) per le zone target."""
|
||||||
|
out = {"titolo": (data.get("titolo") or "").strip(), "alerts": {}}
|
||||||
|
for zone_code, zone_name in REGIONAL_TARGET_ZONES.items():
|
||||||
|
zinfo = data.get(zone_code)
|
||||||
|
if not isinstance(zinfo, dict):
|
||||||
|
continue
|
||||||
|
for risk_key, risk_label in REGIONAL_RISK_LABELS.items():
|
||||||
|
color = zinfo.get(risk_key)
|
||||||
|
color_name = REGIONAL_COLOR_MAP.get((color or "").strip().lower())
|
||||||
|
if not color_name:
|
||||||
|
continue
|
||||||
|
icon = COLOR_ICON.get(color_name, "⚪")
|
||||||
|
ricon = RISK_ICON.get(risk_label, "⚠️")
|
||||||
|
entry = f"{icon} {ricon} {risk_label}"
|
||||||
|
out["alerts"].setdefault(zone_name, [])
|
||||||
|
if entry not in out["alerts"][zone_name]:
|
||||||
|
out["alerts"][zone_name].append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def fetch_regional_documents() -> list:
|
||||||
|
"""Documento valido ora + documento del giorno successivo (preavviso).
|
||||||
|
|
||||||
|
Restituisce solo i documenti che contengono allerte nelle zone target,
|
||||||
|
deduplicati per titolo. Non solleva eccezioni: in caso di errore logga e
|
||||||
|
restituisce la lista dei documenti ottenuti finora.
|
||||||
|
"""
|
||||||
|
docs = []
|
||||||
|
seen_titoli = set()
|
||||||
|
tomorrow = (now_rome() + datetime.timedelta(days=1)).strftime("%Y-%m-%d 12:00")
|
||||||
|
for data in (None, tomorrow):
|
||||||
|
try:
|
||||||
|
j = fetch_regional_state(data)
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.exception("Errore fetch allerta regionale (data=%s): %s", data, e)
|
||||||
|
continue
|
||||||
|
doc = parse_regional_doc(j)
|
||||||
|
if doc["titolo"] in seen_titoli:
|
||||||
|
continue
|
||||||
|
seen_titoli.add(doc["titolo"])
|
||||||
|
if doc["alerts"]:
|
||||||
|
docs.append(doc)
|
||||||
|
return docs
|
||||||
|
|
||||||
def build_signature(parsed: dict) -> str:
|
def build_signature(parsed: dict) -> str:
|
||||||
parts = [parsed.get("issued_date", ""), parsed.get("issued_time", ""), parsed.get("title", "")]
|
parts = [parsed.get("issued_date", ""), parsed.get("issued_time", ""), parsed.get("title", "")]
|
||||||
for day_key in ("oggi", "domani"):
|
for day_key in ("oggi", "domani"):
|
||||||
@@ -303,6 +394,11 @@ def build_signature(parsed: dict) -> str:
|
|||||||
alerts = day.get("alerts", {})
|
alerts = day.get("alerts", {})
|
||||||
for zone in sorted(alerts.keys()):
|
for zone in sorted(alerts.keys()):
|
||||||
parts.append(zone + "=" + ",".join(sorted(alerts[zone])))
|
parts.append(zone + "=" + ",".join(sorted(alerts[zone])))
|
||||||
|
for doc in parsed.get("regional", []):
|
||||||
|
parts.append("REG:" + doc.get("titolo", ""))
|
||||||
|
alerts = doc.get("alerts", {})
|
||||||
|
for zone in sorted(alerts.keys()):
|
||||||
|
parts.append(zone + "=" + ",".join(sorted(alerts[zone])))
|
||||||
return "|".join(parts)
|
return "|".join(parts)
|
||||||
|
|
||||||
def format_message(parsed: dict) -> str:
|
def format_message(parsed: dict) -> str:
|
||||||
@@ -329,6 +425,19 @@ def format_message(parsed: dict) -> str:
|
|||||||
lines.append(html_lib.escape(entry))
|
lines.append(html_lib.escape(entry))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
regional = parsed.get("regional", [])
|
||||||
|
for doc in regional:
|
||||||
|
titolo = doc.get("titolo") or "Allerta meteo Emilia-Romagna"
|
||||||
|
lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>")
|
||||||
|
alerts = doc.get("alerts", {})
|
||||||
|
for zone in sorted(alerts.keys()):
|
||||||
|
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
|
||||||
|
for entry in alerts[zone]:
|
||||||
|
lines.append(html_lib.escape(entry))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if regional:
|
||||||
|
lines.append("<i>Fonte: allertameteo.regione.emilia-romagna.it</i>")
|
||||||
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
|
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -339,13 +448,17 @@ def format_message(parsed: dict) -> str:
|
|||||||
def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
|
def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
|
||||||
LOGGER.info("--- Controllo Protezione Civile (Bollettino ufficiale) ---")
|
LOGGER.info("--- Controllo Protezione Civile (Bollettino ufficiale) ---")
|
||||||
|
|
||||||
|
parsed = {"title": "", "issued_date": "", "issued_time": "", "days": {}}
|
||||||
try:
|
try:
|
||||||
lines = fetch_bulletin_text_lines()
|
lines = fetch_bulletin_text_lines()
|
||||||
parsed = parse_bulletin(lines)
|
parsed = parse_bulletin(lines)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# NESSUN Telegram in caso di errori: solo log.
|
# Bollettino nazionale non disponibile: prosegue comunque con la fonte
|
||||||
LOGGER.exception("Errore durante fetch/parse bollettino: %s", e)
|
# regionale (sotto). Nessun Telegram per il solo errore nazionale.
|
||||||
return
|
LOGGER.exception("Errore durante fetch/parse bollettino nazionale: %s", e)
|
||||||
|
|
||||||
|
# Fonte regionale Arpae (indipendente: gestisce internamente i propri errori).
|
||||||
|
parsed["regional"] = fetch_regional_documents()
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
LOGGER.debug("title=%s", parsed.get("title", ""))
|
LOGGER.debug("title=%s", parsed.get("title", ""))
|
||||||
@@ -353,6 +466,8 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
|
|||||||
d = parsed.get("days", {}).get(k, {})
|
d = parsed.get("days", {}).get(k, {})
|
||||||
LOGGER.debug("%s label=%s", k, d.get("date_label", ""))
|
LOGGER.debug("%s label=%s", k, d.get("date_label", ""))
|
||||||
LOGGER.debug("%s alerts=%s", k, d.get("alerts", {}))
|
LOGGER.debug("%s alerts=%s", k, d.get("alerts", {}))
|
||||||
|
for doc in parsed.get("regional", []):
|
||||||
|
LOGGER.debug("regionale titolo=%s alerts=%s", doc.get("titolo", ""), doc.get("alerts", {}))
|
||||||
|
|
||||||
# Regola: invia Telegram SOLO se esistono allerte (tranne in debug)
|
# Regola: invia Telegram SOLO se esistono allerte (tranne in debug)
|
||||||
if not has_any_alert(parsed):
|
if not has_any_alert(parsed):
|
||||||
|
|||||||
Reference in New Issue
Block a user