Compare commits

...
Author SHA1 Message Date
hrz aee4a3cf7a 文档改成只讲单模块这侧的凭据来源
上一版把两个来源混成一句,说智控台那边的默认值也是空的,那要等数据库变更脚本跑完才成立,容易被当成现状。这版只讲单模块部署:凭据从服务器配置的 plugins.get_weather 读,仓库模板是空的;历史脚本里那把旧 key 已失效,不要用也不要照着填。
2026-10-24 06:06:28 +00:00
hrz 824d2b4837 文档讲清历史脚本里那把失效的旧 key
仓库历史的数据库变更脚本里还留着一把已经失效的旧 key,不能用也不该照着填,但那些脚本已经被现网库执行过,改内容会让存量库启动报错,所以原样留着。文档里把这件事说清楚,也说明当前读凭据的两个地方都是空的,那把旧 key 不在代码路径上。
2026-10-24 05:50:23 +00:00
hrz 4140981a8a 凭据提示原样念出来,日期按北京时间算
评审提的几条一起改:观测时间和数据更新时间分开标注,网页那条路也把观测时间解析出来;「今天」按北京时间算,不跟服务器时区走;失败路径不再传那个读不到的参数。

凭据缺失时改成直接回复,服务端日志也留一条带文档路径的告警,开发者看日志能定位。
2026-10-24 01:19:06 +00:00
hrz 65e18712e6 天气插件不再内置和风天气凭据
仓库里那个和风天气的密钥不再预置,配置文件和代码里的默认值都清空了,没配凭据时直接提示用户自己去申请。

顺带修了几处:网页结构变了取不到内容时给中性提示,不再把Python的报错原文念给用户;接口和网页两条路的日期、单位统一成一种写法;报告加上观测时间;网页兜底只等5秒,失败结果记5分钟,不用每次都等二十几秒。

文档补了申请流程和要勾的三项权限。
2026-10-24 00:30:22 +00:00
hrz 63f3860f29 修掉天气新闻插件的三处鲁棒性缺陷
城市查询不再写死国内范围,境外城市也能查到。实时天气和七天预报要都拿到才走 JSON,缺一个就整体退回网页路径,不产出残缺报告。网页抓取加上跟随跳转,旧链接的 302 也能拿到内容。城市查不到时不再让用户去核对地名,多数情况是密钥失效。报告补上气压和降水量。新闻 URL 拼接抽成纯函数,配了 23 项单测。
2026-10-23 23:32:30 +08:00
Jacob 34cba2f6a5 fix: harden weather and news plugins 2026-10-23 15:35:19 +08:00
6 changed files with 1276 additions and 60 deletions

No files matched your search

