Skill: savior1987/cs-qweather-alert (version 2.0.0)

--- name: 和风天气 - 天气和预警查询 description: 和风天气查询工具集，支持实时天气查询和天气预警查询。当用户询问城市天气、气温、湿度、刮风下雨等天气状况，或需要查询预警时触发。 --- # 和风天气-天气查询与天气预警查询 **和风天气查询工具集** — 支持实时天气查询和天气预警查询两个功能。 --- ## 技能职责 当需要查询某个城市的**实时天气**、**每日天气预报**或**天气预警**时使用此 skill。 --- ## 脚本清单 | 脚本 | 功能 | |------|------| | `qweather-get-weather-now.py` | 实时天气查询 | | `qweather-get-weather-alert.py` | 天气预警查询 | | `qweather-get-weather-daily.py` | 每日天气预报（3/7/10/15/30天） | | `qweather_utils.py` | 公共工具库（供上述脚本调用） | --- ## 环境变量 | 变量 | 必填 | 说明 | |------|------|------| | `QWEATHER_API_HOST` | ✅ | 和风 API Host，格式为 `https://xxx.re.qweatherapi.com` | | `QWEATHER_CITY` | 否 | 默认城市名，作为命令行参数的备用 | > **环境变量加载**：脚本使用 `dotenv` 库自动从 `~/.openclaw/.env` 加载变量（`override=True`，强制读取最新值避免旧进程缓存干扰），在 OpenClaw 环境下无需手动配置即可使用。 > > **Token 配置**：JWT Token 默认从 `~/.myjwtkey/last-token.dat` 读取，也可通过 `--token` 参数显式传入。 --- ## qweather-get-weather-now.py — 实时天气 查询城市当前天气状况，包括温度、湿度、风力、气压等详细信息。 ### 命令 ```bash python3 cs-qweather-alert/scripts/qweather-get-weather-now.py <城市名> [--host API_HOST] [--token TOKEN] ``` ### 示例 ```bash # 查询北京实时天气 python3 cs-qweather-alert/scripts/qweather-get-weather-now.py 北京 # 指定 API Host python3 cs-qweather-alert/scripts/qweather-get-weather-now.py 上海 --host https://md78m2kdwa.re.qweatherapi.com # 通过环境变量设置默认城市 export QWEATHER_CITY=南京 python3 cs-qweather-alert/scripts/qweather-get-weather-now.py ``` ### 输出字段 | 字段 | 说明 | |------|------| | `obsTime` | 数据观测时间 | | `temp` | 温度（°C） | | `feelsLike` | 体感温度（°C） | | `text` | 天气状况文字 | | `windDir / windScale / windSpeed` | 风向 / 风力等级 / 风速（km/h） | | `humidity` | 相对湿度（%） | | `precip` | 过去1小时降水量（mm） | | `pressure` | 大气压强（hPa） | | `vis` | 能见度（km） | | `cloud` | 云量（%） | | `dew` | 露点温度（°C） | ### 示例输出 ``` 🌤️ 北京 实时天气 🕐 2026-04-09 22:48 🌫️ 雾 10°C（体感 8°C） ──────────────────────────────────────── 💨 风力 ······ 西南风 1级 (4 km/h) 💧 湿度 ······ 96% 🌧️ 降水量 ···· 0 mm 🌡️ 气压 ······ 999 hPa 👁️ 能见度 ···· 3 km ☁️ 云量 ······ 91% 🌫️ 露点 ······ 8°C ──────────────────────────────────────── 📡 QWeather | 2026-04-09 22:52 ``` --- ## qweather-get-weather-daily.py — 每日天气预报 查询城市未来 3-30 天天气预报，包含最高/最低温度、天气状况、风力风向、降水概率、紫外线指数、日出日落、月相等信息。 ### 命令 ```bash python3 cs-qweather-alert/scripts/qweather-get-weather-daily.py <城市名> [--days 7] [--host API_HOST] [--token TOKEN] ``` ### 参数 | 参数 | 默认值 | 说明 | |------|--------|------| | `--days` | 7 | 预报天数，支持 3/7/10/15/30 天 | ### 示例 ```bash # 查询北京 7 天预报（默认） python3 cs-qweather-alert/scripts/qweather-get-weather-daily.py 北京 # 查询上海 15 天预报 python3 cs-qweather-alert/scripts/qweather-get-weather-daily.py 上海 --days 15 # 查询广州 30 天预报 python3 cs-qweather-alert/scripts/qweather-get-weather-daily.py 广州 --days 30 ``` ### 输出字段 | 字段 | 说明 | |------|------| | `fxDate` | 预报日期 | | `tempMax / tempMin` | 最高 / 最低温度（°C） | | `textDay / textNight` | 白天 / 夜间天气文字 | | `iconDay / iconNight` | 白天 / 夜间天气图标 | | `windDirDay / windScaleDay` | 白天风向 / 风力等级 | | `precip` | 预报当天总降水量（mm） | | `uvIndex` | 紫外线指数 | | `sunrise / sunset` | 日出 / 日落时间 | | `moonPhase / moonrise / moonset` | 月相 / 月升 / 月落 | | `humidity` | 相对湿度（%） | ### 示例输出 ``` 📅 北京 7天天气预报 ──────────────────────────────────────────────────────────── 今天 04-27 | ⛅多云 | 9°C~22°C | 北风1-3级 | 💧无降水 | 紫外线: 8(极强) 明天 04-28 | ☀️晴 | 9°C~22°C | 北风1-3级 | 💧无降水 | 紫外线: 9(极强) 后天 04-29 | ☀️晴 | 10°C~25°C | 西南风1-3级 | 💧无降水 | 紫外线: 9(极强) 周四 04-30 | ☀️晴 | 15°C~29°C | 西南风1-3级 | 💧无降水 | 紫外线: 9(极强) 周五 05-01 | ⛅多云→☁️阴 | 14°C~25°C | 南风1-3级 | 💧无降水 | 紫外线: 4(较强) 周六 05-02 | ☀️晴 | 12°C~25°C | 西南风1-3级 | 💧无降水 | 紫外线: 9(极强) 周日 05-03 | ☀️晴→☁️阴 | 15°C~28°C | 西南风1-3级 | 💧无降水 | 紫外线: 9(极强) ──────────────────────────────────────────────────────────── 🌅 日出 05:21 / 日落 19:04 🌙 盈凸月 月升 14:46 / 月落 03:11 ──────────────────────────────────────────────────────────── 📡 QWeather | 更新于 2026-04-27 00:04 ``` ### 紫外线指数等级 | 指数 | 描述 | |------|------| | 0-2 | 很弱 / 弱 | | 3-4 | 中等 / 较强 | | 5-6 | 强 / 很强 | | 7-9 | 很强 / 极强 | | 10-12 | 极强（极高） | --- ## qweather-get-weather-alert.py — 天气预警 查询城市当前生效的天气预警信息。 ### 命令 ```bash python3 cs-qweather-alert/scripts/qweather-get-weather-alert.py <城市名> [--host API_HOST] [--token TOKEN] ``` ### 示例 ```bash # 查询北京天气预警 python3 cs-qweather-alert/scripts/qweather-get-weather-alert.py 北京 # 查询多个城市 python3 cs-qweather-alert/scripts/qweather-get-weather-alert.py 上海 ``` ### 预警级别 | 级别 | Emoji | |------|-------| | 极严重（extreme） | 🔴 | | 严重（severe） | 🟠 | | 中等（moderate） | 🟡 | | 轻微（minor） | 🔵 | ### 示例输出 ``` 🌤️ 北京 天气预警 坐标: 39.90499, 116.40529 ──────────────────────────────────────── ✅ 目前没有天气预警 ``` --- ## 公共机制 ### 城市经纬度缓存 - **位置**：`scripts/data/location.json`（脚本同目录下） - **命中**：查询过的城市直接从缓存读取，不调 API - **失效**：永久缓存，人工手动清理文件即可 ### 日志 - **位置**：`/tmp/cslog/` - **命名规则**：`qweather-get-weather-now-YYYYMMDD.log`、`qweather-get-weather-alert-YYYYMMDD.log`、`qweather-get-weather-daily-YYYYMMDD.log` - **脱敏**：JWT Token 只显示前8后4位，其余用 `***` 替代 ### 共享常量 `qweather_utils.py` 中定义了以下共享常量，供所有脚本使用： | 常量 | 说明 | |------|------| | `WEATHER_ICON_EMOJI` | 天气图标代码 → emoji 映射表 | | `DEFAULT_WEATHER_EMOJI` | 未识别图标代码时的默认 emoji | | `UV_DESCRIPTION` | 紫外线指数 → 描述文字映射表 | | `get_weather_emoji(icon_code)` | 根据图标代码返回对应 emoji | | `uv_description(uv_index)` | 根据紫外线指数返回描述文字 | ### 城市名称规则 - 直辖市（如北京、上海）：直接显示城市名 - 省会/地级市（如南京）：直接显示城市名 - 县级市/区县（如浦东新区）：显示「省市区」格式，自动去重 --- ## 依赖 - Python 3（标准库，无需 pip）

