Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee4a3cf7a | ||
|
|
824d2b4837 | ||
|
|
4140981a8a | ||
|
|
65e18712e6 | ||
|
|
63f3860f29 | ||
|
|
34cba2f6a5 |
No files matched your search
@@ -4,6 +4,22 @@
|
|||||||
|
|
||||||
天气插件 `get_weather` 是小智ESP32语音助手的核心功能之一,支持通过语音查询全国各地的天气信息。插件基于和风天气API,提供实时天气和7天天气预报功能。
|
天气插件 `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 申请指南
|
## API Key 申请指南
|
||||||
|
|
||||||
### 1. 注册和风天气账号
|
### 1. 注册和风天气账号
|
||||||
|
|||||||
@@ -130,14 +130,15 @@ context_providers:
|
|||||||
|
|
||||||
# 插件的基础配置
|
# 插件的基础配置
|
||||||
plugins:
|
plugins:
|
||||||
# 获取天气插件的配置,这里填写你的api_key
|
# 获取天气插件的配置,这里填写你自己的api_key和apihost
|
||||||
# 这个密钥是项目共用的key,用多了可能会被限制
|
# 仓库里不再预置凭据,申请流程写在 docs/weather-integration.md
|
||||||
# 想稳定一点就自行申请替换,每天有1000次免费调用
|
|
||||||
# 申请地址:https://console.qweather.com/#/apps/create-key/over
|
# 申请地址:https://console.qweather.com/#/apps/create-key/over
|
||||||
# 申请后通过这个链接可以找到自己的apihost:https://console.qweather.com/setting?lang=zh
|
# 申请后通过这个链接可以找到自己的apihost:https://console.qweather.com/setting?lang=zh
|
||||||
|
# 建key时要勾选三项权限:GeoAPI城市查询、实时天气、7天天气预报
|
||||||
|
# 只勾一两个的话JSON接口取不到数据,天气只能退到网页上抓
|
||||||
get_weather:
|
get_weather:
|
||||||
api_host: "mj7p3y7naa.re.qweatherapi.com"
|
api_host: ""
|
||||||
api_key: "a861d0d5e7bf4ee1a83d9a9e4f96d4da"
|
api_key: ""
|
||||||
default_location: "广州"
|
default_location: "广州"
|
||||||
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
|
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
|
||||||
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
|
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import random
|
import random
|
||||||
import httpx
|
import httpx
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from urllib.parse import quote, urlsplit, urlunsplit, parse_qsl
|
||||||
from markitdown import MarkItDown, StreamInfo
|
from markitdown import MarkItDown, StreamInfo
|
||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
from plugins_func.register import register_function, ToolType, ActionResponse, Action
|
from plugins_func.register import register_function, ToolType, ActionResponse, Action
|
||||||
@@ -54,6 +55,27 @@ CHANNEL_MAP = {
|
|||||||
# 默认新闻来源字典,当配置中没有指定时使用
|
# 默认新闻来源字典,当配置中没有指定时使用
|
||||||
DEFAULT_NEWS_SOURCES = "澎湃新闻;百度热搜;财联社"
|
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):
|
def _get_newsnow_config(conn):
|
||||||
# 从连接配置获取
|
# 从连接配置获取
|
||||||
plugins = conn.config.get("plugins", {})
|
plugins = conn.config.get("plugins", {})
|
||||||
@@ -61,6 +83,8 @@ def _get_newsnow_config(conn):
|
|||||||
sources = newsnow.get("news_sources", "")
|
sources = newsnow.get("news_sources", "")
|
||||||
if isinstance(sources, str) and sources.strip():
|
if isinstance(sources, str) and sources.strip():
|
||||||
return sources
|
return sources
|
||||||
|
if isinstance(sources, (list, tuple)):
|
||||||
|
return ";".join(str(item).strip() for item in sources if str(item).strip())
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -113,23 +137,26 @@ GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = {
|
|||||||
async def fetch_news_from_api(conn: "ConnectionHandler", source="thepaper"):
|
async def fetch_news_from_api(conn: "ConnectionHandler", source="thepaper"):
|
||||||
"""从API获取新闻列表"""
|
"""从API获取新闻列表"""
|
||||||
try:
|
try:
|
||||||
api_url = f"https://newsnow.busiyi.world/api/s?id={source}"
|
|
||||||
|
|
||||||
news_config = conn.config.get("plugins", {}).get("get_news_from_newsnow", {})
|
news_config = conn.config.get("plugins", {}).get("get_news_from_newsnow", {})
|
||||||
if news_config.get("url"):
|
configured_url = news_config.get("url") if isinstance(news_config, dict) else None
|
||||||
api_url = news_config["url"] + source
|
api_url, request_params = _build_news_api_url(source, configured_url)
|
||||||
|
|
||||||
headers = {"User-Agent": "Mozilla/5.0"}
|
headers = {"User-Agent": "Mozilla/5.0"}
|
||||||
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)) 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()
|
data = response.json()
|
||||||
|
|
||||||
if "items" in data:
|
items = data.get("items") if isinstance(data, dict) else None
|
||||||
return data["items"]
|
if not isinstance(items, list):
|
||||||
else:
|
|
||||||
logger.bind(tag=TAG).error(f"获取新闻API响应格式错误: {data}")
|
logger.bind(tag=TAG).error(f"获取新闻API响应格式错误: {data}")
|
||||||
return []
|
return []
|
||||||
|
return [item for item in items if isinstance(item, dict) and item.get("title")]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"获取新闻API失败: {e}")
|
logger.bind(tag=TAG).error(f"获取新闻API失败: {e}")
|
||||||
@@ -239,18 +266,24 @@ async def get_news_from_newsnow(
|
|||||||
english_source_id = None
|
english_source_id = None
|
||||||
|
|
||||||
# 检查输入的中文名称是否在配置的新闻源中
|
# 检查输入的中文名称是否在配置的新闻源中
|
||||||
news_sources_list = [
|
news_sources_list = [name.strip() for name in news_sources.split(";") if name.strip()]
|
||||||
name.strip() for name in news_sources.split(";") if name.strip()
|
|
||||||
]
|
|
||||||
if source in news_sources_list:
|
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)
|
english_source_id = CHANNEL_MAP.get(source)
|
||||||
|
|
||||||
# 如果找不到对应的英文ID,使用默认源
|
# 如果找不到对应的英文ID,使用默认源
|
||||||
if not english_source_id:
|
if not english_source_id:
|
||||||
logger.bind(tag=TAG).warning(f"无效的新闻源: {source},使用默认源澎湃新闻")
|
fallback_source = next(
|
||||||
english_source_id = "thepaper"
|
(name for name in news_sources_list if CHANNEL_MAP.get(name)),
|
||||||
source = "澎湃新闻"
|
"澎湃新闻",
|
||||||
|
)
|
||||||
|
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})")
|
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)
|
selected_news = random.choice(news_items)
|
||||||
|
title = selected_news.get("title", "未知标题")
|
||||||
|
|
||||||
# 保存当前新闻链接到连接对象,以便后续查询详情
|
# 保存当前新闻链接到连接对象,以便后续查询详情
|
||||||
if not hasattr(conn, "last_newsnow_link"):
|
if not hasattr(conn, "last_newsnow_link"):
|
||||||
conn.last_newsnow_link = {}
|
conn.last_newsnow_link = {}
|
||||||
conn.last_newsnow_link = {
|
conn.last_newsnow_link = {
|
||||||
"url": selected_news.get("url", "#"),
|
"url": selected_news.get("url", "#"),
|
||||||
"title": selected_news.get("title", "未知标题"),
|
"title": title,
|
||||||
"source_id": english_source_id,
|
"source_id": english_source_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 构建新闻报告
|
# 构建新闻报告
|
||||||
news_report = (
|
news_report = (
|
||||||
f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n"
|
f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n"
|
||||||
f"新闻标题: {selected_news['title']}\n"
|
f"新闻标题: {title}\n"
|
||||||
# f"新闻来源: {source}\n"
|
# f"新闻来源: {source}\n"
|
||||||
f"(请以自然、流畅的方式向用户播报这条新闻标题,"
|
f"(请以自然、流畅的方式向用户播报这条新闻标题,"
|
||||||
f"提示用户可以要求获取详细内容,此时会获取新闻的详细内容。)"
|
f"提示用户可以要求获取详细内容,此时会获取新闻的详细内容。)"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import re
|
||||||
import httpx
|
import httpx
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from config.logger import setup_logging
|
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
|
# 天气代码 https://dev.qweather.com/docs/resource/icons/#weather-icons
|
||||||
WEATHER_CODE_MAP = {
|
WEATHER_CODE_MAP = {
|
||||||
"100": "晴",
|
"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):
|
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:
|
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client:
|
||||||
response = await client.get(url, headers=HEADERS)
|
response = await client.get(
|
||||||
data = response.json()
|
url,
|
||||||
if data.get("error") is not None:
|
params={
|
||||||
|
"key": api_key,
|
||||||
|
"location": location,
|
||||||
|
"lang": "zh",
|
||||||
|
"number": 1,
|
||||||
|
},
|
||||||
|
headers=HEADERS,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
logger.bind(tag=TAG).error(
|
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 None
|
||||||
return data.get("location", [])[0] if data.get("location") else 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 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)
|
response = await client.get(url, headers=HEADERS)
|
||||||
return BeautifulSoup(response.text, "html.parser") if response.status_code == 200 else None
|
return BeautifulSoup(response.text, "html.parser") if response.status_code == 200 else None
|
||||||
|
|
||||||
|
|
||||||
def parse_weather_info(soup):
|
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 = soup.select_one(".c-city-weather-current .current-abstract")
|
||||||
current_abstract = (
|
current_abstract = (
|
||||||
@@ -145,20 +420,48 @@ def parse_weather_info(soup):
|
|||||||
parts = item.get_text(strip=True, separator=" ").split(" ")
|
parts = item.get_text(strip=True, separator=" ").split(" ")
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
key, value = parts[1], parts[0]
|
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
|
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 = []
|
temps_list = []
|
||||||
for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据
|
for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据
|
||||||
date = row.select_one(".date-bg .date").get_text(strip=True)
|
date_element = row.select_one(".date-bg .date")
|
||||||
weather_code = (
|
icon_element = row.select_one(".date-bg .icon")
|
||||||
row.select_one(".date-bg .icon")["src"].split("/")[-1].split(".")[0]
|
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, "未知")
|
weather = WEATHER_CODE_MAP.get(weather_code, "未知")
|
||||||
temps = [span.get_text(strip=True) for span in row.select(".tmp-cont .temp")]
|
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)
|
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)
|
@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
|
from core.utils.cache.manager import cache_manager, CacheType
|
||||||
|
|
||||||
weather_config = conn.config.get("plugins", {}).get("get_weather", {})
|
weather_config = conn.config.get("plugins", {}).get("get_weather", {})
|
||||||
api_host = weather_config.get("api_host", "mj7p3y7naa.re.qweatherapi.com")
|
api_key = str(weather_config.get("api_key") or "").strip()
|
||||||
api_key = weather_config.get("api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da")
|
api_host = str(weather_config.get("api_host") or "").strip()
|
||||||
default_location = weather_config.get("default_location", "广州")
|
default_location = weather_config.get("default_location", "广州")
|
||||||
client_ip = conn.client_ip
|
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参数
|
# 优先使用用户提供的location参数
|
||||||
if not location:
|
if not location:
|
||||||
# 通过客户端IP解析城市
|
# 通过客户端IP解析城市
|
||||||
@@ -193,37 +511,40 @@ async def get_weather(conn: "ConnectionHandler", location: str = None, lang: str
|
|||||||
location = default_location
|
location = default_location
|
||||||
# 尝试从缓存获取完整天气报告
|
# 尝试从缓存获取完整天气报告
|
||||||
weather_cache_key = f"full_weather_{location}_{lang}"
|
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)
|
cached_weather_report = cache_manager.get(CacheType.WEATHER, weather_cache_key)
|
||||||
if cached_weather_report:
|
if cached_weather_report:
|
||||||
return ActionResponse(Action.REQLLM, cached_weather_report, None)
|
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)
|
city_info = await fetch_city_info(location, api_key, api_host)
|
||||||
if not city_info:
|
if not city_info:
|
||||||
return ActionResponse(
|
message = f"暂时查不到 {location} 的天气(城市未找到或天气服务不可用),请稍后再试"
|
||||||
Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None
|
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)
|
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
|
||||||
Reference in new issue
Block a user