diff --git a/docs/design.md b/docs/design.md index 44910064..b9326a48 100644 --- a/docs/design.md +++ b/docs/design.md @@ -104,7 +104,7 @@ RSS 来源可以按地点 ID 限定范围,并配置正文清洗规则。`verba 和风天气使用 `QWeatherJWTAuthenticator` 签发短期 JWT。认证输入包括项目 ID、凭据 ID、专属 API Host 和 Base64 编码的 Ed25519 私钥。实现不支持用长期 API Key 代替该流程。 -`OpenMeteoProvider` 提供全球天气,并从独立接口读取空气质量和欧洲花粉数据。公开接口适用于非商业使用,没有 SLA;Base URL 和可选 API Key 可以替换。 +`OpenMeteoProvider` 提供全球天气,并从独立接口读取空气质量和欧洲花粉数据。适配器在生成来源正文前把 WMO 天气代码转换为可读的中文天气现象;未知代码保留为“未识别天气现象”并写入安全日志。公开接口适用于非商业使用,没有 SLA;Base URL 和可选 API Key 可以替换。 `AQICNProvider` 只在最终天气结果缺少空气质量时补充当前观测。它不参与未来日期查询,也不把 PM2.5 单项 AQI 换算为浓度。 diff --git a/tests/test_reference_data.py b/tests/test_reference_data.py index b77df472..59d60bf4 100644 --- a/tests/test_reference_data.py +++ b/tests/test_reference_data.py @@ -9,6 +9,7 @@ ReferenceDataError, load_reference_data, localization_table, + open_meteo_weather_code_descriptions, reference_string, reference_string_tuple, reference_value, @@ -46,6 +47,38 @@ def test_packaged_reference_data_is_available() -> None: assert reference_string_tuple("content_cleaning.json", "default_remove_selectors") assert reference_string_tuple("provider_defaults.json", "qweather_lifestyle_index_types") assert reference_string("provider_defaults.json", "qweather_allergen_index_type") == "7" + weather_codes = open_meteo_weather_code_descriptions() + assert set(weather_codes) == { + 0, + 1, + 2, + 3, + 45, + 48, + 51, + 53, + 55, + 56, + 57, + 61, + 63, + 65, + 66, + 67, + 71, + 73, + 75, + 77, + 80, + 81, + 82, + 85, + 86, + 95, + 96, + 99, + } + assert weather_codes[53] == "中等强度毛毛雨" assert localization_table("weather_document")["ja"]["forecast"] == "天気予報" assert localization_table("briefing")["zh-Hans"]["weather"] == "天气信息" classification = telegram_error_classification() @@ -53,6 +86,26 @@ def test_packaged_reference_data_is_available() -> None: assert classification.status_reasons[401] == "bot-token-rejected" +@pytest.mark.parametrize( + "value", + ( + {}, + {"descriptions_zh_CN": {}}, + {"descriptions_zh_CN": {"unknown": "晴朗"}}, + {"descriptions_zh_CN": {"00": "晴朗"}}, + {"descriptions_zh_CN": {"9" * 5_000: "未知"}}, + {"descriptions_zh_CN": {"0": ""}}, + {"descriptions_zh_CN": {"0": "晴朗"}, "unknown": {}}, + ), +) +def test_open_meteo_weather_codes_reject_invalid_data(monkeypatch, value) -> None: + monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value) + open_meteo_weather_code_descriptions.cache_clear() + + with pytest.raises(ReferenceDataError, match="Open-Meteo weather codes"): + open_meteo_weather_code_descriptions() + + def test_air_quality_guidance_covers_values_above_last_bounded_band() -> None: category, guidance = health_guidance(10_000) diff --git a/tests/test_weather_context.py b/tests/test_weather_context.py index 7c3517db..9a81643d 100644 --- a/tests/test_weather_context.py +++ b/tests/test_weather_context.py @@ -27,6 +27,7 @@ _format_qweather_day, _format_qweather_lifestyle, _open_meteo_daily_peak_values, + _open_meteo_weather_description, snapshot_to_documents, ) @@ -532,7 +533,8 @@ def handler(request: httpx.Request) -> httpx.Response: ).fetch(52.52, 13.41) assert len(snapshot.weather_forecast) == 2 - assert "WMO天气代码2" in snapshot.weather_forecast[0] + assert "局部多云" in snapshot.weather_forecast[0] + assert "WMO" not in snapshot.weather_forecast[0] assert snapshot.observed_at.to_iso8601_string() == "2026-07-13T08:00:00+02:00" assert snapshot.observed_at.timezone_name == "Europe/Berlin" assert snapshot.air_quality is not None @@ -541,6 +543,26 @@ def handler(request: httpx.Request) -> httpx.Response: assert snapshot.air_quality.pm25_concentration == 9.5 +def test_open_meteo_weather_code_uses_readable_description() -> None: + assert _open_meteo_weather_description(53) == "中等强度毛毛雨" + + +def test_open_meteo_unknown_weather_code_uses_readable_fallback(caplog) -> None: + with caplog.at_level("WARNING", logger="weather_briefing.weather_context"): + description = _open_meteo_weather_description(100) + + assert description == "未识别天气现象" + assert "Unknown Open-Meteo weather code code=100" in caplog.text + + +def test_open_meteo_invalid_weather_code_uses_readable_fallback(caplog) -> None: + with caplog.at_level("WARNING", logger="weather_briefing.weather_context"): + description = _open_meteo_weather_description("53") + + assert description == "未识别天气现象" + assert "Invalid Open-Meteo weather code value_type=str" in caplog.text + + async def test_open_meteo_provider_requests_only_selected_future_date() -> None: target_date = pendulum.date(2026, 7, 15) diff --git a/weather_briefing/data/open_meteo_weather_codes.json b/weather_briefing/data/open_meteo_weather_codes.json new file mode 100644 index 00000000..85401122 --- /dev/null +++ b/weather_briefing/data/open_meteo_weather_codes.json @@ -0,0 +1,32 @@ +{ + "descriptions_zh_CN": { + "0": "晴朗", + "1": "大致晴朗", + "2": "局部多云", + "3": "阴天", + "45": "有雾", + "48": "雾凇", + "51": "轻微毛毛雨", + "53": "中等强度毛毛雨", + "55": "强毛毛雨", + "56": "轻微冻毛毛雨", + "57": "强冻毛毛雨", + "61": "小雨", + "63": "中雨", + "65": "大雨", + "66": "轻微冻雨", + "67": "强冻雨", + "71": "小雪", + "73": "中雪", + "75": "大雪", + "77": "米雪", + "80": "轻微阵雨", + "81": "中等强度阵雨", + "82": "强阵雨", + "85": "轻微阵雪", + "86": "强阵雪", + "95": "雷暴", + "96": "雷暴伴轻微冰雹", + "99": "雷暴伴强冰雹" + } +} diff --git a/weather_briefing/reference_data.py b/weather_briefing/reference_data.py index 365d96d8..8002e2a3 100644 --- a/weather_briefing/reference_data.py +++ b/weather_briefing/reference_data.py @@ -142,6 +142,32 @@ def reference_string_tuple(filename: str, *path: str) -> tuple[str, ...]: return tuple(value) +@cache +def open_meteo_weather_code_descriptions() -> Mapping[int, str]: + """Return validated Chinese descriptions for Open-Meteo WMO weather codes.""" + value = load_reference_data("open_meteo_weather_codes.json") + descriptions = value.get("descriptions_zh_CN") + if set(value) != {"descriptions_zh_CN"} or not isinstance(descriptions, dict) or not descriptions: + raise ReferenceDataError("Open-Meteo weather codes must contain Chinese descriptions") + + validated: dict[int, str] = {} + for code, description in descriptions.items(): + if ( + not isinstance(code, str) + or not code.isascii() + or not code.isdigit() + or len(code) > 2 + or not isinstance(description, str) + or not description.strip() + ): + raise ReferenceDataError("Open-Meteo weather codes must map numeric codes to descriptions") + numeric_code = int(code) + if str(numeric_code) != code: + raise ReferenceDataError("Open-Meteo weather codes must map numeric codes to descriptions") + validated[numeric_code] = description + return MappingProxyType(validated) + + @cache def telegram_error_classification() -> TelegramErrorClassification: """Return validated Telegram API error classification data.""" diff --git a/weather_briefing/weather_context.py b/weather_briefing/weather_context.py index feaaad97..083a6f37 100644 --- a/weather_briefing/weather_context.py +++ b/weather_briefing/weather_context.py @@ -28,7 +28,13 @@ SourceDocument, WeatherContextSnapshot, ) -from .reference_data import ReferenceDataError, localization_table, reference_string, reference_string_tuple +from .reference_data import ( + ReferenceDataError, + localization_table, + open_meteo_weather_code_descriptions, + reference_string, + reference_string_tuple, +) from .time_utils import ( datetime_timezone_specifier, parse_datetime_with_default_timezone, @@ -1020,7 +1026,7 @@ def _float_value(value: object) -> float: def _format_open_meteo_day(daily: dict[str, object], index: int) -> str: return ( f"{_open_meteo_daily_value(daily, 'time', index)}:" - f"WMO天气代码{_open_meteo_daily_value(daily, 'weather_code', index)}," + f"{_open_meteo_weather_description(_open_meteo_daily_value(daily, 'weather_code', index))}," f"{_open_meteo_daily_value(daily, 'temperature_2m_min', index)}~" f"{_open_meteo_daily_value(daily, 'temperature_2m_max', index)}℃," f"体感{_open_meteo_daily_value(daily, 'apparent_temperature_min', index)}~" @@ -1032,3 +1038,14 @@ def _format_open_meteo_day(daily: dict[str, object], index: int) -> str: f"主导风向{_open_meteo_daily_value(daily, 'wind_direction_10m_dominant', index)}°," f"最高紫外线指数{_open_meteo_daily_value(daily, 'uv_index_max', index)}" ) + + +def _open_meteo_weather_description(value: object) -> str: + descriptions = open_meteo_weather_code_descriptions() + if type(value) is int and value in descriptions: + return descriptions[value] + if type(value) is int: + _LOGGER.warning("Unknown Open-Meteo weather code code=%d", value) + else: + _LOGGER.warning("Invalid Open-Meteo weather code value_type=%s", type(value).__name__) + return "未识别天气现象"