path: _meta.json
content: ownerId: kn7d890spcg0pa3axxn2kb5q3182xsze
slug: cs-qweather-alert
version: 1.4.0
publishedAt: 1775670584600

path: scripts/data/location.json
content: {
  "北京": {
    "name": "北京",
    "id": "101010100",
    "lat": "39.90499",
    "lon": "116.40529",
    "adm2": "北京",
    "adm1": "北京市",
    "country": "中国",
    "tz": "Asia/Shanghai",
    "utcOffset": "+08:00",
    "isDst": "0",
    "type": "city",
    "rank": "10",
    "fxLink": "https://www.qweather.com/weather/beijing-101010100.html"
  },
  "上海": {
    "name": "上海",
    "id": "101020100",
    "lat": "31.23171",
    "lon": "121.47264",
    "adm2": "上海",
    "adm1": "上海市",
    "country": "中国",
    "tz": "Asia/Shanghai",
    "utcOffset": "+08:00",
    "isDst": "0",
    "type": "city",
    "rank": "11",
    "fxLink": "https://www.qweather.com/weather/shanghai-101020100.html"
  },
  "南京": {
    "name": "南京",
    "id": "101190101",
    "lat": "32.04155",
    "lon": "118.76741",
    "adm2": "南京",
    "adm1": "江苏省",
    "country": "中国",
    "tz": "Asia/Shanghai",
    "utcOffset": "+08:00",
    "isDst": "0",
    "type": "city",
    "rank": "11",
    "fxLink": "https://www.qweather.com/weather/nanjing-101190101.html"
  },
  "青海": {
    "name": "大通",
    "id": "101150102",
    "lat": "36.93134",
    "lon": "101.68418",
    "adm2": "西宁",
    "adm1": "青海省",
    "country": "中国",
    "tz": "Asia/Shanghai",
    "utcOffset": "+08:00",
    "isDst": "0",
    "type": "city",
    "rank": "35",
    "fxLink": "https://www.qweather.com/weather/datong-101150102.html"
  },
  "西宁": {
    "name": "西宁",
    "id": "101150101",
    "lat": "36.62318",
    "lon": "101.77892",
    "adm2": "西宁",
    "adm1": "青海省",
    "country": "中国",
    "tz": "Asia/Shanghai",
    "utcOffset": "+08:00",
    "isDst": "0",
    "type": "city",
    "rank": "21",
    "fxLink": "https://www.qweather.com/weather/xining-101150101.html"
  },
  "乌鲁木齐": {
    "name": "乌鲁木齐",
    "id": "101130101",
    "lat": "43.79282",
    "lon": "87.61773",
    "adm2": "乌鲁木齐",
    "adm1": "新疆维吾尔自治区",
    "country": "中国",
    "tz": "Asia/Shanghai",
    "utcOffset": "+08:00",
    "isDst": "0",
    "type": "city",
    "rank": "11",
    "fxLink": "https://www.qweather.com/weather/urumqi-101130101.html"
  },
  "西安": {
    "name": "西安",
    "id": "101110101",
    "lat": "34.34321",
    "lon": "108.93965",
    "adm2": "西安",
    "adm1": "陕西省",
    "country": "中国",
    "tz": "Asia/Shanghai",
    "utcOffset": "+08:00",
    "isDst": "0",
    "type": "city",
    "rank": "11",
    "fxLink": "https://www.qweather.com/weather/xi'an-101110101.html"
  },
  "兰州": {
    "name": "兰州",
    "id": "101160101",
    "lat": "36.05804",
    "lon": "103.82356",
    "adm2": "兰州",
    "adm1": "甘肃省",
    "country": "中国",
    "tz": "Asia/Shanghai",
    "utcOffset": "+08:00",
    "isDst": "0",
    "type": "city",
    "rank": "11",
    "fxLink": "https://www.qweather.com/weather/lanzhou-101160101.html"
  },
  "银川": {
    "name": "银川",
    "id": "101170101",
    "lat": "38.46637",
    "lon": "106.27818",
    "adm2": "银川",
    "adm1": "宁夏回族自治区",
    "country": "中国",
    "tz": "Asia/Shanghai",
    "utcOffset": "+08:00",
    "isDst": "0",
    "type": "city",
    "rank": "21",
    "fxLink": "https://www.qweather.com/weather/yinchuan-101170101.html"
  },
  "鄂尔多斯": {
    "name": "鄂尔多斯",
    "id": "101080701",
    "lat": "39.60874",
    "lon": "109.78247",
    "adm2": "鄂尔多斯",
    "adm1": "内蒙古自治区",
    "country": "中国",
    "tz": "Asia/Shanghai",
    "utcOffset": "+08:00",
    "isDst": "0",
    "type": "city",
    "rank": "25",
    "fxLink": "https://www.qweather.com/weather/ordos-101080701.html"
  },
  "杭锦旗": {
    "name": "杭锦旗",
    "id": "101080709",
    "lat": "39.83179",
    "lon": "108.73632",
    "adm2": "鄂尔多斯",
    "adm1": "内蒙古自治区",
    "country": "中国",
    "tz": "Asia/Shanghai",
    "utcOffset": "+08:00",
    "isDst": "0",
    "type": "city",
    "rank": "45",
    "fxLink": "https://www.qweather.com/weather/hanggin-banner-101080709.html"
  }
}

