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:
2026-06-28 18:21:01 +02:00
co-authored by Cursor
parent 4377b8c9ea
commit bd3b4b66cc
+119 -4
View File
@@ -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):