426 lines
16 KiB
Python
426 lines
16 KiB
Python
import requests
|
||
import json
|
||
import base64
|
||
import time
|
||
from bs4 import BeautifulSoup
|
||
from config.logger import setup_logging
|
||
from plugins_func.register import register_function, ToolType, ActionResponse, Action
|
||
from core.utils.util import get_ip_info
|
||
from cryptography.hazmat.primitives import serialization
|
||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||
|
||
TAG = __name__
|
||
logger = setup_logging()
|
||
|
||
GET_WEATHER_FUNCTION_DESC = {
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_weather",
|
||
"description": (
|
||
"获取某个地点的天气,用户应提供一个位置,比如用户说杭州天气,参数为:杭州。"
|
||
"如果用户说的是省份,默认用省会城市。如果用户说的不是省份或城市而是一个地名,默认用该地所在省份的省会城市。"
|
||
"如果用户没有指明地点,说“天气怎么样”,”今天天气如何“,location参数为空"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"location": {
|
||
"type": "string",
|
||
"description": "地点名,例如杭州。可选参数,如果不提供则不传",
|
||
},
|
||
"lang": {
|
||
"type": "string",
|
||
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN",
|
||
},
|
||
},
|
||
"required": ["lang"],
|
||
},
|
||
},
|
||
}
|
||
|
||
HEADERS = {
|
||
"User-Agent": (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||
"(KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36"
|
||
)
|
||
}
|
||
|
||
# 天气代码 https://dev.qweather.com/docs/resource/icons/#weather-icons
|
||
WEATHER_CODE_MAP = {
|
||
"100": "晴",
|
||
"101": "多云",
|
||
"102": "少云",
|
||
"103": "晴间多云",
|
||
"104": "阴",
|
||
"150": "晴",
|
||
"151": "多云",
|
||
"152": "少云",
|
||
"153": "晴间多云",
|
||
"300": "阵雨",
|
||
"301": "强阵雨",
|
||
"302": "雷阵雨",
|
||
"303": "强雷阵雨",
|
||
"304": "雷阵雨伴有冰雹",
|
||
"305": "小雨",
|
||
"306": "中雨",
|
||
"307": "大雨",
|
||
"308": "极端降雨",
|
||
"309": "毛毛雨/细雨",
|
||
"310": "暴雨",
|
||
"311": "大暴雨",
|
||
"312": "特大暴雨",
|
||
"313": "冻雨",
|
||
"314": "小到中雨",
|
||
"315": "中到大雨",
|
||
"316": "大到暴雨",
|
||
"317": "暴雨到大暴雨",
|
||
"318": "大暴雨到特大暴雨",
|
||
"350": "阵雨",
|
||
"351": "强阵雨",
|
||
"399": "雨",
|
||
"400": "小雪",
|
||
"401": "中雪",
|
||
"402": "大雪",
|
||
"403": "暴雪",
|
||
"404": "雨夹雪",
|
||
"405": "雨雪天气",
|
||
"406": "阵雨夹雪",
|
||
"407": "阵雪",
|
||
"408": "小到中雪",
|
||
"409": "中到大雪",
|
||
"410": "大到暴雪",
|
||
"456": "阵雨夹雪",
|
||
"457": "阵雪",
|
||
"499": "雪",
|
||
"500": "薄雾",
|
||
"501": "雾",
|
||
"502": "霾",
|
||
"503": "扬沙",
|
||
"504": "浮尘",
|
||
"507": "沙尘暴",
|
||
"508": "强沙尘暴",
|
||
"509": "浓雾",
|
||
"510": "强浓雾",
|
||
"511": "中度霾",
|
||
"512": "重度霾",
|
||
"513": "严重霾",
|
||
"514": "大雾",
|
||
"515": "特强浓雾",
|
||
"900": "热",
|
||
"901": "冷",
|
||
"999": "未知",
|
||
}
|
||
|
||
# Base64URL编码
|
||
def base64url_encode(data):
|
||
"""Base64URL encode without padding"""
|
||
return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=')
|
||
|
||
# 生成JWT token
|
||
def generate_jwt_token(kid, project_id, private_key_pem):
|
||
"""
|
||
Generate JWT token for QWeather API authentication
|
||
|
||
Args:
|
||
kid: Credential ID from QWeather console
|
||
project_id: Project ID from QWeather console
|
||
private_key_pem: Private key in PEM format
|
||
|
||
Returns:
|
||
JWT token string
|
||
"""
|
||
# Header
|
||
header = {
|
||
"alg": "EdDSA",
|
||
"kid": kid
|
||
}
|
||
|
||
# Payload
|
||
current_time = int(time.time())
|
||
payload = {
|
||
"sub": project_id,
|
||
"iat": current_time - 30, # Set to 30 seconds before current time to prevent time errors
|
||
"exp": current_time + 3600 # Expire in 1 hour
|
||
}
|
||
|
||
# Encode header and payload
|
||
header_encoded = base64url_encode(json.dumps(header, separators=(',', ':')).encode('utf-8'))
|
||
payload_encoded = base64url_encode(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
|
||
|
||
# Create signing input
|
||
signing_input = f"{header_encoded}.{payload_encoded}"
|
||
|
||
# Load private key and sign
|
||
private_key = serialization.load_pem_private_key(
|
||
private_key_pem.encode('utf-8'),
|
||
password=None
|
||
)
|
||
|
||
signature = private_key.sign(signing_input.encode('utf-8'))
|
||
signature_encoded = base64url_encode(signature)
|
||
|
||
# Return complete JWT
|
||
return f"{signing_input}.{signature_encoded}"
|
||
|
||
# 获取城市信息GeoAPI
|
||
def fetch_city_info(location, api_key, api_host, kid=None, project_id=None, private_key=None):
|
||
# Use JWT authentication if JWT parameters are provided
|
||
if kid and project_id and private_key:
|
||
try:
|
||
logger.bind(tag=TAG).info(f"使用JWT认证,kid: {kid[:10]}..., project_id: {project_id[:10]}...")
|
||
jwt_token = generate_jwt_token(kid, project_id, private_key)
|
||
headers = HEADERS.copy()
|
||
headers['Authorization'] = f'Bearer {jwt_token}'
|
||
url = f"https://{api_host}/geo/v2/city/lookup?location={location}&lang=zh"
|
||
logger.bind(tag=TAG).info(f"JWT请求URL: {url}")
|
||
|
||
response = requests.get(url, headers=headers)
|
||
logger.bind(tag=TAG).info(f"HTTP状态码: {response.status_code}")
|
||
|
||
if response.status_code != 200:
|
||
logger.bind(tag=TAG).error(f"HTTP请求失败: {response.status_code} - {response.text}")
|
||
return None
|
||
|
||
response_json = response.json()
|
||
logger.bind(tag=TAG).info(f"API响应: {response_json}")
|
||
|
||
except Exception as e:
|
||
logger.bind(tag=TAG).error(f"JWT认证失败: {str(e)}")
|
||
# Fallback to API key authentication
|
||
logger.bind(tag=TAG).info("回退到API KEY认证")
|
||
url = f"https://{api_host}/geo/v2/city/lookup?key={api_key}&location={location}&lang=zh"
|
||
response = requests.get(url, headers=HEADERS)
|
||
if response.status_code != 200:
|
||
logger.bind(tag=TAG).error(f"API KEY认证也失败: {response.status_code} - {response.text}")
|
||
return None
|
||
response_json = response.json()
|
||
else:
|
||
# Fallback to API key authentication
|
||
logger.bind(tag=TAG).info(f"使用API KEY认证: {api_key}")
|
||
url = f"https://{api_host}/geo/v2/city/lookup?key={api_key}&location={location}&lang=zh"
|
||
response = requests.get(url, headers=HEADERS)
|
||
if response.status_code != 200:
|
||
logger.bind(tag=TAG).error(f"API KEY认证失败: {response.status_code} - {response.text}")
|
||
return None
|
||
response_json = response.json()
|
||
|
||
if response_json.get("error") is not None:
|
||
error_detail = response_json.get('error', {})
|
||
logger.bind(tag=TAG).error(f"API错误 - 状态: {error_detail.get('status')}, 详情: {error_detail.get('detail')}")
|
||
return None
|
||
|
||
locations = response_json.get("location", [])
|
||
if not locations:
|
||
logger.bind(tag=TAG).warning(f"未找到位置信息: {location}")
|
||
return None
|
||
|
||
return locations[0]
|
||
|
||
# 获取天气页面
|
||
def fetch_weather_page(url):
|
||
logger.bind(tag=TAG).info(f"正在获取天气页面: {url}")
|
||
response = requests.get(url, headers=HEADERS)
|
||
logger.bind(tag=TAG).info(f"页面请求状态码: {response.status_code}")
|
||
|
||
if response.ok:
|
||
soup = BeautifulSoup(response.text, "html.parser")
|
||
logger.bind(tag=TAG).info(f"页面内容长度: {len(response.text)}")
|
||
logger.bind(tag=TAG).info(f"页面标题: {soup.title.string if soup.title else '无标题'}")
|
||
return soup
|
||
else:
|
||
logger.bind(tag=TAG).error(f"获取天气页面失败: {response.status_code}")
|
||
return None
|
||
|
||
# 解析天气信息
|
||
def parse_weather_info(soup):
|
||
try:
|
||
# 尝试解析城市名称
|
||
city_element = soup.select_one("h1.c-submenu__location")
|
||
if city_element:
|
||
city_name = city_element.get_text(strip=True)
|
||
logger.bind(tag=TAG).info(f"成功解析城市名称: {city_name}")
|
||
else:
|
||
logger.bind(tag=TAG).warning("未找到城市名称元素,尝试其他选择器")
|
||
# 尝试其他可能的选择器
|
||
alt_selectors = ["h1", ".location", ".city-name", "title"]
|
||
city_name = "未知城市"
|
||
for selector in alt_selectors:
|
||
element = soup.select_one(selector)
|
||
if element:
|
||
text = element.get_text(strip=True)
|
||
logger.bind(tag=TAG).info(f"使用选择器 {selector} 找到: {text}")
|
||
city_name = text
|
||
break
|
||
|
||
# 尝试解析当前天气摘要
|
||
current_abstract = soup.select_one(".c-city-weather-current .current-abstract")
|
||
if current_abstract:
|
||
current_abstract = current_abstract.get_text(strip=True)
|
||
logger.bind(tag=TAG).info(f"成功解析天气摘要: {current_abstract}")
|
||
else:
|
||
current_abstract = "未知"
|
||
logger.bind(tag=TAG).warning("未找到天气摘要元素")
|
||
|
||
# 尝试解析详细参数
|
||
current_basic = {}
|
||
basic_items = soup.select(".c-city-weather-current .current-basic .current-basic___item")
|
||
logger.bind(tag=TAG).info(f"找到 {len(basic_items)} 个基本信息元素")
|
||
|
||
for item in basic_items:
|
||
parts = item.get_text(strip=True, separator=" ").split(" ")
|
||
if len(parts) == 2:
|
||
key, value = parts[1], parts[0]
|
||
current_basic[key] = value
|
||
logger.bind(tag=TAG).debug(f"解析基本信息: {key} = {value}")
|
||
|
||
# 尝试解析7天预报
|
||
temps_list = []
|
||
forecast_rows = soup.select(".city-forecast-tabs__row")
|
||
logger.bind(tag=TAG).info(f"找到 {len(forecast_rows)} 个预报行")
|
||
|
||
for i, row in enumerate(forecast_rows[:7]):
|
||
try:
|
||
date_element = row.select_one(".date-bg .date")
|
||
icon_element = row.select_one(".date-bg .icon")
|
||
temp_elements = row.select(".tmp-cont .temp")
|
||
|
||
if date_element and icon_element and temp_elements:
|
||
date = date_element.get_text(strip=True)
|
||
weather_code = icon_element["src"].split("/")[-1].split(".")[0]
|
||
weather = WEATHER_CODE_MAP.get(weather_code, "未知")
|
||
temps = [span.get_text(strip=True) for span in temp_elements]
|
||
high_temp, low_temp = (temps[0], temps[-1]) if len(temps) >= 2 else (None, None)
|
||
temps_list.append((date, weather, high_temp, low_temp))
|
||
logger.bind(tag=TAG).debug(f"解析第{i+1}天: {date} {weather} {high_temp}~{low_temp}")
|
||
else:
|
||
logger.bind(tag=TAG).warning(f"第{i+1}天预报元素不完整")
|
||
except Exception as e:
|
||
logger.bind(tag=TAG).warning(f"解析第{i+1}天预报失败: {e}")
|
||
|
||
if not temps_list:
|
||
logger.bind(tag=TAG).error("未能解析任何预报数据,页面结构可能已更改")
|
||
# 打印页面结构用于调试
|
||
logger.bind(tag=TAG).debug(f"页面主要结构: {[tag.name for tag in soup.find_all()[:20]]}")
|
||
|
||
return city_name, current_abstract, current_basic, temps_list
|
||
|
||
except Exception as e:
|
||
logger.bind(tag=TAG).error(f"解析天气信息时发生错误: {e}")
|
||
return "解析失败", "无法获取", {}, []
|
||
|
||
|
||
@register_function("get_weather", GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL)# 获取天气
|
||
|
||
# @param location 城市名称,默认值为"北京"
|
||
# @param lang 语言,默认值为"zh_CN"
|
||
def get_weather(conn, location: str = None, lang: str = "zh_CN"):
|
||
from core.utils.cache.manager import cache_manager, CacheType
|
||
|
||
# 硬编码配置参数
|
||
api_host = "kq3aapg9h5.re.qweatherapi.com"
|
||
api_key = "aa5ec0859c144ac7b33966e25eef5580"
|
||
default_location = "北京"
|
||
kid = "T45F5GTR8Y"
|
||
project_id = "4N855TEVNN"
|
||
private_key = """-----BEGIN PRIVATE KEY-----
|
||
MC4CAQAwBQYDK2VwBCIEIA26lz31HoaZV17EjIGcyo9YNGGQ77/gOZU8Chw8wlWq
|
||
-----END PRIVATE KEY-----"""
|
||
|
||
# Debug JWT configuration
|
||
logger.bind(tag=TAG).info(f"使用硬编码JWT配置 - kid: {kid}, project_id: {project_id}")
|
||
logger.bind(tag=TAG).info(f"✅ JWT认证已配置,将使用JWT认证访问: {api_host}")
|
||
client_ip = conn.client_ip
|
||
|
||
# 优先使用用户提供的location参数
|
||
if not location:
|
||
# 通过客户端IP解析城市
|
||
if client_ip:
|
||
# 先从缓存获取IP对应的城市信息
|
||
cached_ip_info = cache_manager.get(CacheType.IP_INFO, client_ip)
|
||
if cached_ip_info:
|
||
location = cached_ip_info.get("city")
|
||
else:
|
||
# 缓存未命中,调用API获取
|
||
ip_info = get_ip_info(client_ip, logger)
|
||
if ip_info:
|
||
cache_manager.set(CacheType.IP_INFO, client_ip, ip_info)
|
||
location = ip_info.get("city")
|
||
|
||
if not location:
|
||
location = default_location
|
||
else:
|
||
# 若无IP,使用默认位置
|
||
location = default_location
|
||
# 尝试从缓存获取完整天气报告
|
||
weather_cache_key = f"full_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)
|
||
|
||
# 缓存未命中,获取实时天气数据
|
||
city_info = fetch_city_info(location, api_key, api_host, kid, project_id, private_key)
|
||
if not city_info:
|
||
return ActionResponse(
|
||
Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None
|
||
)
|
||
soup = 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(如需某一天的具体天气,请告诉我日期)"// 在adjust_audio_val处理逻辑后添加
|
||
|
||
|
||
if (strcmp(name->valuestring, "get_weather") == 0) {
|
||
cJSON* location = cJSON_GetObjectItem(args_obj, "location");
|
||
cJSON* lang = cJSON_GetObjectItem(args_obj, "lang");
|
||
|
||
// 设置默认值
|
||
const char* location_str = (location && cJSON_IsString(location)) ? location->valuestring : "广州";
|
||
const char* lang_str = (lang && cJSON_IsString(lang)) ? lang->valuestring : "zh_CN";
|
||
|
||
ESP_LOGI(TAG, "获取天气: location=%s, lang=%s", location_str, lang_str);
|
||
|
||
// 创建异步任务处理天气获取
|
||
Schedule([this, location_str, lang_str, call_id]() {
|
||
try {
|
||
// 调用天气API获取结果
|
||
std::string weather_result = GetWeatherInfo(location_str, lang_str);
|
||
|
||
if (!call_id || !call_id[0]) {
|
||
if (protocol_) {
|
||
protocol_->SendTextMessage(weather_result);
|
||
}
|
||
} else if (protocol_) {
|
||
protocol_->SendFunctionResult(call_id, weather_result);
|
||
}
|
||
} catch (const std::exception& e) {
|
||
ESP_LOGE(TAG, "天气获取异常: %s", e.what());
|
||
std::string error_msg = "获取天气信息失败,请稍后重试";
|
||
if (protocol_) {
|
||
protocol_->SendTextMessage(error_msg);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
# 缓存完整的天气报告
|
||
cache_manager.set(CacheType.WEATHER, weather_cache_key, weather_report)
|
||
|
||
return ActionResponse(Action.REQLLM, weather_report, None)
|