path: scripts/qweather-get-weather-alert.py
content: #!/usr/bin/env python3
"""
和风天气预警查询脚本

用法:
    python qweather-get-weather-alert.py <城市名> [API_HOST] [JWT_TOKEN]

环境变量:
    QWEATHER_API_HOST  和风天气 API Host（如 https://api.qweather.com）
    QWEATHER_CITY      默认城市名称（可选）

Token 配置:
    默认从 ~/.myjwtkey/last-token.dat 读取，也可通过 --token 参数指定

示例:
    python qweather-get-weather-alert.py 北京
"""

import sys
import os
import argparse

# 将脚本所在目录加入 import 路径，确保从任意目录运行都能找到 qweather_utils
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
    sys.path.insert(0, _SCRIPT_DIR)

# 导入公共模块
from qweather_utils import (
    get_token,
    get_host,
    api_get,
    resolve_city,
    format_timestamp,
    set_cache_dir,
)

# ============ 预警 Emoji 映射（预警专用）=============

SEVERITY_EMOJI = {
    "extreme": "🔴",
    "severe":   "🟠",
    "moderate": "🟡",
    "minor":    "🔵",
    "unknown":  "⚪",
}

SEVERITY_NAME = {
    "extreme": "极严重",
    "severe":   "严重",
    "moderate": "中等",
    "minor":    "轻微",
    "unknown":  "未知",
}

EVENT_TYPE_EMOJI = {
    "1006": "💨",  # 大风
    "2001": "🌡️", # 高温
    "2002": "🌀", # 台风
    "2003": "🌧️", # 暴雨
    "2004": "❄️",  # 暴雪
    "2005": "🥶",  # 寒潮
    "2006": "🌫️", # 大雾
    "2007": "😷", # 霾
    "2008": "⚡", # 雷电
    "2009": "🧊", # 冰雹
    "2010": "🌪️", # 沙尘暴
    "2011": "🏜️", # 干旱
    "2013": "🌊", # 洪涝
    "2014": "💧", # 渍涝
    "2015": "🏙️", # 城市高温
    "5001": "⛰️", # 地质灾害
    "5002": "🏞️", # 洪水
    "5003": "🪨", # 崩塌
    "5004": "⛰️", # 滑坡
    "5005": "🌋", # 泥石流
    "5006": "💦", # 水文
    "5007": "🏜️", # 干旱
    "6001": "🔥", # 森林火险
    "6002": "🔥", # 草原火险
    "6003": "🧊", # 冰冻
    "6004": "🥶", # 低温冻害
    "6005": "❄️",  # 雪灾
}

DEFAULT_EVENT_EMOJI = "⚠️"


# ============ API 查询 ============

def get_weather_alerts(host: str, token: str, lat: str, lon: str) -> dict:
    """调用天气预警 API。"""
    url = f"{host}/weatheralert/v1/current/{lat}/{lon}"
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/json",
    }
    return api_get(url, headers, log_prefix="qweather-get-weather-alert")


# ============ 格式化输出 ============

def get_event_emoji(event_code: str) -> str:
    """根据事件代码获取 emoji。"""
    return EVENT_TYPE_EMOJI.get(event_code, DEFAULT_EVENT_EMOJI)


def get_severity_emoji(severity: str) -> str:
    """根据严重程度获取 emoji。"""
    return SEVERITY_EMOJI.get(severity.lower(), SEVERITY_EMOJI["unknown"])


