diff --git a/README.md b/README.md index 50853df4..b3e078bc 100644 --- a/README.md +++ b/README.md @@ -98,4 +98,4 @@ docker run -d --name weather-briefing --restart unless-stopped \ - CI 使用 Gitleaks 扫描凭据,并运行自定义隐私哨兵测试。 - Telegram token 与 chat ID 只从运行环境读取;测试可选择 stdout publisher。 -地点解析使用 [Open-Meteo Geocoding](https://open-meteo.com/en/docs/geocoding-api);详细地名备用数据 © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright),通过 Nominatim 获取。公共 Nominatim 仅在本地缓存未命中时调用,并遵守其使用政策。 +花粉数据由 [Open-Meteo](https://open-meteo.com/en/docs/air-quality-api) 基于 [CAMS ENSEMBLE](https://confluence.ecmwf.int/spaces/CKB/pages/202173092/CAMS+Regional+European+air+quality+analysis+and+forecast+data+documentation) 提供。地点解析使用 [Open-Meteo Geocoding](https://open-meteo.com/en/docs/geocoding-api);详细地名备用数据 © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright),通过 Nominatim 获取。公共 Nominatim 仅在本地缓存未命中时调用,并遵守其使用政策。 diff --git a/docs/design.md b/docs/design.md index ea755b7f..7aa5a2f4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -48,7 +48,7 @@ CLI 根据 `BRIEFING_CRON` 判断当天最后一个 briefing 小时,并在对 `AirQualitySupplementingWeatherProvider` 在最终天气快照缺少空气质量时调用可选 `AQICNProvider`。AQICN 只返回空气质量,不会被当成天气来源;它保留美国 EPA AQI 和 PM2.5 单项 AQI,不反向折算 PM2.5 浓度。天气来源与 AQICN 都无法提供空气质量时抛出可操作错误。 -天气快照可选包含花粉过敏原信息。QWeather 生活指数请求过敏指数(类型 7),并将供应商返回的指数说明直接放入 `lifestyle_advice`。`OpenMeteoProvider` 在同一次空气质量 API 请求中额外请求花粉变量,按浓度分级(参考数据 `allergen_guidance.json`)转换为独立 `AllergenSnapshot`,供每日预报生活建议参考。Open-Meteo 花粉数据仅在欧洲花粉季可用,缺失或字段无效时不影响天气与空气质量结果。当前实现不把综合过敏指数解释为具体花粉种类,也不推断独立的杨絮、柳絮等飞絮信息。 +天气快照可选包含花粉过敏原信息。QWeather 生活指数请求过敏指数(类型 7),并将供应商返回的指数说明直接放入 `lifestyle_advice`。`OpenMeteoProvider` 在同一次空气质量 API 请求中额外请求花粉变量,按浓度分级(参考数据 `allergen_guidance.json`)转换为独立 `AllergenSnapshot`,供每日预报生活建议参考。Open-Meteo 花粉数据仅在欧洲花粉季可用,来自 CAMS European Air Quality forecast 的 ENSEMBLE 数据;引用该上下文时,渲染层使用文档名称确定性展示 Open-Meteo 与 CAMS ENSEMBLE 署名。缺失或字段无效时不影响天气与空气质量结果。当前实现不把综合过敏指数解释为具体花粉种类,也不推断独立的杨絮、柳絮等飞絮信息。 QWeather 认证由独立的 `QWeatherJWTAuthenticator` 负责。它从运行环境读取 Base64 编码的 Ed25519 PKCS#8 私钥 PEM,解码后通过 EdDSA 签发 JWT:Header 只加入凭据 ID `kid`,Payload 只加入项目 ID `sub`、提前 30 秒的 `iat` 和可配置的短期 `exp`。provider 只依赖认证协议生成 Bearer header,不接触私钥字段,也不支持把长期 API KEY 混入同一请求。 diff --git a/tests/test_render.py b/tests/test_render.py index a68296f2..02f8f6d8 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,6 +1,6 @@ import pendulum -from weather_briefing.models import Article, BriefingResult, Conclusion, Warning +from weather_briefing.models import Article, BriefingResult, Conclusion, SourceDocument, Warning from weather_briefing.render import PlainTextRenderer, TelegramHTMLRenderer @@ -38,6 +38,46 @@ def test_plain_text_renderer_uses_the_same_structured_briefing() -> None: assert "" not in rendered.body +def test_telegram_html_renderer_uses_context_source_name_as_attribution() -> None: + context = SourceDocument( + "allergen:open-meteo", + "Open-Meteo / CAMS ENSEMBLE 花粉过敏原", + "https://open-meteo.com/en/docs/air-quality-api", + "Pollen data", + ) + result = BriefingResult( + "Daily", + "Overview", + (), + advice=(Conclusion("花粉浓度较高", ("allergen:open-meteo",)),), + ) + + rendered = TelegramHTMLRenderer().render_briefing(result, (), (context,)) + + assert ( + 'Open-Meteo / CAMS ENSEMBLE 花粉过敏原' + ) in rendered.body + + +def test_plain_text_renderer_uses_context_source_name_as_attribution() -> None: + context = SourceDocument( + "allergen:open-meteo", + "Open-Meteo / CAMS ENSEMBLE 花粉过敏原", + "https://open-meteo.com/en/docs/air-quality-api", + "Pollen data", + ) + result = BriefingResult( + "Daily", + "Overview", + (), + advice=(Conclusion("花粉浓度较高", ("allergen:open-meteo",)),), + ) + + rendered = PlainTextRenderer().render_briefing(result, (), (context,)) + + assert ("Open-Meteo / CAMS ENSEMBLE 花粉过敏原: https://open-meteo.com/en/docs/air-quality-api") in rendered.body + + def test_telegram_html_renders_active_warnings_section() -> None: now = pendulum.datetime(2026, 7, 11, 8, tz="Asia/Shanghai") article = Article("source", "feed", "Feed", "Title", "https://example.invalid/a", now, "Body") diff --git a/tests/test_weather_context.py b/tests/test_weather_context.py index 1cb45a52..3190164c 100644 --- a/tests/test_weather_context.py +++ b/tests/test_weather_context.py @@ -1353,6 +1353,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert snapshot.air_quality is not None assert snapshot.allergen is not None assert snapshot.allergen.source_id == "allergen:open-meteo" + assert snapshot.allergen.source_name == "Open-Meteo / CAMS ENSEMBLE 花粉过敏原" level_names = {level.name for level in snapshot.allergen.levels} assert {"桤木", "桦木", "禾本"} == level_names birch = next(level for level in snapshot.allergen.levels if level.name == "桦木") diff --git a/weather_briefing/render.py b/weather_briefing/render.py index c90bbd0e..be106674 100644 --- a/weather_briefing/render.py +++ b/weather_briefing/render.py @@ -34,21 +34,21 @@ def render_briefing( reference_articles: tuple[Article, ...], context: tuple[SourceDocument, ...], ) -> RenderedMessage: - source_urls = {article.id: article.url for article in reference_articles} - source_urls.update({document.id: document.url for document in context}) + source_links = {article.id: _html_link(article.url) for article in reference_articles} + source_links.update({document.id: _html_link(document.url, document.name) for document in context}) lines = [f"{_html_text(result.headline)}", "", _html_text(result.overview), ""] if result.active_warnings: lines.extend(["当前生效的气象预警", ""]) for warning in result.active_warnings: - links = " ".join(_html_link(source_urls[source_id]) for source_id in warning.source_ids) + links = " ".join(source_links[source_id] for source_id in warning.source_ids) lines.append( f"• {_html_text(warning.title)}({_html_text(warning.status)}):" f"{_html_text(warning.detail)} {links}".rstrip() ) lines.append("") - lines.extend(_html_items("天气信息", result.conclusions, source_urls)) - lines.extend(_html_items("灾害动态", result.disaster_tracking, source_urls)) - lines.extend(_html_items("生活建议", result.advice, source_urls)) + lines.extend(_html_items("天气信息", result.conclusions, source_links)) + lines.extend(_html_items("灾害动态", result.disaster_tracking, source_links)) + lines.extend(_html_items("生活建议", result.advice, source_links)) return _html_message("\n".join(lines).strip()) def render_verbatim(self, article: Article) -> RenderedMessage: @@ -73,18 +73,18 @@ def render_briefing( reference_articles: tuple[Article, ...], context: tuple[SourceDocument, ...], ) -> RenderedMessage: - source_urls = {article.id: article.url for article in reference_articles} - source_urls.update({document.id: document.url for document in context}) + source_references = {article.id: article.url for article in reference_articles} + source_references.update({document.id: f"{document.name}: {document.url}" for document in context}) lines = [result.headline, "", result.overview, ""] if result.active_warnings: lines.extend(["当前生效的气象预警", ""]) for warning in result.active_warnings: - sources = " ".join(source_urls[item] for item in warning.source_ids) + sources = " ".join(source_references[item] for item in warning.source_ids) lines.append(f"- {warning.title}({warning.status}):{warning.detail} {sources}".rstrip()) lines.append("") - lines.extend(_plain_items("天气信息", result.conclusions, source_urls)) - lines.extend(_plain_items("灾害动态", result.disaster_tracking, source_urls)) - lines.extend(_plain_items("生活建议", result.advice, source_urls)) + lines.extend(_plain_items("天气信息", result.conclusions, source_references)) + lines.extend(_plain_items("灾害动态", result.disaster_tracking, source_references)) + lines.extend(_plain_items("生活建议", result.advice, source_references)) return _plain_message("\n".join(lines).strip()) def render_verbatim(self, article: Article) -> RenderedMessage: @@ -98,27 +98,27 @@ def _html_text(value: str) -> str: return escape(unescape(value), quote=False) -def _html_link(url: str) -> str: - return f'来源' +def _html_link(url: str, label: str = "来源") -> str: + return f'{_html_text(label)}' -def _html_items(title: str, items: tuple[Conclusion, ...], source_urls: dict[str, str]) -> list[str]: +def _html_items(title: str, items: tuple[Conclusion, ...], source_links: dict[str, str]) -> list[str]: if not items: return [] lines = [f"{_html_text(title)}", ""] for item in items: - links = " ".join(_html_link(source_urls[source_id]) for source_id in item.source_ids) + links = " ".join(source_links[source_id] for source_id in item.source_ids) lines.append(f"• {_html_text(item.text)} {links}".rstrip()) lines.append("") return lines -def _plain_items(title: str, items: tuple[Conclusion, ...], source_urls: dict[str, str]) -> list[str]: +def _plain_items(title: str, items: tuple[Conclusion, ...], source_references: dict[str, str]) -> list[str]: if not items: return [] lines = [title, ""] for item in items: - sources = " ".join(source_urls[source_id] for source_id in item.source_ids) + sources = " ".join(source_references[source_id] for source_id in item.source_ids) lines.append(f"- {item.text} {sources}".rstrip()) lines.append("") return lines diff --git a/weather_briefing/weather_context.py b/weather_briefing/weather_context.py index 7fc023f2..50e5bae3 100644 --- a/weather_briefing/weather_context.py +++ b/weather_briefing/weather_context.py @@ -508,7 +508,7 @@ def _parse_allergen( ) return AllergenSnapshot( source_id="allergen:open-meteo", - source_name="Open-Meteo 花粉过敏原", + source_name="Open-Meteo / CAMS ENSEMBLE 花粉过敏原", source_url="https://open-meteo.com/en/docs/air-quality-api", observed_at=observed_at, levels=tuple(levels),