Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a0d933074 | ||
|
|
b77ef426e3 | ||
|
|
4a8ba9c80b | ||
|
|
aee4a3cf7a | ||
|
|
824d2b4837 | ||
|
|
4140981a8a | ||
|
|
65e18712e6 | ||
|
|
63f3860f29 | ||
|
|
34cba2f6a5 |
No files matched your search
@@ -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. 注册和风天气账号
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -50,6 +50,12 @@ EMOJI_List = [
|
||||
]
|
||||
|
||||
|
||||
# 天气取不到时给终端用户的一句话。给开发者的申请指引留在服务端日志里,
|
||||
# 仓库路径不能进提示词,模型会把它当内容念给用户
|
||||
WEATHER_NOT_CONFIGURED_NOTICE = "天气功能尚未配置,暂时查不到天气。"
|
||||
WEATHER_UNAVAILABLE_NOTICE = "暂时查不到天气信息。"
|
||||
|
||||
|
||||
class PromptManager:
|
||||
"""系统提示词管理器,负责管理和更新系统提示词"""
|
||||
|
||||
@@ -58,6 +64,8 @@ class PromptManager:
|
||||
self.logger = logger or setup_logging()
|
||||
self.base_prompt_template = None
|
||||
self.last_update_time = 0
|
||||
# 这次取到的天气内容,取不到时是一句给用户的说明
|
||||
self._weather_content = ""
|
||||
|
||||
# 导入全局缓存管理器
|
||||
from core.utils.cache.manager import cache_manager, CacheType
|
||||
@@ -164,19 +172,24 @@ class PromptManager:
|
||||
return "未知位置"
|
||||
|
||||
def _get_weather_info(self, conn: "ConnectionHandler", location: str) -> str:
|
||||
"""获取天气信息"""
|
||||
try:
|
||||
# 先从缓存获取
|
||||
cached_weather = self.cache_manager.get(self.CacheType.WEATHER, location)
|
||||
if cached_weather is not None:
|
||||
return cached_weather
|
||||
"""获取天气信息
|
||||
|
||||
# 缓存未命中,调用 async get_weather 函数
|
||||
<p>
|
||||
这一路不再自己存天气。插件那边存了:成功的报告 8 小时,失败的提示
|
||||
5 分钟。这边再存一份,存的是插件给什么就存什么,失败提示会被存成
|
||||
8 小时,网络恢复了、凭据配好了也出不来天气。
|
||||
"""
|
||||
try:
|
||||
# 以前这条路过缓存,存进去的条目是空的时候,管理员把凭据配好之后
|
||||
# 还得等它过期才出天气。没人读它了,清掉
|
||||
self.cache_manager.delete(self.CacheType.WEATHER, location)
|
||||
|
||||
# 调用 async get_weather 函数
|
||||
# Windows ProactorEventLoop 不支持 run_coroutine_threadsafe().result()
|
||||
# 因此用 call_soon_threadsafe 提交任务 + threading.Event 等待结果
|
||||
# 注意:Event.wait() 只阻塞当前线程池线程,不阻塞主事件循环
|
||||
from plugins_func.functions.get_weather import get_weather
|
||||
from plugins_func.register import ActionResponse
|
||||
from plugins_func.register import Action, ActionResponse
|
||||
|
||||
result_holder = []
|
||||
exception_holder = []
|
||||
@@ -200,13 +213,24 @@ class PromptManager:
|
||||
result = result_holder[0]
|
||||
if isinstance(result, ActionResponse):
|
||||
weather_report = result.result
|
||||
self.cache_manager.set(self.CacheType.WEATHER, location, weather_report)
|
||||
return weather_report
|
||||
return "天气信息获取失败"
|
||||
if weather_report:
|
||||
# 成功还是失败,插件那边分得很清楚:成功的存 8 小时,
|
||||
# 失败的只存 5 分钟。这边原样用,不另存
|
||||
self._weather_content = weather_report
|
||||
return weather_report
|
||||
# 凭据没配好时插件给不出报告
|
||||
if result.action == Action.RESPONSE:
|
||||
self._weather_content = WEATHER_NOT_CONFIGURED_NOTICE
|
||||
else:
|
||||
self._weather_content = WEATHER_UNAVAILABLE_NOTICE
|
||||
return self._weather_content
|
||||
self._weather_content = WEATHER_UNAVAILABLE_NOTICE
|
||||
return self._weather_content
|
||||
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"获取天气信息失败: {e}")
|
||||
return "天气信息获取失败"
|
||||
self._weather_content = WEATHER_UNAVAILABLE_NOTICE
|
||||
return self._weather_content
|
||||
|
||||
def update_context_info(self, conn, client_ip: str):
|
||||
"""同步更新上下文信息"""
|
||||
@@ -246,6 +270,15 @@ class PromptManager:
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"更新上下文信息失败: {e}")
|
||||
|
||||
def _get_weather_context(self) -> str:
|
||||
"""给提示词用的天气内容
|
||||
|
||||
<p>
|
||||
天气没取到时给一句说明,不留空。占位符空着的话,模型会自己编一个
|
||||
天气出来。
|
||||
"""
|
||||
return self._weather_content or WEATHER_UNAVAILABLE_NOTICE
|
||||
|
||||
def build_enhanced_prompt(
|
||||
self, user_prompt: str, device_id: str, client_ip: str = None, *args, **kwargs
|
||||
) -> str:
|
||||
@@ -259,7 +292,6 @@ class PromptManager:
|
||||
|
||||
# 获取缓存的上下文信息
|
||||
local_address = ""
|
||||
weather_info = ""
|
||||
|
||||
if client_ip:
|
||||
# 获取位置信息(从全局缓存)
|
||||
@@ -267,12 +299,8 @@ class PromptManager:
|
||||
self.cache_manager.get(self.CacheType.LOCATION, client_ip) or ""
|
||||
)
|
||||
|
||||
# 获取天气信息(从全局缓存)
|
||||
if local_address:
|
||||
weather_info = (
|
||||
self.cache_manager.get(self.CacheType.WEATHER, local_address)
|
||||
or ""
|
||||
)
|
||||
# 获取天气信息(取天气的那一步已经把内容放在管理器上了)
|
||||
weather_info = self._get_weather_context()
|
||||
|
||||
# 获取TTS选择的语言,默认值为中文
|
||||
language = (
|
||||
|
||||
@@ -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,229 @@
|
||||
"""提示词里的天气内容
|
||||
|
||||
天气有两条路进模型:工具调用那条走插件,提示词那条走 {weather_info} 占位符。
|
||||
这条用例盯的是占位符那条:凭据没配好时它得给用户一句说明,不能留空让模型
|
||||
自己编天气;取到的报告和失败提示也不能被塞进长缓存。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
# 真插件没配凭据时给用户的提示语,里面带着给开发者看的文档路径。
|
||||
# 提示词里不能出现这个路径,模型会照着念给终端用户
|
||||
CREDENTIAL_HINT = "尚未配置和风天气 api_key,请参考 docs/weather-integration.md 申请后填入"
|
||||
WEATHER_REPORT = "您查询的位置是:北京\n\n当前天气: 晴\n"
|
||||
FAILURE_MESSAGE = "暂时查不到 北京 的天气(城市未找到或天气服务不可用),请稍后再试"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _config_file():
|
||||
"""import 链路要 data/.config.yaml 在场,缺了就现造一个空的"""
|
||||
config_file = _PROJECT_ROOT / "data" / ".config.yaml"
|
||||
if not config_file.exists():
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text("{}\n", encoding="utf-8")
|
||||
yield
|
||||
|
||||
|
||||
class _FakeLoop:
|
||||
"""给 _get_weather_info 用的假事件循环
|
||||
|
||||
<p>
|
||||
那边用 call_soon_threadsafe 提交协程,这里起一个真循环在后台线程里跑,
|
||||
协程里的代码原样执行。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def call_soon_threadsafe(self, callback):
|
||||
self._loop.call_soon_threadsafe(callback)
|
||||
|
||||
def close(self):
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
self._thread.join(timeout=5)
|
||||
self._loop.close()
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, loop, api_key: str):
|
||||
self.loop = loop
|
||||
self.config = {
|
||||
"plugins": {
|
||||
"get_weather": {
|
||||
"api_key": api_key,
|
||||
"api_host": "example-host",
|
||||
}
|
||||
}
|
||||
}
|
||||
self.client_ip = "1.2.3.4"
|
||||
self.device_id = "test-device"
|
||||
|
||||
|
||||
def _install_fake_plugin(monkeypatch, responses=None):
|
||||
"""把真插件换成假的:按有没有 api_key 走两种返回
|
||||
|
||||
<p>
|
||||
responses 给了就按顺序回,回完最后一条就一直回最后一条。返回调用次数,
|
||||
用例要看插件被调了几次。
|
||||
"""
|
||||
import plugins_func.functions.get_weather as weather_module
|
||||
from plugins_func.register import Action, ActionResponse
|
||||
|
||||
calls = []
|
||||
|
||||
async def _fake_get_weather(conn, location=None, lang="zh_CN"):
|
||||
api_key = str(
|
||||
conn.config.get("plugins", {}).get("get_weather", {}).get("api_key") or ""
|
||||
)
|
||||
calls.append(location)
|
||||
if not api_key.strip():
|
||||
return ActionResponse(Action.RESPONSE, None, CREDENTIAL_HINT)
|
||||
if responses:
|
||||
index = min(len(calls) - 1, len(responses) - 1)
|
||||
return responses[index]
|
||||
return ActionResponse(Action.REQLLM, WEATHER_REPORT, None)
|
||||
|
||||
monkeypatch.setattr(weather_module, "get_weather", _fake_get_weather)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _manager(tmp_path):
|
||||
"""一个只带天气占位符的提示词管理器"""
|
||||
from core.utils.prompt_manager import PromptManager
|
||||
|
||||
template = tmp_path / "prompt.txt"
|
||||
template.write_text("当前天气:{{weather_info}}", encoding="utf-8")
|
||||
return PromptManager({"prompt_template": str(template)})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _cache():
|
||||
"""全局缓存,测试之间要清干净"""
|
||||
from core.utils.cache.manager import cache_manager, CacheType
|
||||
|
||||
cache_manager.clear(CacheType.WEATHER)
|
||||
cache_manager.clear(CacheType.LOCATION)
|
||||
yield cache_manager
|
||||
cache_manager.clear(CacheType.WEATHER)
|
||||
cache_manager.clear(CacheType.LOCATION)
|
||||
|
||||
|
||||
def _build_prompt(manager, conn):
|
||||
"""走一遍真实的两步:先更新上下文,再拼提示词"""
|
||||
manager.update_context_info(conn, conn.client_ip)
|
||||
return manager.build_enhanced_prompt("你是助手", conn.device_id, conn.client_ip)
|
||||
|
||||
|
||||
def test_unconfigured_weather_gives_notice_and_caches_nothing(
|
||||
monkeypatch, _manager, _cache
|
||||
):
|
||||
"""凭据没配好:提示词里要有说明,缓存里不能留下空条目"""
|
||||
from core.utils.cache.manager import CacheType
|
||||
|
||||
_install_fake_plugin(monkeypatch)
|
||||
loop = _FakeLoop()
|
||||
conn = _FakeConn(loop, api_key="")
|
||||
_cache.set(CacheType.LOCATION, conn.client_ip, "北京")
|
||||
|
||||
try:
|
||||
prompt = _build_prompt(_manager, conn)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert "未配置" in prompt
|
||||
# 给开发者的文档路径不能进提示词,模型会照着念给终端用户
|
||||
assert "docs/weather-integration.md" not in prompt
|
||||
# 天气内容不能是空的,空着模型会自己编一个
|
||||
assert prompt.strip() != "当前天气:"
|
||||
# 空结果不写缓存
|
||||
assert _cache.get(CacheType.WEATHER, "北京") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stale", [None, ""])
|
||||
def test_empty_cached_entry_is_cleared_and_weather_is_fetched_again(
|
||||
monkeypatch, _manager, _cache, stale
|
||||
):
|
||||
"""缓存里已经有空条目时,配好凭据后下一次就该出天气"""
|
||||
from core.utils.cache.manager import CacheType
|
||||
|
||||
calls = _install_fake_plugin(monkeypatch)
|
||||
loop = _FakeLoop()
|
||||
conn = _FakeConn(loop, api_key="not-a-real-key")
|
||||
_cache.set(CacheType.LOCATION, conn.client_ip, "北京")
|
||||
_cache.set(CacheType.WEATHER, "北京", stale)
|
||||
|
||||
try:
|
||||
prompt = _build_prompt(_manager, conn)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert "当前天气: 晴" in prompt
|
||||
# 存量空条目被清掉了,没有留在缓存里
|
||||
assert _cache.get(CacheType.WEATHER, "北京") is None
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_failed_weather_is_not_cached_and_is_fetched_again(
|
||||
monkeypatch, _manager, _cache
|
||||
):
|
||||
"""插件说查不到天气时,那句话不能进长缓存,下一次要重新取"""
|
||||
from core.utils.cache.manager import CacheType
|
||||
from plugins_func.register import Action, ActionResponse
|
||||
|
||||
calls = _install_fake_plugin(
|
||||
monkeypatch,
|
||||
responses=[
|
||||
ActionResponse(Action.REQLLM, FAILURE_MESSAGE, None),
|
||||
ActionResponse(Action.REQLLM, WEATHER_REPORT, None),
|
||||
],
|
||||
)
|
||||
loop = _FakeLoop()
|
||||
conn = _FakeConn(loop, api_key="not-a-real-key")
|
||||
_cache.set(CacheType.LOCATION, conn.client_ip, "北京")
|
||||
|
||||
try:
|
||||
first_prompt = _build_prompt(_manager, conn)
|
||||
# 失败提示进提示词,用户能听到「暂时查不到」
|
||||
assert "暂时查不到" in first_prompt
|
||||
# 但它不能被存进 8 小时那档缓存,不然网络恢复了就出不来天气
|
||||
assert _cache.get(CacheType.WEATHER, "北京") is None
|
||||
|
||||
second_prompt = _build_prompt(_manager, conn)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# 下一次重新取,插件被调了两次
|
||||
assert len(calls) == 2
|
||||
assert "当前天气: 晴" in second_prompt
|
||||
|
||||
|
||||
def test_configured_weather_report_goes_to_placeholder(
|
||||
monkeypatch, _manager, _cache
|
||||
):
|
||||
"""凭据齐全:报告照旧进占位符,缓存由插件那边管"""
|
||||
from core.utils.cache.manager import CacheType
|
||||
|
||||
_install_fake_plugin(monkeypatch)
|
||||
loop = _FakeLoop()
|
||||
conn = _FakeConn(loop, api_key="not-a-real-key")
|
||||
_cache.set(CacheType.LOCATION, conn.client_ip, "北京")
|
||||
|
||||
try:
|
||||
prompt = _build_prompt(_manager, conn)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert "当前天气: 晴" in prompt
|
||||
assert "未配置" not in prompt
|
||||
# 提示词这一侧不再自己存一份,成功那份由插件按 8 小时存
|
||||
assert _cache.get(CacheType.WEATHER, "北京") is None
|
||||
@@ -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