def format_alert(alert: dict) -> str:
    """将单条预警格式化为易读文本。"""
    severity_emoji = get_severity_emoji(alert.get("severity", "unknown"))
    severity_name = SEVERITY_NAME.get(alert.get("severity", "unknown"), "未知")
    event_code = alert.get("eventType", {}).get("code", "")
    event_name = alert.get("eventType", {}).get("name", "未知预警")
    event_emoji = get_event_emoji(event_code)

    headline = alert.get("headline", "无标题")
    description = alert.get("description", "无详细描述")
    instruction = alert.get("instruction", "")
    sender = alert.get("senderName", "未知来源")
    effective = format_timestamp(alert.get("effectiveTime", ""))
    expire = format_timestamp(alert.get("expireTime", ""))

    lines = [
        f"{severity_emoji} {event_emoji} 【{event_name}】{severity_emoji}",
        f"{'─' * 40}",
        f"⚡ 预警级别: {severity_name}",
        f"📢 预警标题: {headline}",
        "",
    ]

    if description and description != "无详细描述":
        lines.append(f"📋 详情: {description}")
        lines.append("")

    if instruction:
        instr_lines = instruction.strip().split("\n")
        formatted_instr = "\n".join(f"   • {l.strip()}" for l in instr_lines if l.strip())
        lines.append(f"🛡️  防御指南:")
        lines.append(formatted_instr)
        lines.append("")

    lines.append(f"🏛️  发布机构: {sender}")
    if effective:
        lines.append(f"⏰ 生效时间: {effective}")
    if expire:
        lines.append(f"⏰ 失效时间: {expire}")

    return "\n".join(lines)


def format_output(city_name: str, lat: str, lon: str, alert_result: dict) -> str:
    """将完整查询结果格式化为最终输出文本。"""
    metadata = alert_result.get("metadata", {})
    zero_result = metadata.get("zeroResult", False)

    header = [
        f"🌤️  {city_name} 天气预警",
        f"坐标: {lat}, {lon}",
        f"{'─' * 40}",
    ]

    if zero_result:
        header.append("✅ 目前没有天气预警")
        attr = metadata.get("attributions", [])
        if attr:
            header.append("")
            header.append(f"📡 数据来源: {attr[0] if attr else '和风天气'}")
        return "\n".join(header)

    alerts = alert_result.get("alerts", [])
    if not alerts:
        header.append("✅ 目前没有天气预警")
        return "\n".join(header)

    header.append(f"⚠️  共 {len(alerts)} 条预警")
    header.append("")

    lines = header[:]
    for i, alert in enumerate(alerts, 1):
        lines.append(f"【预警 {i}/{len(alerts)}】")
        lines.append(format_alert(alert))
        lines.append("")

    attr = metadata.get("attributions", [])
    if attr:
        lines.append(f"{'─' * 40}")
        for a in attr:
            lines.append(f"📡 {a}")

    return "\n".join(lines)


# ============ 入口 ============

def main():
    # 注入脚本目录，用于确定缓存路径
    set_cache_dir(_SCRIPT_DIR)

    parser = argparse.ArgumentParser(
        description="和风天气预警查询工具",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument("city", nargs="?", default=None, help="城市名称（如北京、上海）")
    parser.add_argument("--host", dest="host", default=None,
                        help="API Host（也可设置环境变量 QWEATHER_API_HOST）")
    parser.add_argument("--token", dest="token", default=None,
                        help="JWT Token（默认从 ~/.myjwtkey/last-token.dat 读取）")
    args = parser.parse_args()

    city_name = args.city or os.environ.get("QWEATHER_CITY", "").strip()
    if not city_name:
        print("错误: 请提供城市名称作为参数，或设置环境变量 QWEATHER_CITY", file=sys.stderr)
        sys.exit(1)

    host = get_host("QWEATHER_API_HOST", args.host, "API Host")
    [REDACTED_SECRET])

    print(f"正在查询城市: {city_name} ...", file=sys.stderr)

    result = resolve_city(city_name, host, token)
    if not result:
        sys.exit(1)
    _, lat, lon, full_city_name, _ = result

    print(f"正在获取天气预警 ...", file=sys.stderr)
    alert_result = get_weather_alerts(host, token, lat, lon)

    output = format_output(full_city_name, lat, lon, alert_result)
    print()
    print(output)


if __name__ == "__main__":
    main()

path: scripts/qweather-get-weather-daily.py
content: #!/usr/bin/env python3
"""
和风天气每日预报查询脚本

用法:
    python qweather-get-weather-daily.py <城市名> [--days 7] [--host API_HOST] [--token TOKEN]

环境变量:
    QWEATHER_API_HOST  和风天气 API Host（如 https://xxx.re.qweatherapi.com）
    QWEATHER_CITY      默认城市名称（可选）

Token 配置:
    默认从 ~/.myjwtkey/last-token.dat 读取，也可通过 --token 参数指定

示例:
    python qweather-get-weather-daily.py 北京                    # 查询7天预报
    python qweather-get-weather-daily.py 北京 --days 15          # 查询15天预报
    python qweather-get-weather-daily.py 上海 --days 30         # 查询30天预报
    python qweather-get-weather-daily.py 广州 --days 3           # 查询3天预报
"""

import sys
import os
import argparse
import datetime as dt

# 将脚本所在目录加入 import 路径，确保从任意目录运行都能找到 qweather_utils
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
    sys.path.insert(0, _SCRIPT_DIR)

# 导入公共模块
from qweather_utils import (
    get_token,
    get_host,
    api_get,
    resolve_city,
    format_timestamp,
    set_cache_dir,
    get_weather_emoji,
    uv_description,
)


# ============ 本地工具函数 ============

def format_weekday(date_str: str) -> str:
    """将 YYYY-MM-DD 格式的日期转换为星期几。"""
    try:
        dt_obj = dt.datetime.strptime(date_str, "%Y-%m-%d")
        weekdays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
        return weekdays[dt_obj.weekday()]
    except Exception:
        return ""


