fix: harden weather and news plugins (#3465)

* fix: harden weather and news plugins

* 修掉天气新闻插件的三处鲁棒性缺陷

城市查询不再写死国内范围,境外城市也能查到。实时天气和七天预报要都拿到才走 JSON,缺一个就整体退回网页路径,不产出残缺报告。网页抓取加上跟随跳转,旧链接的 302 也能拿到内容。城市查不到时不再让用户去核对地名,多数情况是密钥失效。报告补上气压和降水量。新闻 URL 拼接抽成纯函数,配了 23 项单测。

---------

Co-authored-by: hrz <1710360675@qq.com>
This commit is contained in:
Jacobandhrz authored and GitHub committed 2026-10-29 09:46:01 +08:00
1 parent 8c5330f742
commit 15d9b0fba1
3 files changed
+504 -45

No files matched your search

@@ -1,6 +1,7 @@
import random
import httpx
from io import BytesIO
from urllib.parse import quote, urlsplit, urlunsplit, parse_qsl
from markitdown import MarkItDown, StreamInfo
from config.logger import setup_logging
from plugins_func.register import register_function, ToolType, ActionResponse, Action
@@ -54,6 +55,27 @@ CHANNEL_MAP = {
# 默认新闻来源字典,当配置中没有指定时使用
DEFAULT_NEWS_SOURCES = "澎湃新闻;百度热搜;财联社"
DEFAULT_NEWS_API_URL = "https://newsnow.busiyi.world/api/s?id="
def _build_news_api_url(source, configured_url=None):
"""拼出新闻接口的请求地址和查询参数。
配置里带 {source} 占位符就替换占位符;否则把 id 设成新闻源 id,
这样配置里已经写了 id 时也不会拼出脏串。
"""
api_url = str(configured_url).strip() if configured_url else DEFAULT_NEWS_API_URL
if "{source}" in api_url:
return api_url.replace("{source}", quote(source, safe="")), None
parts = urlsplit(api_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["id"] = source
return (
urlunsplit((parts.scheme, parts.netloc, parts.path, "", parts.fragment)),
query,
)
def _get_newsnow_config(conn):
# 从连接配置获取
plugins = conn.config.get("plugins", {})
@@ -61,6 +83,8 @@ def _get_newsnow_config(conn):
sources = newsnow.get("news_sources", "")
if isinstance(sources, str) and sources.strip():
return sources
if isinstance(sources, (list, tuple)):
return ";".join(str(item).strip() for item in sources if str(item).strip())
return ""
@@ -113,23 +137,26 @@ GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = {
async def fetch_news_from_api(conn: "ConnectionHandler", source="thepaper"):
"""从API获取新闻列表"""
try:
api_url = f"https://newsnow.busiyi.world/api/s?id={source}"
news_config = conn.config.get("plugins", {}).get("get_news_from_newsnow", {})
if news_config.get("url"):
api_url = news_config["url"] + source
configured_url = news_config.get("url") if isinstance(news_config, dict) else None
api_url, request_params = _build_news_api_url(source, configured_url)
headers = {"User-Agent": "Mozilla/5.0"}
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
response = await client.get(api_url, headers=headers)
response = await client.get(api_url, params=request_params, headers=headers)
if response.status_code != 200:
logger.bind(tag=TAG).error(
f"获取新闻API失败:HTTP {response.status_code},source={source}"
)
return []
data = response.json()
if "items" in data:
return data["items"]
else:
items = data.get("items") if isinstance(data, dict) else None
if not isinstance(items, list):
logger.bind(tag=TAG).error(f"获取新闻API响应格式错误: {data}")
return []
return [item for item in items if isinstance(item, dict) and item.get("title")]
except Exception as e:
logger.bind(tag=TAG).error(f"获取新闻API失败: {e}")
@@ -239,18 +266,24 @@ async def get_news_from_newsnow(
english_source_id = None
# 检查输入的中文名称是否在配置的新闻源中
news_sources_list = [
name.strip() for name in news_sources.split(";") if name.strip()
]
news_sources_list = [name.strip() for name in news_sources.split(";") if name.strip()]
if source in news_sources_list:
# 如果输入的中文名称在配置的新闻源中,在 CHANNEL_MAP 中查找对应的英文ID
english_source_id = CHANNEL_MAP.get(source)
elif not source and news_sources_list:
source = news_sources_list[0]
english_source_id = CHANNEL_MAP.get(source)
# 如果找不到对应的英文ID,使用默认源
if not english_source_id:
logger.bind(tag=TAG).warning(f"无效的新闻源: {source},使用默认源澎湃新闻")
english_source_id = "thepaper"
source = "澎湃新闻"
fallback_source = next(
(name for name in news_sources_list if CHANNEL_MAP.get(name)),
"澎湃新闻",
)
logger.bind(tag=TAG).warning(
f"无效的新闻源: {source},使用配置中的默认源{fallback_source}"
)
english_source_id = CHANNEL_MAP[fallback_source]
source = fallback_source
logger.bind(tag=TAG).info(f"获取新闻: 新闻源={source}({english_source_id})")
@@ -266,20 +299,21 @@ async def get_news_from_newsnow(
# 随机选择一条新闻
selected_news = random.choice(news_items)
title = selected_news.get("title", "未知标题")
# 保存当前新闻链接到连接对象,以便后续查询详情
if not hasattr(conn, "last_newsnow_link"):
conn.last_newsnow_link = {}
conn.last_newsnow_link = {
"url": selected_news.get("url", "#"),
"title": selected_news.get("title", "未知标题"),
"title": title,
"source_id": english_source_id,
}
# 构建新闻报告
news_report = (
f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n"
f"新闻标题: {selected_news['title']}\n"
f"新闻标题: {title}\n"
# f"新闻来源: {source}\n"
f"(请以自然、流畅的方式向用户播报这条新闻标题,"
f"提示用户可以要求获取详细内容,此时会获取新闻的详细内容。)"
@@ -1,3 +1,4 @@
import asyncio
import httpx
from bs4 import BeautifulSoup
from config.logger import setup_logging
@@ -112,20 +113,106 @@ WEATHER_CODE_MAP = {
async def fetch_city_info(location, api_key, api_host):
url = f"https://{api_host}/geo/v2/city/lookup?key={api_key}&location={location}&lang=zh"
url = f"https://{api_host}/geo/v2/city/lookup"
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client:
response = await client.get(url, headers=HEADERS)
data = response.json()
if data.get("error") is not None:
response = await client.get(
url,
params={
"key": api_key,
"location": location,
"lang": "zh",
"number": 1,
},
headers=HEADERS,
)
try:
data = response.json()
except ValueError:
logger.bind(tag=TAG).error(
f"获取天气失败,原因:{data.get('error', {}).get('detail')}"
f"获取天气城市失败:HTTP {response.status_code} 返回了非 JSON 内容"
)
return None
if not isinstance(data, dict):
logger.bind(tag=TAG).error("获取天气城市失败:响应不是 JSON 对象")
return None
if response.status_code != 200 or data.get("code") != "200":
logger.bind(tag=TAG).error(
f"获取天气城市失败:HTTP {response.status_code},代码 {data.get('code')}"
)
return None
return data.get("location", [])[0] if data.get("location") else None
async def fetch_weather_data(city_info, api_key, api_host):
location_id = city_info.get("id")
if not location_id:
return None
base_url = f"https://{api_host}/v7/weather"
params = {"key": api_key, "location": location_id, "lang": "zh"}
async def request(client, endpoint):
response = await client.get(f"{base_url}/{endpoint}", params=params, headers=HEADERS)
try:
data = response.json()
except ValueError:
return None
if response.status_code != 200 or data.get("code") != "200":
logger.bind(tag=TAG).error(
f"获取天气数据失败:接口={endpoint} HTTP {response.status_code},代码 {data.get('code')}"
)
return None
return data
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
now, daily = await asyncio.gather(
request(client, "now"), request(client, "7d")
)
except Exception as e:
logger.bind(tag=TAG).warning(f"获取天气 JSON 接口失败,将尝试网页接口: {e}")
return None
if now is None or daily is None:
return None
return now, daily
def build_weather_report(city_info, now_data, daily_data):
city_name = city_info.get("name") or city_info.get("adm2") or "未知位置"
now = now_data.get("now", {})
weather_report = f"您查询的位置是:{city_name}\n\n"
weather_report += f"当前天气: {now.get('text', '未知')}\n"
current_basic = {
"温度": f"{now['temp']}℃" if now.get("temp") is not None else None,
"体感温度": f"{now['feelsLike']}℃" if now.get("feelsLike") is not None else None,
"湿度": f"{now['humidity']}%" if now.get("humidity") is not None else None,
"风向": now.get("windDir"),
"风力": now.get("windScale"),
"能见度": f"{now['vis']}公里" if now.get("vis") is not None else None,
"气压": f"{now['pressure']}百帕" if now.get("pressure") is not None else None,
"降水量": f"{now['precip']}毫米" if now.get("precip") is not None else None,
}
current_basic = {key: value for key, value in current_basic.items() if value}
if current_basic:
weather_report += "详细参数:\n"
weather_report += "".join(f" · {key}: {value}\n" for key, value in current_basic.items())
weather_report += "\n未来7天预报:\n"
for item in daily_data.get("daily", [])[:7]:
date = item.get("fxDate", "未知日期")
weather = item.get("textDay") or WEATHER_CODE_MAP.get(item.get("iconDay"), "未知")
high = item.get("tempMax", "未知")
low = item.get("tempMin", "未知")
weather_report += f"{date}: {weather},气温 {low}~{high}℃\n"
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
return weather_report
async def fetch_weather_page(url):
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
async with httpx.AsyncClient(
timeout=httpx.Timeout(10.0, connect=3.0), follow_redirects=True
) as client:
response = await client.get(url, headers=HEADERS)
return BeautifulSoup(response.text, "html.parser") if response.status_code == 200 else None
@@ -201,29 +288,31 @@ async def get_weather(conn: "ConnectionHandler", location: str = None, lang: str
city_info = await fetch_city_info(location, api_key, api_host)
if not city_info:
return ActionResponse(
Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None
Action.REQLLM,
f"暂时查不到 {location} 的天气(城市未找到或天气服务不可用),请稍后再试",
None,
)
soup = await fetch_weather_page(city_info["fxLink"])
if not soup:
return ActionResponse(Action.REQLLM, None, "请求失败")
city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup)
weather_report = f"您查询的位置是:{city_name}\n\n当前天气: {current_abstract}\n"
# 添加有效的当前天气参数
if current_basic:
weather_report += "详细参数:\n"
for key, value in current_basic.items():
if value != "0": # 过滤无效值
weather_report += f" · {key}: {value}\n"
# 添加7天预报
weather_report += "\n未来7天预报:\n"
for date, weather, high, low in temps_list:
weather_report += f"{date}: {weather},气温 {low}~{high}\n"
# 提示语
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
weather_data = await fetch_weather_data(city_info, api_key, api_host)
if weather_data:
weather_report = build_weather_report(city_info, *weather_data)
else:
fx_link = city_info.get("fxLink")
soup = await fetch_weather_page(fx_link) if fx_link else None
if not soup:
return ActionResponse(Action.REQLLM, None, "请求天气数据失败")
city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup)
weather_report = f"您查询的位置是:{city_name}\n\n当前天气: {current_abstract}\n"
if current_basic:
weather_report += "详细参数:\n"
weather_report += "".join(
f" · {key}: {value}\n" for key, value in current_basic.items() if value != "0"
)
weather_report += "\n未来7天预报:\n"
weather_report += "".join(
f"{date}: {weather},气温 {low}~{high}\n"
for date, weather, high, low in temps_list
)
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
# 缓存完整的天气报告
cache_manager.set(CacheType.WEATHER, weather_cache_key, weather_report)
@@ -0,0 +1,336 @@
"""Tests for the weather and newsnow plugin helpers.
The two plugins talk to external APIs, so the HTTP layer is replaced with a
fake client and every request is recorded. Nothing here touches the network.
"""
import httpx
import pytest
from plugins_func.functions import get_news_from_newsnow as news_module
from plugins_func.functions import get_weather as weather_module
class FakeResponse:
def __init__(self, status_code=200, payload=None, text="", json_error=None):
self.status_code = status_code
self.payload = payload
self.text = text
self.json_error = json_error
def json(self):
if self.json_error is not None:
raise self.json_error
return self.payload
class FakeAsyncClient:
"""Stands in for httpx.AsyncClient and records what was requested."""
requests = []
responder = None
init_kwargs = {}
def __init__(self, *args, **kwargs):
FakeAsyncClient.init_kwargs = kwargs
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def get(self, url, params=None, headers=None):
FakeAsyncClient.requests.append({"url": url, "params": params})
return FakeAsyncClient.responder(url, params)
@pytest.fixture
def fake_http(monkeypatch):
"""Replace httpx.AsyncClient with the recording fake."""
FakeAsyncClient.requests = []
FakeAsyncClient.responder = lambda url, params: FakeResponse(payload={"code": "200"})
FakeAsyncClient.init_kwargs = {}
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
return FakeAsyncClient
class FakeConn:
def __init__(self, plugins=None):
self.config = {"plugins": plugins or {}}
self.client_ip = None
# ---------------------------------------------------------------------------
# 城市查询
# ---------------------------------------------------------------------------
async def test_city_lookup_returns_none_on_error_code(fake_http):
fake_http.responder = lambda url, params: FakeResponse(payload={"code": "402"})
result = await weather_module.fetch_city_info("杭州", "key", "host")
assert result is None
async def test_city_lookup_returns_none_on_non_json_body(fake_http):
fake_http.responder = lambda url, params: FakeResponse(
text="<html>oops</html>", json_error=ValueError("not json")
)
result = await weather_module.fetch_city_info("杭州", "key", "host")
assert result is None
async def test_city_lookup_returns_none_on_non_dict_body(fake_http):
fake_http.responder = lambda url, params: FakeResponse(payload=["not", "an", "object"])
result = await weather_module.fetch_city_info("杭州", "key", "host")
assert result is None
async def test_city_lookup_returns_none_on_empty_location(fake_http):
fake_http.responder = lambda url, params: FakeResponse(
payload={"code": "200", "location": []}
)
result = await weather_module.fetch_city_info("杭州", "key", "host")
assert result is None
async def test_city_lookup_searches_all_cities(fake_http):
"""不带 range 参数就是全球检索,写死 cn 会查不到境外城市。"""
fake_http.responder = lambda url, params: FakeResponse(
payload={"code": "200", "location": [{"id": "101210101", "name": "杭州"}]}
)
result = await weather_module.fetch_city_info("杭州", "key", "host")
assert result == {"id": "101210101", "name": "杭州"}
sent = fake_http.requests[0]["params"]
assert "range" not in sent
assert sent["location"] == "杭州"
assert sent["number"] == 1
# ---------------------------------------------------------------------------
# 天气数据
# ---------------------------------------------------------------------------
def _weather_responder(now_payload, daily_payload):
def responder(url, params):
if url.endswith("/now"):
return now_payload if isinstance(now_payload, FakeResponse) else FakeResponse(
payload=now_payload
)
return daily_payload if isinstance(daily_payload, FakeResponse) else FakeResponse(
payload=daily_payload
)
return responder
async def test_weather_data_needs_both_now_and_daily(fake_http):
fake_http.responder = _weather_responder(
{"code": "200", "now": {"text": "晴"}},
FakeResponse(status_code=401, payload={"code": "401"}),
)
assert await weather_module.fetch_weather_data({"id": "1"}, "key", "host") is None
async def test_weather_data_needs_daily_when_now_failed(fake_http):
fake_http.responder = _weather_responder(
FakeResponse(status_code=401, payload={"code": "401"}),
{"code": "200", "daily": [{"fxDate": "2026-10-24"}]},
)
assert await weather_module.fetch_weather_data({"id": "1"}, "key", "host") is None
async def test_weather_data_returns_both_when_all_succeed(fake_http):
fake_http.responder = _weather_responder(
{"code": "200", "now": {"text": "晴"}},
{"code": "200", "daily": [{"fxDate": "2026-10-24"}]},
)
now, daily = await weather_module.fetch_weather_data({"id": "1"}, "key", "host")
assert now["now"]["text"] == "晴"
assert daily["daily"][0]["fxDate"] == "2026-10-24"
async def test_weather_page_follows_redirects(fake_http):
"""旧形态 fxLink 会 302,不跟随重定向就拿不到内容。"""
fake_http.responder = lambda url, params: FakeResponse(text="<html></html>")
await weather_module.fetch_weather_page("https://www.qweather.com/weather/101210101.html")
assert fake_http.init_kwargs.get("follow_redirects") is True
def test_weather_report_keeps_pressure_and_precipitation():
report = weather_module.build_weather_report(
{"name": "杭州"},
{"now": {"text": "多云", "temp": "24", "pressure": "1003", "precip": "0.0"}},
{"daily": []},
)
assert "气压: 1003百帕" in report
assert "降水量: 0.0毫米" in report
async def test_weather_report_keeps_pressure_and_precipitation_in_flow(monkeypatch):
"""走完整条链路时报告里也要有气压和降水量。"""
monkeypatch.setattr(
weather_module,
"fetch_city_info",
_async_value({"id": "1", "name": "杭州"}),
)
monkeypatch.setattr(
weather_module,
"fetch_weather_data",
_async_value(
({"now": {"text": "多云", "pressure": "1003", "precip": "0.0"}}, {"daily": []})
),
)
response = await weather_module.get_weather(
FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}}), location="气压测试市"
)
assert "气压: 1003百帕" in response.result
assert "降水量: 0.0毫米" in response.result
async def test_missing_fxlink_does_not_call_web_fallback(monkeypatch):
"""城市信息里没有 fxLink 时不该去抓网页,直接报失败。"""
called = []
async def fake_city_info(*args, **kwargs):
return {"id": "1", "name": "杭州"}
async def fake_weather_data(*args, **kwargs):
return None
async def fake_page(url):
called.append(url)
return None
monkeypatch.setattr(weather_module, "fetch_city_info", fake_city_info)
monkeypatch.setattr(weather_module, "fetch_weather_data", fake_weather_data)
monkeypatch.setattr(weather_module, "fetch_weather_page", fake_page)
response = await weather_module.get_weather(
FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}}), location="无链接市"
)
assert called == []
assert response.response == "请求天气数据失败"
async def test_city_not_found_message_does_not_blame_the_place_name(monkeypatch):
"""key 失效和城市找不到都落到这句话,不能让用户去核对地名。"""
async def fake_city_info(*args, **kwargs):
return None
monkeypatch.setattr(weather_module, "fetch_city_info", fake_city_info)
response = await weather_module.get_weather(
FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}}), location="随便什么市"
)
assert "请确认地点是否正确" not in response.result
assert "天气服务不可用" in response.result
# ---------------------------------------------------------------------------
# 新闻接口
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"configured_url, expected_url, expected_params",
[
(None, "https://newsnow.busiyi.world/api/s", {"id": "thepaper"}),
(
"https://newsnow.busiyi.world/api/s?id=thepaper",
"https://newsnow.busiyi.world/api/s",
{"id": "thepaper"},
),
(
"https://newsnow.busiyi.world/api/s?id={source}",
"https://newsnow.busiyi.world/api/s?id=thepaper",
None,
),
(
"https://newsnow.busiyi.world/api/s",
"https://newsnow.busiyi.world/api/s",
{"id": "thepaper"},
),
(
"https://newsnow.busiyi.world/api/s?id=",
"https://newsnow.busiyi.world/api/s",
{"id": "thepaper"},
),
(
"https://newsnow.busiyi.world/api/s?id=thepaper&limit=5",
"https://newsnow.busiyi.world/api/s",
{"id": "thepaper", "limit": "5"},
),
],
)
def test_news_api_url_forms(configured_url, expected_url, expected_params):
url, params = news_module._build_news_api_url("thepaper", configured_url)
assert url == expected_url
assert params == expected_params
async def test_news_api_url_forms_are_actually_requested(fake_http):
"""配置里写了 id 也不能拼出脏串。"""
fake_http.responder = lambda url, params: FakeResponse(
payload={"items": [{"id": "1", "title": "标题"}]}
)
conn = FakeConn({"get_news_from_newsnow": {"url": "https://newsnow.busiyi.world/api/s?id=thepaper"}})
await news_module.fetch_news_from_api(conn, "thepaper")
assert fake_http.requests[0]["url"] == "https://newsnow.busiyi.world/api/s"
assert fake_http.requests[0]["params"] == {"id": "thepaper"}
async def test_news_items_must_be_a_list(fake_http):
fake_http.responder = lambda url, params: FakeResponse(payload={"items": "oops"})
assert await news_module.fetch_news_from_api(FakeConn(), "thepaper") == []
async def test_news_items_without_title_are_filtered_out(fake_http):
fake_http.responder = lambda url, params: FakeResponse(
payload={"items": [{"id": "1"}, {"id": "2", "title": ""}]}
)
assert await news_module.fetch_news_from_api(FakeConn(), "thepaper") == []
async def test_news_items_keep_valid_entries(fake_http):
fake_http.responder = lambda url, params: FakeResponse(
payload={"items": [{"id": "1", "title": ""}, {"id": "2", "title": "有效标题"}]}
)
items = await news_module.fetch_news_from_api(FakeConn(), "thepaper")
assert items == [{"id": "2", "title": "有效标题"}]
def _async_value(value):
async def _inner(*args, **kwargs):
return value
return _inner