Files
meshcore-bot/modules/nws_alerts.py
T
agessaman fc79a5bfe3 refactor(weather): one NWS alert entry parser for wx and the weather service
wx's get_weather_alerts_noaa and the service's _parse_alert_entry each carried a copy of the same Atom entry parser: title, summary, NWSheadline, the title-based event and time extraction, the CAP child scan and the severity and urgency inference. modules/nws_alerts.py now holds it once as parse_alert_fields. The two Special Weather Statement classifiers stay distinct, as WX_SPECIAL_RULES and SERVICE_SPECIAL_RULES (headline classifier, summary keyword table, and whether to run wx's extra localName scan), so each caller produces exactly what it did before. The service keeps its link extraction, id and None-on-error contract; wx keeps its title-only fallback and warning.

Also dropped: wx's expiry-time block, which parsed nothing and assigned nothing, and the redundant endswith(':tag') checks in the CAP scan, which the substring checks already covered. The localName pass catches Exception instead of everything. The characterization goldens are unchanged.
2026-10-01 07:44:19 -07:00

328 lines
14 KiB
Python

"""Parse entries of the NWS active-alerts Atom feed (api.weather.gov/alerts/active.atom).
Shared by the wx command and the weather service. The two classify Special
Weather Statements differently, so each passes its own ``SpecialStatementRules``
(``WX_SPECIAL_RULES`` or ``SERVICE_SPECIAL_RULES``); everything else is common.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, Callable, Optional
SPECIAL_EVENTS = ("special", "special weather")
_HEADLINE_SKIP_WORDS = {
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'will', 'lead',
'increased', 'threat', 'remains', 'effect', 'until', 'during', 'last', 'week', 'including', 'today',
}
# (keywords in the lowercased child tag, field); first match wins, as an elif chain
_CAP_CHILD_FIELDS = (
(("event",), "event"),
(("severity",), "severity"),
(("urgency",), "urgency"),
(("certainty",), "certainty"),
(("effective",), "effective"),
(("expires",), "expires"),
(("areadesc", "area"), "area_desc"),
)
# localName -> (field, value meaning "not found yet")
_CAP_LOCAL_NAMES = {
'event': ('event', ''),
'severity': ('severity', 'Unknown'),
'urgency': ('urgency', 'Unknown'),
'certainty': ('certainty', 'Unknown'),
'effective': ('effective', ''),
'expires': ('expires', ''),
'areadesc': ('area_desc', ''),
'area': ('area_desc', ''),
}
def node_value(node: Any) -> str:
"""Joined text of a DOM node's direct text children."""
if not node or not node.childNodes:
return ""
text_parts = []
for child in node.childNodes:
if child.nodeType == child.TEXT_NODE or hasattr(child, 'nodeValue') and child.nodeValue:
text_parts.append(child.nodeValue)
return " ".join(text_parts).strip()
def _first_child_value(entry: Any, tag: str) -> str:
elems = entry.getElementsByTagName(tag)
if elems and elems[0].childNodes:
return elems[0].childNodes[0].nodeValue if elems[0].childNodes[0].nodeValue else ""
return ""
def entry_title(entry: Any) -> Optional[str]:
"""The entry's <title> text ("" when absent; None when its first child is not text)."""
title_elem = entry.getElementsByTagName("title")
return title_elem[0].childNodes[0].nodeValue if title_elem and title_elem[0].childNodes else ""
def entry_summary(entry: Any) -> str:
"""The entry's <summary> text, falling back to <content>."""
return _first_child_value(entry, "summary") or _first_child_value(entry, "content")
def entry_nws_headline(entry: Any) -> str:
"""The NWSheadline CAP parameter (cap:parameter, or parameter without the prefix)."""
params = entry.getElementsByTagName("cap:parameter")
if not params:
params = entry.getElementsByTagName("parameter")
for param in params:
value_name_elem = param.getElementsByTagName("valueName")
value_elem = param.getElementsByTagName("value")
if value_name_elem and value_elem and value_name_elem[0].childNodes and value_elem[0].childNodes:
value_name = value_name_elem[0].childNodes[0].nodeValue if value_name_elem[0].childNodes[0].nodeValue else ""
if value_name == "NWSheadline":
return value_elem[0].childNodes[0].nodeValue if value_elem[0].childNodes[0].nodeValue else ""
return ""
def _first_meaningful_headline_word(headline_lower: str) -> Optional[str]:
meaningful_words = [w for w in headline_lower.split() if w not in _HEADLINE_SKIP_WORDS and len(w) > 3]
return meaningful_words[0].capitalize() if meaningful_words else None
def _rain_unless_other_threat(headline_lower: str) -> Optional[str]:
# Rain mentioned alongside another threat keeps the generic event
if not any(word in headline_lower for word in ['landslide', 'flood', 'wind', 'snow']):
return "Rainfall"
return None
def wx_special_headline_event(headline_lower: str) -> Optional[str]:
"""wx: event name for a Special Weather Statement from its NWSheadline, or None to keep it generic."""
if any(phrase in headline_lower for phrase in ['debris flow', 'mudslide']):
return "Debris Flow"
if 'landslide' in headline_lower:
return "Landslide (Burn)" if ('burn' in headline_lower or 'burned area' in headline_lower) else "Landslide"
if any(phrase in headline_lower for phrase in ['flash flood', 'river flood']) or 'flood' in headline_lower or 'flooding' in headline_lower:
return "Flood"
if any(phrase in headline_lower for phrase in ['high wind', 'strong wind', 'damaging wind']) or 'wind' in headline_lower or 'gust' in headline_lower:
return "Wind"
if any(phrase in headline_lower for phrase in ['heavy rain', 'excessive rain']):
return "Heavy Rain"
if 'rain' in headline_lower or 'rainfall' in headline_lower or 'precipitation' in headline_lower:
return _rain_unless_other_threat(headline_lower)
if any(phrase in headline_lower for phrase in ['heavy snow', 'blizzard', 'winter storm']) or 'snow' in headline_lower or 'winter' in headline_lower:
return "Snow"
if any(phrase in headline_lower for phrase in ['dense fog', 'low visibility']):
return "Fog"
if 'fog' in headline_lower or 'visibility' in headline_lower:
return "Visibility"
if any(phrase in headline_lower for phrase in ['extreme heat', 'excessive heat']):
return "Heat"
if 'heat' in headline_lower or 'temperature' in headline_lower:
return "Temperature"
if any(phrase in headline_lower for phrase in ['storm surge', 'coastal flood']) or 'marine' in headline_lower or 'coastal' in headline_lower:
return "Marine"
return _first_meaningful_headline_word(headline_lower)
def service_special_headline_event(headline_lower: str) -> Optional[str]:
"""Weather service: event name for a Special Weather Statement from its NWSheadline, or None."""
if any(phrase in headline_lower for phrase in ['debris flow', 'mudslide']):
return "Debris Flow"
if 'landslide' in headline_lower:
return "Landslide (Burn)" if ('burn' in headline_lower or 'burned area' in headline_lower) else "Landslide"
if any(phrase in headline_lower for phrase in ['flash flood', 'river flood', 'flood', 'flooding']):
return "Flood"
if any(phrase in headline_lower for phrase in ['high wind', 'strong wind', 'damaging wind', 'wind', 'gust']):
return "Wind"
if any(phrase in headline_lower for phrase in ['heavy rain', 'excessive rain', 'rain', 'rainfall', 'precipitation']):
return _rain_unless_other_threat(headline_lower)
if any(phrase in headline_lower for phrase in ['heavy snow', 'blizzard', 'winter storm', 'snow', 'winter']):
return "Snow"
if any(phrase in headline_lower for phrase in ['dense fog', 'low visibility', 'fog', 'visibility']):
return "Fog" if 'fog' in headline_lower else "Visibility"
if any(phrase in headline_lower for phrase in ['extreme heat', 'excessive heat', 'heat', 'temperature']):
return "Heat" if 'heat' in headline_lower else "Temperature"
if any(phrase in headline_lower for phrase in ['storm surge', 'coastal flood', 'marine', 'coastal']):
return "Marine"
return _first_meaningful_headline_word(headline_lower)
_COMMON_SUMMARY_KEYWORDS: tuple[tuple[tuple[str, ...], str], ...] = (
(('landslide', 'debris flow', 'mudslide'), "Landslide"),
(('hydrologic', 'river', 'flood', 'stream'), "Hydrologic"),
(('marine', 'coastal', 'beach', 'surf'), "Marine"),
)
@dataclass(frozen=True)
class SpecialStatementRules:
"""How a caller names a generic Special Weather Statement."""
headline_event: Callable[[str], Optional[str]]
summary_keywords: tuple[tuple[tuple[str, ...], str], ...]
# wx also reads CAP fields by DOM localName after the tag-name scan
scan_local_names: bool
WX_SPECIAL_RULES = SpecialStatementRules(
headline_event=wx_special_headline_event,
summary_keywords=_COMMON_SUMMARY_KEYWORDS + (
(('avalanche', 'snow', 'mountain'), "Avalanche"),
(('air quality', 'smoke', 'pollution'), "Air Quality"),
(('wind', 'gust'), "Wind"),
(('rain', 'precipitation', 'shower', 'rainfall'), "Rainfall"),
(('temperature', 'heat', 'cold', 'freeze'), "Temperature"),
(('visibility', 'fog', 'haze'), "Visibility"),
),
scan_local_names=True,
)
SERVICE_SPECIAL_RULES = SpecialStatementRules(
headline_event=service_special_headline_event,
summary_keywords=_COMMON_SUMMARY_KEYWORDS + (
(('wind', 'gust'), "Wind"),
(('rain', 'precipitation', 'shower', 'rainfall'), "Rainfall"),
),
scan_local_names=False,
)
def _special_statement_event(event: str, title_lower: str, nws_headline: str, summary: str,
rules: SpecialStatementRules) -> str:
if event.lower() in SPECIAL_EVENTS and nws_headline:
event = rules.headline_event(nws_headline.lower()) or event
if event.lower() in SPECIAL_EVENTS and summary:
summary_lower = summary.lower()
for words, name in rules.summary_keywords:
if any(word in summary_lower for word in words):
event = name
break
if event.lower() in SPECIAL_EVENTS:
event = "Weather" if "weather" in title_lower else "Special"
return event
def _event_from_title(title: str, nws_headline: str, summary: str, rules: SpecialStatementRules) -> tuple[str, str]:
"""(event_type, event) from a title such as "High Wind Warning issued ... by NWS Seattle WA"."""
title_lower = title.lower()
for event_type, pattern in (
("Warning", r'^([^W]+?)\s+Warning'),
("Watch", r'^([^W]+?)\s+Watch'),
("Advisory", r'^([^A]+?)\s+Advisory'),
):
if event_type.lower() in title_lower:
event_match = re.search(pattern, title, re.IGNORECASE)
return event_type, event_match.group(1).strip() if event_match else ""
if "statement" in title_lower:
event_match = re.search(r'^([^S]+?)\s+Statement', title, re.IGNORECASE)
event = event_match.group(1).strip() if event_match else "Special"
return "Statement", _special_statement_event(event, title_lower, nws_headline, summary, rules)
return "Unknown", title.split()[0] if title else ""
def _times_and_office_from_title(title: str) -> tuple[str, str, str]:
"""(effective, expires, office) from "issued X until Y by OFFICE"."""
effective = expires = office = ""
issued_match = re.search(r'issued\s+([^u]+?)\s+until\s+(.+?)\s+by', title, re.IGNORECASE)
if issued_match:
effective = issued_match.group(1).strip()
expires = issued_match.group(2).strip()
else:
until_match = re.search(r'until\s+(.+?)\s+by', title, re.IGNORECASE)
if until_match:
expires = until_match.group(1).strip()
office_match = re.search(r'by\s+(.+?)$', title, re.IGNORECASE)
if office_match:
office = office_match.group(1).strip()
return effective, expires, office
def _apply_cap_children(entry: Any, fields: dict[str, str]) -> None:
"""Override fields from the entry's direct CAP children (cap:event, cap:severity, ...)."""
for child in entry.childNodes:
if not hasattr(child, 'tagName'):
continue
tag_lower = child.tagName.lower()
for keywords, field in _CAP_CHILD_FIELDS:
if not any(k in tag_lower for k in keywords):
continue
if field == "event" and fields["event"]:
continue
value = node_value(child)
if value:
fields[field] = value
break
def _apply_cap_local_names(entry: Any, fields: dict[str, str]) -> None:
"""Fill still-unset fields from any descendant by DOM localName."""
try:
for node in entry.getElementsByTagName("*"):
if hasattr(node, 'localName'):
local_name = node.localName.lower()
node_val = node_value(node)
if node_val and local_name in _CAP_LOCAL_NAMES:
field, unset = _CAP_LOCAL_NAMES[local_name]
if fields[field] == unset:
fields[field] = node_val
except Exception:
pass # Namespace-aware attributes may not be available
def parse_alert_fields(entry: Any, title: str, summary: str, nws_headline: str,
rules: SpecialStatementRules) -> dict[str, str]:
"""Alert dict (title through office) for one feed entry; raises on malformed input."""
event_type, event = _event_from_title(title, nws_headline, summary, rules)
effective, expires, office = _times_and_office_from_title(title)
fields = {
'event': event,
'severity': "Unknown",
'urgency': "Unknown",
'certainty': "Unknown",
'effective': effective,
'expires': expires,
'area_desc': "",
}
_apply_cap_children(entry, fields)
if rules.scan_local_names:
_apply_cap_local_names(entry, fields)
event = fields['event']
severity = fields['severity']
if severity == "Unknown":
if any(word in event.lower() for word in ['extreme', 'tornado', 'hurricane', 'blizzard']):
severity = "Extreme"
elif any(word in event.lower() for word in ['severe', 'warning']):
severity = "Severe"
elif any(word in event.lower() for word in ['advisory', 'moderate']):
severity = "Moderate"
else:
severity = "Minor"
urgency = fields['urgency']
if urgency == "Unknown":
if event_type == "Warning":
urgency = "Immediate"
elif event_type == "Watch":
urgency = "Expected"
else:
urgency = "Future"
return {
'title': title,
'summary': summary,
'nws_headline': nws_headline,
'event': event,
'event_type': event_type,
'severity': severity,
'urgency': urgency,
'certainty': fields['certainty'],
'effective': fields['effective'],
'expires': fields['expires'],
'area_desc': fields['area_desc'],
'office': office,
}