# ============ API 查询 ============

def get_weather_daily(host: str, token: str, location: str, days: str) -> dict:
    """
    调用每日天气预报 API（v7/weather/{days}）。
    location: 和风 LocationID（如 101010100）或 "经度,纬度"
    days: 预报天数，支持 3d/7d/10d/15d/30d
    """
    url = f"{host}/v7/weather/{days}?location={location}"
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/json",
    }
    return api_get(url, headers, log_prefix="qweather-get-weather-daily")


# ============ 格式化输出 ============

def format_day_line(day: dict, index: int, total: int) -> str:
    """将单日预报格式化为一行。"""
    fx_date = day.get("fxDate", "")
    weekday = format_weekday(fx_date)
    temp_max = day.get("tempMax", "--")
    temp_min = day.get("tempMin", "--")
    icon_day = day.get("iconDay", "900")
    icon_night = day.get("iconNight", "900")
    text_day = day.get("textDay", "")
    text_night = day.get("textNight", "")
    wind_dir_day = day.get("windDirDay", "")
    wind_scale_day = day.get("windScaleDay", "")
    precip = day.get("precip", "0.0")
    uv_index = day.get("uvIndex", "")

    day_emoji = get_weather_emoji(icon_day)
    night_emoji = get_weather_emoji(icon_night)

    # 今天/明天/后天标记
    if index == 0:
        day_label = "今天"
    elif index == 1:
        day_label = "明天"
    elif index == 2:
        day_label = "后天"
    else:
        day_label = weekday

    # 日间图标 + 天气 + 夜间图标
    weather_str = f"{day_emoji}{text_day}"
    if text_night != text_day:
        weather_str += f"→{night_emoji}{text_night}"

    # 降水
    precip_float = float(precip) if precip not in ("", None) else 0.0
    if precip_float > 0:
        precip_str = f"🌧️{precip}mm"
    else:
        precip_str = "💧无降水"

    # 紫外线
    uv_str = f"紫外线: {uv_index}({uv_description(uv_index)})" if uv_index else ""

    parts = [
        f"{day_label} {fx_date[5:]}",
        weather_str,
        f"{temp_min}°C~{temp_max}°C",
        f"{wind_dir_day}{wind_scale_day}级",
        precip_str,
    ]
    if uv_str:
        parts.append(uv_str)

    return " | ".join(parts)


def format_output(city_name: str, lat: str, lon: str, daily_result: dict, days_label: str) -> str:
    """将完整查询结果格式化为最终输出文本。"""
    daily_list = daily_result.get("daily", [])
    refer = daily_result.get("refer", {})
    update_time = format_timestamp(daily_result.get("updateTime", ""))

    lines = [
        f"📅 {city_name} {days_label}天气预报",
        f"{'─' * 60}",
    ]

    # 每日详情
    for i, day in enumerate(daily_list):
        lines.append(format_day_line(day, i, len(daily_list)))
        lines.append("")

    # 日出日落 / 月相（只显示第一天的，因为后续可能重复）
    if daily_list:
        first = daily_list[0]
        sunrise = first.get("sunrise", "")
        sunset = first.get("sunset", "")
        moonrise = first.get("moonrise", "")
        moonset = first.get("moonset", "")
        moon_phase = first.get("moonPhase", "")
        lines.append(f"{'─' * 60}")
        astro_parts = []
        if sunrise and sunset:
            astro_parts.append(f"🌅 日出 {sunrise} / 日落 {sunset}")
        if moon_phase:
            astro_parts.append(f"🌙 {moon_phase}")
            if moonrise:
                astro_parts.append(f"   月升 {moonrise} / 月落 {moonset}")
        if astro_parts:
            lines.append("  ".join(astro_parts))

    # 数据来源
    sources = refer.get("sources", [])
    if sources:
        lines.append(f"{'─' * 60}")
        lines.append(f"📡 {', '.join(sources)} | 更新于 {update_time}")

    return "\n".join(lines)


# ============ 入口 ============

def main():
    # 注入脚本目录，用于确定缓存路径
    set_cache_dir(_SCRIPT_DIR)

    parser = argparse.ArgumentParser(
        description="和风天气每日预报查询工具",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument("city", nargs="?", default=None, help="城市名称（如北京、上海）")
    parser.add_argument("--days", dest="days", default="7",
                        choices=["3", "7", "10", "15", "30"],
                        help="预报天数（3/7/10/15/30天，默认7天）")
    parser.add_argument("--host", dest="host", default=None,
                        help="API Host（也可设置环境变量 QWEATHER_API_HOST）")
    parser.add_argument("--token", dest="token", default=None,
                        help="JWT Token（默认从 ~/.myjwtkey/last-token.dat 读取）")
    args = parser.parse_args()

    city_name = args.city or os.environ.get("QWEATHER_CITY", "").strip()
    if not city_name:
        print("错误: 请提供城市名称作为参数，或设置环境变量 QWEATHER_CITY", file=sys.stderr)
        sys.exit(1)

    host = get_host("QWEATHER_API_HOST", args.host, "API Host")
    [REDACTED_SECRET])

    days_map = {"3": "3天", "7": "7天", "10": "10天", "15": "15天", "30": "30天"}
    days_label = days_map.get(args.days, "7天")

    print(f"正在查询城市: {city_name}（{days_label}预报）...", file=sys.stderr)

    result = resolve_city(city_name, host, token)
    if not result:
        sys.exit(1)
    _, lat, lon, full_city_name, location_id = result

    # API 的 days 参数格式
    api_days = args.days + "d"

    print(f"正在获取天气预报 ...", file=sys.stderr)
    weather_result = get_weather_daily(host, token, location_id, api_days)

    output = format_output(full_city_name, lat, lon, weather_result, days_label)
    print()
    print(output)


if __name__ == "__main__":
    main()