+16
View File
@@ -4,6 +4,22 @@
天气插件 `get_weather` 是小智ESP32语音助手的核心功能之一,支持通过语音查询全国各地的天气信息。插件基于和风天气API,提供实时天气和7天天气预报功能。
## 仓库不再预置凭据
单模块部署的时候,天气的凭据从服务器配置里的 `plugins.get_weather` 读。仓库里的模板 `main/xiaozhi-server/config.yaml` 这一节是空的,得自己申请一套填进去。插件读不到 `api_key` 或 `api_host` 的时候会直接提示还没配置,不会拿公共账号去请求。
仓库历史的数据库变更脚本(manager-api 的 changelog 目录里)还残留着一把**已经失效**的旧 key。那把 key 早就失效了,不要使用它,也不要照着它去填。那几个脚本已经被现网库执行过,改内容会让存量库启动时报校验不一致,所以原样留着。
创建凭据时要勾选三项权限,少勾哪一项都取不到对应的数据:
| 权限 | 用来做什么 |
| --- | --- |
| GeoAPI 城市查询 | 把地名换成城市编号 |
| 实时天气 | 当前的温度、风力、湿度这些 |
| 7 天天气预报 | 未来几天的预报 |
三项里少勾一两个,JSON 接口就取不到完整的数据,天气只能退到网页上抓,显示的内容比接口给的少。
## API Key 申请指南
### 1. 注册和风天气账号
+6 -5
View File
@@ -130,14 +130,15 @@ context_providers:
# 插件的基础配置
plugins:
# 获取天气插件的配置,这里填写你的api_key
# 这个密钥是项目共用的key,用多了可能会被限制
# 想稳定一点就自行申请替换,每天有1000次免费调用
# 获取天气插件的配置,这里填写你自己的api_key和apihost
# 仓库里不再预置凭据,申请流程写在 docs/weather-integration.md
# 申请地址:https://console.qweather.com/#/apps/create-key/over
# 申请后通过这个链接可以找到自己的apihost:https://console.qweather.com/setting?lang=zh
# 建key时要勾选三项权限:GeoAPI城市查询、实时天气、7天天气预报
# 只勾一两个的话JSON接口取不到数据,天气只能退到网页上抓
get_weather:
api_host: "mj7p3y7naa.re.qweatherapi.com"
api_key: "a861d0d5e7bf4ee1a83d9a9e4f96d4da"
api_host: ""
api_key: ""
default_location: "广州"
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
@@ -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,6 @@
import asyncio
import datetime
import re
import httpx
from bs4 import BeautifulSoup
from config.logger import setup_logging
@@ -44,6 +47,41 @@ HEADERS = {
)
}
# 仓库里不再预置和风天气的凭据,自己部署的人得自己去申请一个
WEATHER_CREDENTIAL_HINT = (
"尚未配置和风天气 api_key,请参考 docs/weather-integration.md 申请后填入"
)
WEATHER_HOST_HINT = (
"尚未配置和风天气 api_host,请参考 docs/weather-integration.md 申请后填入"
)
# 上游挂着的时候每次提问都要把整条链路重走一遍,用户要等二十几秒。
# 失败结果短时间内不会变,先记下来直接回
WEATHER_FAILURE_TTL = 300
WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
# 报告里的「今天」按北京时间算。默认部署的服务器时区是 UTC,国内用户北京时间
# 凌晨到早上八点这段时间服务器还在昨天,「今天」会说错一整天。
# 时区自己造,不依赖 zoneinfo:容器和 Windows 上不一定有 tzdata
BEIJING_TZ = datetime.timezone(datetime.timedelta(hours=8))
# 网页上写的是符号单位,报告里统一用中文,两条路径读出来的东西才对得上
UNIT_NAMES = {
"km/h": "公里/小时",
"km": "公里",
"mm": "毫米",
"hpa": "百帕",
"°": "℃",
"°c": "℃",
}
# 网页上的参数名和接口返回的不一样,报告里用接口那套
ITEM_NAMES = {
"大气压": "气压",
"相对湿度": "湿度",
}
# 天气代码 https://dev.qweather.com/docs/resource/icons/#weather-icons
WEATHER_CODE_MAP = {
"100": "晴",
@@ -111,27 +149,264 @@ WEATHER_CODE_MAP = {
}
def format_observation_time(raw_time):
"""观测时间接口给的是带 T 的写法,报告里换成空格好看点"""
if not raw_time:
return None
return str(raw_time).replace("T", " ", 1)
def beijing_today(now=None):
"""
北京时间今天是哪天
<p>
服务器本地时区不能拿来当准:默认部署写的是 UTC,国内用户北京时间凌晨到
早上八点这段时间,服务器还在昨天,「今天」会说错一整天。
"""
if now is None:
now = datetime.datetime.now(datetime.timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=datetime.timezone.utc)
return now.astimezone(BEIJING_TZ).date()
def format_forecast_date(raw_date, today=None):
"""
预报日期统一成 今天/明天/后天 或者 星期加月日
<p>
接口给的是 2026-10-24 这种写法,网页上写的是 今天10月24日,
两条路径各写各的,用户听不出来是同一份数据。统一成一种写法。
"""
if not raw_date:
return raw_date
text = str(raw_date).strip()
today = today or beijing_today()
iso = re.fullmatch(r"(\d{4})-(\d{1,2})-(\d{1,2})", text)
if iso:
year = int(iso.group(1))
month = int(iso.group(2))
day = int(iso.group(3))
else:
found = re.search(r"(\d{1,2})月(\d{1,2})日", text)
if not found:
return text
year = today.year
month = int(found.group(1))
day = int(found.group(2))
try:
date_value = datetime.date(year, month, day)
except ValueError:
return text
delta = (date_value - today).days
if delta == 0:
prefix = "今天"
elif delta == 1:
prefix = "明天"
elif delta == 2:
prefix = "后天"
else:
prefix = WEEKDAY_NAMES[date_value.weekday()]
return f"{prefix}{month}月{day}日"
def normalize_unit(value):
"""把单位符号换成中文写法,换不动就原样留着"""
if value is None:
return None
text = str(value).strip()
if not text:
return text
matched = re.fullmatch(r"(.+?)([a-zA-Z°℃/]+)", text)
if not matched:
return text
unit = matched.group(2).lower()
if unit not in UNIT_NAMES:
return text
return f"{matched.group(1)}{UNIT_NAMES[unit]}"
def normalize_temperature(value):
"""温度只留数值,单位在报告里统一写一次"""
if value is None:
return None
matched = re.match(r"-?\d+(?:\.\d+)?", str(value).strip())
return matched.group(0) if matched else None
def normalize_wind_scale(value):
"""风力统一成 3级 这种带单位的写法"""
if value is None:
return None
text = str(value).strip()
if not text or text.endswith("级"):
return text
return f"{text}级"
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(8.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 format_weather_report(
city_name,
current_text,
current_basic,
temps_list,
observed_at=None,
data_updated_at=None,
):
"""两条路径的报告走同一个模板,日期和单位的写法才对得上"""
weather_report = f"您查询的位置是:{city_name}\n\n当前天气: {current_text}\n"
if observed_at:
weather_report += f"观测时间:{observed_at}\n"
elif data_updated_at:
# 顶层那个是接口的数据更新时间,不是观测时间,标签得说清楚
weather_report += f"数据更新时间:{data_updated_at}\n"
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"
weather_report += "".join(
f"{date}: {weather},气温 {low}~{high}℃\n"
for date, weather, high, low in temps_list
)
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
return weather_report
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", {})
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"),
"风力": normalize_wind_scale(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}
temps_list = []
for item in daily_data.get("daily", [])[:7]:
date = format_forecast_date(item.get("fxDate") or "未知日期")
weather = item.get("textDay") or WEATHER_CODE_MAP.get(item.get("iconDay"), "未知")
high = normalize_temperature(item.get("tempMax")) or "未知"
low = normalize_temperature(item.get("tempMin")) or "未知"
temps_list.append((date, weather, high, low))
# 观测时间在 now.obsTime 里,没有的时候才用响应顶层那个数据更新时间,
# 两个不是一个意思,标签得分开写
observed_at = format_observation_time(now.get("obsTime"))
data_updated_at = format_observation_time(now_data.get("updateTime"))
return format_weather_report(
city_name,
now.get("text", "未知"),
current_basic,
temps_list,
observed_at,
data_updated_at,
)
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(5.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
def parse_weather_info(soup):
city_name = soup.select_one("h1.c-submenu__location").get_text(strip=True)
"""
解析和风天气网页上的天气
<p>
页面的结构一变,取不到元素就返回 None。抛异常的话 Python 的原文
会被当成回复内容念给用户,听着像系统在报错。
"""
if soup is None:
return None
city_element = soup.select_one("h1.c-submenu__location")
if city_element is None:
logger.bind(tag=TAG).warning("网页天气解析失败:取不到城市名")
return None
city_name = city_element.get_text(strip=True)
current_abstract = soup.select_one(".c-city-weather-current .current-abstract")
current_abstract = (
@@ -145,20 +420,48 @@ def parse_weather_info(soup):
parts = item.get_text(strip=True, separator=" ").split(" ")
if len(parts) == 2:
key, value = parts[1], parts[0]
# 风向和风力挤在一项里,拆成两项跟接口那套对上
if key.endswith("风") and value.endswith("级"):
current_basic["风向"] = key
current_basic["风力"] = value
continue
key = ITEM_NAMES.get(key, key)
value = normalize_unit(value)
if not value or value == "0":
continue
current_basic[key] = value
# 网页上标的当前时间,取不到就当作没有。页面结构变了不该抛异常
observed_at = None
time_element = soup.select_one(".current-weather__bg .current-time")
if time_element is not None:
observed_at = time_element.get_text(strip=True) or None
temps_list = []
for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据
date = row.select_one(".date-bg .date").get_text(strip=True)
weather_code = (
row.select_one(".date-bg .icon")["src"].split("/")[-1].split(".")[0]
)
date_element = row.select_one(".date-bg .date")
icon_element = row.select_one(".date-bg .icon")
if date_element is None or icon_element is None:
logger.bind(tag=TAG).warning("网页天气解析失败:预报行缺日期或图标")
return None
icon_src = icon_element.get("src")
if not icon_src:
logger.bind(tag=TAG).warning("网页天气解析失败:预报行图标没有地址")
return None
weather_code = icon_src.split("/")[-1].split(".")[0]
weather = WEATHER_CODE_MAP.get(weather_code, "未知")
temps = [span.get_text(strip=True) for span in row.select(".tmp-cont .temp")]
high_temp, low_temp = (temps[0], temps[-1]) if len(temps) >= 2 else (None, None)
temps_list.append((date, weather, high_temp, low_temp))
temps_list.append(
(
format_forecast_date(date_element.get_text(strip=True)),
weather,
normalize_temperature(high_temp) or "未知",
normalize_temperature(low_temp) or "未知",
)
)
return city_name, current_abstract, current_basic, temps_list
return city_name, current_abstract, current_basic, temps_list, observed_at
@register_function("get_weather", GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL)
@@ -166,11 +469,26 @@ async def get_weather(conn: "ConnectionHandler", location: str = None, lang: str
from core.utils.cache.manager import cache_manager, CacheType
weather_config = conn.config.get("plugins", {}).get("get_weather", {})
api_host = weather_config.get("api_host", "mj7p3y7naa.re.qweatherapi.com")
api_key = weather_config.get("api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da")
api_key = str(weather_config.get("api_key") or "").strip()
api_host = str(weather_config.get("api_host") or "").strip()
default_location = weather_config.get("default_location", "广州")
client_ip = conn.client_ip
# 凭据没配就没法取数据,直接告诉用户去申请,别拿空 key 去撞接口。
# 这句话要让开发者原样听到:走 LLM 转述的话,文档路径会被转述掉
if not api_key:
logger.bind(tag=TAG).warning(
"未配置和风天气 api_key,天气插件没法取数据。"
"申请和填写步骤见 docs/weather-integration.md 的「API Key 申请指南」"
)
return ActionResponse(Action.RESPONSE, None, WEATHER_CREDENTIAL_HINT)
if not api_host:
logger.bind(tag=TAG).warning(
"未配置和风天气 api_host,天气插件没法取数据。"
"申请和填写步骤见 docs/weather-integration.md 的「API Key 申请指南」"
)
return ActionResponse(Action.RESPONSE, None, WEATHER_HOST_HINT)
# 优先使用用户提供的location参数
if not location:
# 通过客户端IP解析城市
@@ -193,37 +511,40 @@ async def get_weather(conn: "ConnectionHandler", location: str = None, lang: str
location = default_location
# 尝试从缓存获取完整天气报告
weather_cache_key = f"full_weather_{location}_{lang}"
failure_cache_key = f"failed_weather_{location}_{lang}"
cached_weather_report = cache_manager.get(CacheType.WEATHER, weather_cache_key)
if cached_weather_report:
return ActionResponse(Action.REQLLM, cached_weather_report, None)
# 刚失败过就不要再走一遍链路,用户等不起
cached_failure = cache_manager.get(CacheType.WEATHER, failure_cache_key)
if cached_failure:
return ActionResponse(Action.REQLLM, cached_failure, None)
# 缓存未命中,获取实时天气数据
city_info = await fetch_city_info(location, api_key, api_host)
if not city_info:
return ActionResponse(
Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None
message = f"暂时查不到 {location} 的天气(城市未找到或天气服务不可用),请稍后再试"
cache_manager.set(
CacheType.WEATHER, failure_cache_key, message, ttl=WEATHER_FAILURE_TTL
)
return ActionResponse(Action.REQLLM, message)
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
parsed = parse_weather_info(soup)
if not parsed:
message = "暂时查不到天气数据,请稍后再试"
cache_manager.set(
CacheType.WEATHER, failure_cache_key, message, ttl=WEATHER_FAILURE_TTL
)
return ActionResponse(Action.REQLLM, message)
city_name, current_abstract, current_basic, temps_list, observed_at = parsed
weather_report = format_weather_report(
city_name, current_abstract, current_basic, temps_list, observed_at
)
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(如需某一天的具体天气,请告诉我日期)"
# 缓存完整的天气报告
cache_manager.set(CacheType.WEATHER, weather_cache_key, weather_report)
@@ -0,0 +1,507 @@
"""天气插件的凭据、健壮性和报告格式测试。
接口一律换成假客户端,不碰网络。
"""
import datetime
import os
import time
from datetime import date as _date
from datetime import timedelta as _timedelta
from datetime import timezone as _timezone
import httpx
import pytest
from bs4 import BeautifulSoup
from plugins_func.functions import get_weather as weather_module
from plugins_func.register import Action
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self.payload = payload
self.text = text
def json(self):
return self.payload
class FakeAsyncClient:
"""顶替 httpx.AsyncClient,把建客户端时的参数记下来"""
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):
FakeAsyncClient.requests = []
FakeAsyncClient.responder = lambda url, params: FakeResponse(payload={"code": "200"})
FakeAsyncClient.init_kwargs = {}
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
return FakeAsyncClient
@pytest.fixture(autouse=True)
def clear_weather_cache():
"""天气缓存是全局的,清一下免得上一条用例的结果串到下一条"""
from core.utils.cache.manager import cache_manager, CacheType
cache_manager.clear(CacheType.WEATHER)
yield
cache_manager.clear(CacheType.WEATHER)
class FakeConn:
def __init__(self, plugins=None):
self.config = {"plugins": plugins or {}}
self.client_ip = None
def _today():
return datetime.date.today()
def _web_page(date_text, temp_high="13°", temp_low="9°", current_time=None):
"""照着和风天气网页的样子拼一段,解析要的就是这些结构和写法"""
time_block = (
f'<div class="current-weather__bg"><span class="current-time">{current_time}</span></div>'
if current_time
else ""
)
return f"""
<html>
<h1 class="c-submenu__location">杭州市</h1>
<div class="c-city-weather-current">
<div class="current-abstract">多云</div>
<div class="current-basic">
<div class="current-basic___item">4级 西北风</div>
<div class="current-basic___item">97% 相对湿度</div>
<div class="current-basic___item">13° 体感温度</div>
<div class="current-basic___item">9km 能见度</div>
<div class="current-basic___item">0.0mm 降水量</div>
<div class="current-basic___item">1009hPa 大气压</div>
</div>
</div>
{time_block}
<div class="city-forecast-tabs">
<div class="city-forecast-tabs__row">
<div class="date-bg">
<span class="date">{date_text}</span>
<img class="icon" src="https://static.qweather.com/img/common/icon/202106d/306.png">
</div>
<div class="tmp-cont">
<span class="temp">{temp_high}</span>
<span class="temp">{temp_low}</span>
</div>
</div>
</div>
</html>
"""
# ---------------------------------------------------------------------------
# 凭据
# ---------------------------------------------------------------------------
class RecordingLogger:
"""把日志收下来,好断言告警里带着文档路径"""
def __init__(self):
self.messages = []
def bind(self, **kwargs):
return self
def _record(self, message, *args, **kwargs):
self.messages.append(str(message))
warning = _record
error = _record
info = _record
debug = _record
async def test_missing_api_key_points_at_the_application_doc():
response = await weather_module.get_weather(
FakeConn({"get_weather": {"api_host": "h"}}), location="没配密钥市"
)
assert response.action == Action.RESPONSE
assert response.response == (
"尚未配置和风天气 api_key,请参考 docs/weather-integration.md 申请后填入"
)
async def test_missing_api_host_points_at_the_application_doc():
response = await weather_module.get_weather(
FakeConn({"get_weather": {"api_key": "k"}}), location="没配主机市"
)
assert response.action == Action.RESPONSE
assert response.response == (
"尚未配置和风天气 api_host,请参考 docs/weather-integration.md 申请后填入"
)
async def test_blank_api_key_counts_as_missing():
response = await weather_module.get_weather(
FakeConn({"get_weather": {"api_key": " ", "api_host": "h"}}), location="空密钥市"
)
assert "尚未配置和风天气 api_key" in response.response
async def test_missing_credentials_are_logged_with_the_doc_path(monkeypatch):
"""日志里得写清楚缺哪一项、去哪儿申请,开发看日志就能定位"""
recorder = RecordingLogger()
monkeypatch.setattr(weather_module, "logger", recorder)
await weather_module.get_weather(
FakeConn({"get_weather": {"api_host": "h"}}), location="没配密钥市"
)
assert recorder.messages, "凭据缺失时一条日志都没有"
assert any(
"api_key" in message and "docs/weather-integration.md" in message
for message in recorder.messages
), f"告警里没写清楚缺哪一项、文档在哪儿:{recorder.messages}"
async def test_missing_credentials_are_not_cached():
"""凭据缺失的提示不能进缓存,配好之后还得重新走一遍链路"""
from core.utils.cache.manager import cache_manager, CacheType
await weather_module.get_weather(
FakeConn({"get_weather": {"api_host": "h"}}), location="没配密钥市"
)
assert cache_manager.get(CacheType.WEATHER, "failed_weather_没配密钥市_zh_CN") is None
async def test_no_credential_leaves_the_request_out(monkeypatch):
"""没配凭据就别去撞接口,调用次数得是零"""
called = []
async def fake_city_info(*args, **kwargs):
called.append(1)
return None
monkeypatch.setattr(weather_module, "fetch_city_info", fake_city_info)
await weather_module.get_weather(FakeConn(), location="无凭据市")
assert called == []
# ---------------------------------------------------------------------------
# 网页解析
# ---------------------------------------------------------------------------
def test_parse_weather_info_returns_none_when_city_name_missing():
"""页面结构变了要返回 None,抛异常的话 Python 原文会被念给用户"""
soup = BeautifulSoup("<html><div>没有城市名</div></html>", "html.parser")
assert weather_module.parse_weather_info(soup) is None
def test_parse_weather_info_returns_none_when_forecast_icon_missing():
html = """
<h1 class="c-submenu__location">杭州市</h1>
<div class="city-forecast-tabs__row">
<div class="date-bg"><span class="date">今天10月24日</span></div>
</div>
"""
soup = BeautifulSoup(html, "html.parser")
assert weather_module.parse_weather_info(soup) is None
def test_parse_weather_info_returns_none_when_soup_is_missing():
assert weather_module.parse_weather_info(None) is None
async def test_broken_web_page_gives_a_neutral_message(monkeypatch):
async def fake_city_info(*args, **kwargs):
return {"id": "1", "name": "杭州", "fxLink": "https://example.com/x.html"}
async def fake_weather_data(*args, **kwargs):
return None
async def fake_page(url):
return BeautifulSoup("<html></html>", "html.parser")
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 response.result == "暂时查不到天气数据,请稍后再试"
assert "AttributeError" not in str(response.result)
assert "NoneType" not in str(response.result)
# ---------------------------------------------------------------------------
# 报告内容
# ---------------------------------------------------------------------------
def test_report_carries_the_observation_time():
report = weather_module.build_weather_report(
{"name": "杭州"},
{"now": {"text": "多云", "obsTime": "2026-10-24T09:52+08:00"}},
{"daily": []},
)
assert "观测时间:2026-10-24 09:52+08:00" in report
def test_report_falls_back_to_the_top_level_update_time():
"""没有观测时间的时候,顶层那个是数据更新时间,标签不能写成观测时间"""
report = weather_module.build_weather_report(
{"name": "杭州"},
{"updateTime": "2026-10-24T10:00+08:00", "now": {"text": "多云"}},
{"daily": []},
)
assert "数据更新时间:2026-10-24 10:00+08:00" in report
assert "观测时间" not in report
def test_report_prefers_the_observation_time_over_the_update_time():
report = weather_module.build_weather_report(
{"name": "杭州"},
{
"updateTime": "2026-10-24T10:00+08:00",
"now": {"text": "多云", "obsTime": "2026-10-24T09:52+08:00"},
},
{"daily": []},
)
assert "观测时间:2026-10-24 09:52+08:00" in report
assert "数据更新时间" not in report
def test_report_without_any_time_has_no_empty_line():
report = weather_module.build_weather_report(
{"name": "杭州"}, {"now": {"text": "多云"}}, {"daily": []}
)
assert "观测时间" not in report
assert "数据更新时间" not in report
# ---------------------------------------------------------------------------
# 日期按北京时间
# ---------------------------------------------------------------------------
# 时间停在 UTC 的 10 月 23 日晚上 16:30,北京时间已经是 24 号了
FROZEN_UTC = datetime.datetime(2026, 10, 23, 16, 30, tzinfo=datetime.timezone.utc)
class FrozenDatetime(datetime.datetime):
@classmethod
def now(cls, tz=None):
return FROZEN_UTC.astimezone(tz) if tz else FROZEN_UTC
class FrozenDate(datetime.date):
"""服务器本地时区是 UTC,这个时刻服务器那边还是 23 号"""
@classmethod
def today(cls):
return cls(2026, 10, 23)
class FrozenDatetimeModule:
datetime = FrozenDatetime
date = FrozenDate
timezone = _timezone
timedelta = _timedelta
def test_today_follows_beijing_time_not_the_server_clock(monkeypatch):
"""服务器时区是 UTC 时,「今天」得按北京时间算,否则凌晨八点前会说错一整天"""
original_tz = os.environ.get("TZ")
os.environ["TZ"] = "UTC"
time.tzset()
try:
monkeypatch.setattr(weather_module, "datetime", FrozenDatetimeModule)
assert FROZEN_UTC.astimezone().date() == datetime.date(2026, 10, 23), "服务器本地还是 23 号"
assert weather_module.beijing_today() == datetime.date(2026, 10, 24)
assert (
weather_module.format_forecast_date("2026-10-24") == "今天10月24日"
), "北京已经跨天了,「今天」还停在 23 号"
assert weather_module.format_forecast_date("2026-10-25") == "明天10月25日"
finally:
if original_tz is None:
os.environ.pop("TZ", None)
else:
os.environ["TZ"] = original_tz
time.tzset()
# ---------------------------------------------------------------------------
# 两条路径的写法
# ---------------------------------------------------------------------------
def test_forecast_date_writing_is_the_same_on_both_paths():
today = datetime.date(2026, 10, 24)
assert weather_module.format_forecast_date("2026-10-24", today) == "今天10月24日"
assert weather_module.format_forecast_date("今天10月24日", today) == "今天10月24日"
assert weather_module.format_forecast_date("2026-10-25", today) == "明天10月25日"
assert weather_module.format_forecast_date("周日10月25日", today) == "明天10月25日"
assert weather_module.format_forecast_date("2026-10-27", today) == "周二10月27日"
assert weather_module.format_forecast_date("周二10月27日", today) == "周二10月27日"
def test_both_paths_write_the_same_date_and_units():
today = _today()
iso_date = today.isoformat()
web_date = f"今天{today.month}月{today.day}日"
json_report = weather_module.build_weather_report(
{"name": "杭州市"},
{
"now": {
"text": "多云",
"temp": "13",
"feelsLike": "13",
"humidity": "97",
"windDir": "西北风",
"windScale": "4",
"vis": "9",
"pressure": "1009",
"precip": "0.0",
}
},
{
"daily": [
{
"fxDate": iso_date,
"textDay": "中雨",
"iconDay": "306",
"tempMax": "13",
"tempMin": "9",
}
]
},
)
parsed = weather_module.parse_weather_info(
BeautifulSoup(_web_page(web_date), "html.parser")
)
web_report = weather_module.format_weather_report(*parsed)
for report in (json_report, web_report):
assert f"{web_date}" in report
assert "气温 9~13℃" in report
assert "能见度: 9公里" in report
assert "气压: 1009百帕" in report
assert "降水量: 0.0毫米" in report
assert "湿度: 97%" in report
assert "体感温度: 13℃" in report
assert "风力: 4级" in report
def test_web_page_units_are_written_in_chinese():
parsed = weather_module.parse_weather_info(
BeautifulSoup(_web_page("今天10月24日"), "html.parser")
)
_, _, current_basic, _, _ = parsed
assert current_basic["能见度"] == "9公里"
assert current_basic["气压"] == "1009百帕"
assert current_basic["降水量"] == "0.0毫米"
assert current_basic["湿度"] == "97%"
assert current_basic["体感温度"] == "13℃"
assert current_basic["风向"] == "西北风"
assert current_basic["风力"] == "4级"
def test_web_page_temperature_uses_the_same_unit_as_the_api_path():
parsed = weather_module.parse_weather_info(
BeautifulSoup(_web_page("今天10月24日"), "html.parser")
)
_, _, _, temps_list, _ = parsed
assert temps_list[0][2] == "13"
assert temps_list[0][3] == "9"
def test_web_page_carries_the_observation_time():
"""网页这条路也得有观测时间,两条路径的报告才对得齐"""
parsed = weather_module.parse_weather_info(
BeautifulSoup(
_web_page("今天10月24日", current_time="10月24日 09:52"), "html.parser"
)
)
assert parsed[4] == "10月24日 09:52"
assert "观测时间:10月24日 09:52" in weather_module.format_weather_report(*parsed)
def test_web_page_without_a_time_element_is_not_an_error():
"""页面结构变了取不到时间,就当作没有,不能抛异常"""
parsed = weather_module.parse_weather_info(
BeautifulSoup(_web_page("今天10月24日"), "html.parser")
)
assert parsed[4] is None
assert "观测时间" not in weather_module.format_weather_report(*parsed)
# ---------------------------------------------------------------------------
# 等待时间
# ---------------------------------------------------------------------------
async def test_web_fallback_does_not_wait_long(fake_http):
"""网页只是兜底,等太久不值得"""
fake_http.responder = lambda url, params: FakeResponse(text="<html></html>")
await weather_module.fetch_weather_page("https://example.com/x.html")
assert fake_http.init_kwargs["timeout"].read == 5.0
async def test_failure_is_remembered_so_the_next_ask_does_not_wait(monkeypatch):
"""上游挂着的时候别每次都把整条链路重走一遍"""
calls = []
async def fake_city_info(*args, **kwargs):
calls.append(1)
return None
monkeypatch.setattr(weather_module, "fetch_city_info", fake_city_info)
conn = FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}})
first = await weather_module.get_weather(conn, location="失败标记市")
second = await weather_module.get_weather(conn, location="失败标记市")
assert calls == [1]
assert first.result == second.result
@@ -0,0 +1,337 @@
"""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.result == "暂时查不到天气数据,请稍后再试"
assert response.response is None
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