path: scripts/qweather-get-weather-now.py
content: #!/usr/bin/env python3
"""
和风天气实况查询脚本

用法:
    python qweather-get-weather-now.py <城市名> [API_HOST] [JWT_TOKEN]

环境变量:
    QWEATHER_API_HOST  和风天气 API Host（如 https://api.qweather.com）
    QWEATHER_CITY      默认城市名称（可选）

Token 配置:
    默认从 ~/.myjwtkey/last-token.dat 读取，也可通过 --token 参数指定

示例:
    python qweather-get-weather-now.py 北京
"""

import sys
import os
import argparse

# 将脚本所在目录加入 import 路径，确保从任意目录运行都能找到 qweather_utils
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
    sys.path.insert(0, _SCRIPT_DIR)

# 导入公共模块
from qweather_utils import (
    get_token,
    get_host,
    api_get,
    resolve_city,
    format_timestamp,
    set_cache_dir,
    get_weather_emoji,
)


# ============ API 查询 ============

def get_weather_now(host: str, token: str, location_id: str) -> dict:
    """
    调用实时天气 API（v7/weather/now）。
    location_id: 和风 LocationID（如 101010100）
    """
    url = f"{host}/v7/weather/now?location={location_id}"
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/json",
    }
    return api_get(url, headers, log_prefix="qweather-get-weather-now")


# ============ 格式化输出 ============

def format_wind_info(wind_dir: str, wind_scale: str, wind_speed: str) -> str:
    """格式化风力信息。"""
    return f"{wind_dir} {wind_scale}级 ({wind_speed} km/h)"


def format_output(city_name: str, lat: str, lon: str, weather_result: dict) -> str:
    """将完整查询结果格式化为最终输出文本。"""
    now = weather_result.get("now", {})
    refer = weather_result.get("refer", {})

    obs_time = format_timestamp(now.get("obsTime", ""))
    temp = now.get("temp", "--")
    feels_like = now.get("feelsLike", "--")
    icon_code = now.get("icon", "900")
    text = now.get("text", "未知")
    weather_emoji = get_weather_emoji(icon_code)
    wind_info = format_wind_info(
        now.get("windDir", "--"),
        now.get("windScale", "--"),
        now.get("windSpeed", "--")
    )
    humidity = now.get("humidity", "--")
    precip = now.get("precip", "--")
    pressure = now.get("pressure", "--")
    vis = now.get("vis", "--")
    cloud = now.get("cloud", "")
    dew = now.get("dew", "")
    update_time = format_timestamp(weather_result.get("updateTime", ""))

    lines = [
        f"🌤️  {city_name} 实时天气",
        f"🕐 {obs_time}  {weather_emoji} {text}  {temp}°C（体感 {feels_like}°C）",
        f"{'─' * 40}",
        "",
        f"💨 风力 ······ {wind_info}",
        f"💧 湿度 ······ {humidity}%",
    ]

    if precip and precip not in ("0.0", "0"):
        lines.append(f"🌧️  降水量 ···· {precip} mm")
    else:
        lines.append(f"🌧️  降水量 ···· 0 mm")

    lines.extend([
        f"🌡️  气压 ······ {pressure} hPa",
        f"👁️  能见度 ···· {vis} km",
    ])

    if cloud:
        lines.append(f"☁️  云量 ······ {cloud}%")
    if dew:
        lines.append(f"🌫️  露点 ······ {dew}°C")

    lines.append("")
    lines.append(f"{'─' * 40}")
    sources = refer.get("sources", [])
    if sources:
        lines.append(f"📡 {', '.join(sources)} | {update_time}")

    return "\n".join(lines)


# ============ 入口 ============

def main():
    # 注入脚本目录，用于确定缓存路径
    set_cache_dir(_SCRIPT_DIR)

    parser = argparse.ArgumentParser(
        description="和风天气实况查询工具",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument("city", nargs="?", default=None, help="城市名称（如北京、上海）")
    parser.add_argument("--host", dest="host", default=None,
                        help="API Host（也可设置环境变量 QWEATHER_API_HOST）")
    parser.add_argument("--token", dest="token", default=None,
                        help="JWT Token（默认从 ~/.myjwtkey/last-token.dat 读取）")
    args = parser.parse_args()

    city_name = args.city or os.environ.get("QWEATHER_CITY", "").strip()
    if not city_name:
        print("错误: 请提供城市名称作为参数，或设置环境变量 QWEATHER_CITY", file=sys.stderr)
        sys.exit(1)

    host = get_host("QWEATHER_API_HOST", args.host, "API Host")
    [REDACTED_SECRET])

    print(f"正在查询城市: {city_name} ...", file=sys.stderr)

    result = resolve_city(city_name, host, token)
    if not result:
        sys.exit(1)
    _, lat, lon, full_city_name, location_id = result

    print(f"正在获取实时天气 ...", file=sys.stderr)
    weather_result = get_weather_now(host, token, location_id)

    output = format_output(full_city_name, lat, lon, weather_result)
    print()
    print(output)


if __name__ == "__main__":
    main()

path: scripts/qweather_utils.py
content: #!/usr/bin/env python3
"""
和风天气工具模块 - 公共函数库

提供所有和风天气脚本共用的工具函数。
"""

import sys
import os
import json
import gzip
import urllib.request
import urllib.error
import urllib.parse
import datetime as dt

# 尝试加载 dotenv（标准方式读取 .env 文件）
try:
    import dotenv
    dotenv.load_dotenv(os.path.expanduser("~/.openclaw/.env"), override=True)
except ImportError:
    pass


# ============ 天气图标 emoji 映射 ============

WEATHER_ICON_EMOJI = {
    "100": "☀️",   "100n": "🌙",
    "101": "⛅",  "102": "⛅",  "103": "🌤️",  "104": "☁️",
    "200": "🌬️",  "201": "🌬️",  "202": "🌬️",  "203": "🌬️",
    "204": "💨",  "205": "💨",  "206": "💨",  "207": "💨",
    "208": "💨",  "209": "💨",  "210": "🌬️",  "211": "🌬️",
    "212": "🌬️",  "213": "💨",
    "300": "🌦️",  "301": "🌧️",  "302": "⛈️",  "303": "⛈️",
    "304": "⛈️",  "305": "🌧️",  "306": "🌧️",  "307": "🌧️",
    "308": "🌧️",  "309": "🌧️",  "310": "🌧️",  "311": "🌧️",
    "312": "🌧️",  "313": "🌧️",  "314": "🌧️",  "315": "🌧️",
    "316": "🌧️",  "317": "🌧️",  "318": "🌧️",  "399": "🌧️",
    "400": "🌨️",  "401": "🌨️",  "402": "❄️",  "403": "❄️",
    "404": "🌨️",  "405": "🌨️",  "406": "🌨️",  "407": "🌨️",
    "408": "🌨️",  "409": "🌨️",  "410": "🌨️",  "499": "🌨️",
    "500": "🌫️",  "501": "🌫️",  "502": "🌫️",  "503": "🌫️",
    "504": "🌫️",  "507": "😷",  "508": "😷",  "509": "😷",
    "510": "🌫️",  "511": "🌫️",  "512": "🌫️",  "513": "🌫️",
    "514": "🌫️",  "515": "🌫️",
    "800": "🌙",  "801": "🌤️",  "802": "⛅",  "803": "⛅",  "804": "☁️",
    "900": "❓",
}

DEFAULT_WEATHER_EMOJI = "🌡️"

# ============ 紫外线指数等级 ============

UV_DESCRIPTION = {
    "0": "很弱",
    "1": "很弱",
    "2": "弱",
    "3": "中等",
    "4": "较强",
    "5": "强",
    "6": "很强",
    "7": "很强",
    "8": "极强",
    "9": "极强",
    "10": "极强",
    "11": "很强",
    "12": "极强",
}


def get_weather_emoji(icon_code: str) -> str:
    """根据天气图标代码获取 emoji。"""
    return WEATHER_ICON_EMOJI.get(icon_code, DEFAULT_WEATHER_EMOJI)


def uv_description(uv_index: str) -> str:
    """获取紫外线指数描述。"""
    return UV_DESCRIPTION.get(str(uv_index), "未知")

def get_token(arg_val: str | None) -> str:
    """获取 JWT Token。优先级：1) 命令行参数 2) ~/.myjwtkey/last-token.dat"""
    if arg_val:
        return arg_val.strip()

    token_file = os.path.expanduser("~/.myjwtkey/last-token.dat")
    if os.path.exists(token_file):
        with open(token_file, "r", encoding="utf-8") as f:
            [REDACTED_SECRET]).strip()
        if [REDACTED_SECRET] token

    print(f"错误: 缺少 JWT Token", file=sys.stderr)
    print(f"  请通过以下方式提供：1) --token 参数 2) ~/.myjwtkey/last-token.dat", file=sys.stderr)
    sys.exit(1)


# ============ Host / Env 读取 ============

def get_host(env_var: str, arg_val: str | None, description: str) -> str:
    """获取 API Host，自动补上 https:// 前缀。"""
    val = arg_val or os.environ.get(env_var)
    if not val:
        print(f"错误: 缺少 {description}", file=sys.stderr)
        print(f"  请设置环境变量 {env_var} 或作为命令行参数传入", file=sys.stderr)
        sys.exit(1)
    val = val.strip().rstrip("/")
    if not val.startswith(("http://", "https://")):
        val = "https://" + val
    return val


# ============ 日志 ============

def _mask_token(token: str) -> str:
    """脱敏 JWT token，只显示前8后4位。"""
    if len(token) <= 12:
        return "****"
    return token[:8] + "..." + token[-4:]


def _decompress(body: bytes) -> bytes:
    """尝试解压 gzip，如果失败则返回原始 bytes。"""
    try:
        return gzip.decompress(body)
    except Exception:
        return body


def api_get(url: str, headers: dict, *, log_prefix: str = "api_get") -> dict:
    """
    发送 GET 请求，返回 JSON。支持 gzip 自动解压。
    敏感信息自动脱敏后写入日志。
    """
    log_dir = "/tmp/cslog"
    os.makedirs(log_dir, exist_ok=True)
    today = dt.datetime.now().strftime("%Y%m%d")
    log_path = os.path.join(log_dir, f"{log_prefix}-{today}.log")
    ts = dt.datetime.now().strftime("%H:%M:%S")

    auth_header = headers.get("Authorization", "")
    raw_token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else auth_header
    masked_token = _mask_token(raw_token)

    # 构造可粘贴的 curl 命令（token 已脱敏）
    curl_cmd = (
        f"curl -X GET --compressed \\ \n"
        f" -H 'Authorization: Bearer {masked_token}' \\ \n"
        f" -H 'Accept: application/json' \\ \n"
        f" -H 'Accept-Encoding: gzip' \\ \n"
        f" '{url}'"
    )

    with open(log_path, "a", encoding="utf-8") as log_f:
        log_f.write(f"\n[{ts}] ===== api_get =====\n")
        log_f.write(f"{curl_cmd}\n")
        log_f.write(f"  ────────────────────────────────\n")

    req_headers = dict(headers)
    req_headers["Accept-Encoding"] = "gzip"
    req = urllib.request.Request(url, headers=req_headers)
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            raw = resp.read()
            body = _decompress(raw)
            body_str = body.decode("utf-8", errors="replace")
            with open(log_path, "a", encoding="utf-8") as log_f:
                log_f.write(f"  响应 [200 OK]: {body_str[:2000]}\n")
            return json.loads(body_str)
    except urllib.error.HTTPError as e:
        raw = e.read()
        body = _decompress(raw)
        body_str = body.decode("utf-8", errors="replace")
        with open(log_path, "a", encoding="utf-8") as log_f:
            log_f.write(f"  HTTP 错误: {e.code} {e.reason}\n")
            log_f.write(f"  响应内容: {body_str[:2000]}\n")
        print(f"HTTP 错误: {e.code} {e.reason}", file=sys.stderr)
        print(f"响应内容: {body_str[:500]}", file=sys.stderr)
        print(f"已记录到: {log_path}", file=sys.stderr)
        sys.exit(1)
    except urllib.error.URLError as e:
        with open(log_path, "a", encoding="utf-8") as log_f:
            log_f.write(f"  网络错误: {e.reason}\n")
        print(f"网络错误: {e.reason}", file=sys.stderr)
        sys.exit(1)


# ============ 城市经纬度缓存 ============

# 缓存路径由调用者通过 set_cache_dir() 注入
_cache_dir: str | None = None


def set_cache_dir(script_dir: str) -> None:
    """由调用脚本在入口处注入其所在目录，用于确定缓存文件位置。"""
    global _cache_dir
    _cache_dir = script_dir


def _get_cache_path() -> str:
    """获取城市经纬度缓存文件路径。"""
    if _cache_dir:
        data_dir = os.path.join(_cache_dir, "data")
    else:
        # fallback：使用本模块所在目录
        data_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
    os.makedirs(data_dir, exist_ok=True)
    return os.path.join(data_dir, "location.json")


def _load_cache() -> dict:
    """加载城市缓存。"""
    path = _get_cache_path()
    if not os.path.exists(path):
        return {}
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except (json.JSONDecodeError, IOError):
        return {}


def _save_cache(cache: dict) -> None:
    """保存城市缓存到文件。"""
    path = _get_cache_path()
    with open(path, "w", encoding="utf-8") as f:
        json.dump(cache, f, ensure_ascii=False, indent=2)


def lookup_city(host: str, token: str, city_name: str) -> dict | None:
    """
    查询城市经纬度。优先从本地缓存读取，缓存不存在则调用 API 并写入缓存。
    """
    cache = _load_cache()

    if city_name in cache:
        print(f"[缓存命中] {city_name} -> {cache[city_name].get('lat')}, {cache[city_name].get('lon')}", file=sys.stderr)
        return cache[city_name]

    print(f"[缓存未命中] 正在查询 API: {city_name} ...", file=sys.stderr)

    url = f"{host}/geo/v2/city/lookup?location={urllib.parse.quote(city_name)}&number=1"
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/json",
    }
    result = api_get(url, headers, log_prefix="qweather-geo")
    if result.get("code") != "200":
        print(f"城市查询失败: code={result.get('code')}", file=sys.stderr)
        return None
    locations = result.get("location", [])
    if not locations:
        print(f"未找到城市: {city_name}", file=sys.stderr)
        return None

    city_info = locations[0]

    cache[city_name] = city_info
    _save_cache(cache)
    print(f"[已写入缓存] {city_name}", file=sys.stderr)

    return city_info


# ============ 通用工具函数 ============

def format_timestamp(ts: str) -> str:
    """将 ISO 时间格式化为可读字符串，去掉 T 和时区后缀。"""
    if not ts:
        return ""
    ts = ts.replace("T", " ").replace("+08:00", "").replace("Z", "+00:00")
    return ts.strip()


def _build_display_name(city_info: dict, city_name: str) -> str:
    """
    构建城市显示名。规则：
    - 若 adm2 等于 name，说明是直辖市/省会主城区，显示「X市」或「X省」格式
    - 若不同（如浦东)，显示「X省X市X区」格式（去掉重复后缀）
    """
    adm2 = city_info.get('adm2', '')
    name = city_info.get('name', city_name)
    adm1 = city_info.get('adm1', '')

    # 去掉 adm1 中的「省」「市」等后缀，只留地名部分
    def strip_suffix(region: str, suffix: str) -> str:
        for s in (suffix, suffix[:-1] if suffix else ''):
            if region.endswith(s):
                return region[:-len(s)]
        return region

    # 去掉「市」「省」「自治区」「特别行政区」等后缀
    for suffix in ('市', '省', '自治区', '特别行政区', '壮族自治区', '回族自治区', '维吾尔自治区'):
        adm1 = strip_suffix(adm1, suffix)

    # 若 adm2 和 name 相同（主城区），只显示「X市」
    if adm2 == name:
        # 排除直辖市（adm1 == name 的情况，如「北京市」->「北京」本身）
        if adm1 == name:
            return name  # 直辖市/省会直接用名字
        return f"{adm1}{name}"  # 如「江苏省南京市」->「江苏南京」

    # adm2 和 name 不同，显示「X省X市X区」格式，去掉重复
    # 例如「上海市浦东新区」：adm1=上海，adm2=浦东，name=浦东
    # 去掉 adm1 的「市」后=上海，去掉 adm2 的「区」后=浦东，显示「上海浦东」
    for suffix in ('区', '县', '市'):
        adm2_clean = strip_suffix(adm2, suffix)
    parts = [p for p in (adm1, adm2_clean, name) if p and p not in (adm1,)]
    # 简化：如果 name 已经是 adm2 的一部分，跳过重复
    if adm2_clean and name.startswith(adm2_clean):
        parts = [p for p in parts if p != name]
        result = "".join(parts) + name
    else:
        result = "".join(parts)
    return result if result else name


def resolve_city(city_name: str, host: str, token: str) -> tuple[dict, str, str, str, str] | None:
    """
    查询城市信息，返回 (city_info, lat, lon, full_city_name, location_id)。
    full_city_name 为美化后的城市显示名。
    失败返回 None。
    """
    city_info = lookup_city(host, token, city_name)
    if not city_info:
        return None

    lat = city_info["lat"]
    lon = city_info["lon"]
    location_id = city_info.get("id", "")
    full_city_name = _build_display_name(city_info, city_name)

    print(
        f"找到: {full_city_name}（{city_info.get('adm1', '')}）{lat}, {lon} [{location_id}]",
        file=sys.stderr,
    )
    return city_info, lat, lon, full_city_name, location_id


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)