Claude 工作提醒自动化:香港天文台四个接口三个「更新时间」,警告到期了却还在生效

在这里插入图片描述

1. 先说结论

今天上午香港天文台有一个雷暴警告生效。我把四个接口在同一时刻各问一遍,一小时后又问一遍:

问题实测结果
四个接口的「更新时间」一致吗?不一致。第一次快照 11:05 / 11:05 / 11:45 / 12:02——三个时间戳、相差 57 分钟;一小时后变成两个、相差 1 分钟
「警告类别」和「警告级别」是同一个字段吗?不是。11 个属性名对应 21 个级别代号,黄/红/黑暴雨共用一个属性名
到期时间过了,警告会消失吗?不会。第一次读到 13:45 到期,13:55 再抓它还在——13:25 又被延长了一次

三个坑:读错接口 = 读到半小时到一小时的旧状态;用属性名判级别 = 红色暴雨当成黄色;把 expireTime 当「事件结束时间」= 定时任务等不到它预期的状态。

2. 四个接口与取数代码

天文台的天气 API 是同一个地址、用 dataType 区分数据集。与警告相关的有四个:

dataType官方名称返回什么
warnsum天气警告一览每个警告一条:codeactionCodeissueTimeexpireTimeupdateTime
warningInfo详细天气警告资讯details[],含 contents[] 人类可读全文
swt特别天气提示swt[],一句提示 + updateTime
rhrread本港地区天气报告气温/湿度/雨量,外加 warningMessage[] 一句话警告
import json, subprocess, time

API = "https://data.weather.gov.hk/weatherAPI/opendata/weather.php"

def fetch(data_type, lang="tc", timeout=30):
    """四个数据集共用同一个入口,只换 dataType。"""
    url = f"{API}?dataType={data_type}&lang={lang}"
    raw = subprocess.run(["curl", "-sL", "-m", str(timeout), url],
                         capture_output=True, text=True).stdout
    return json.loads(raw.lstrip("\ufeff"))

FIELDS = ["warnsum", "warningInfo", "swt", "rhrread"]

def snapshot(tag):
    """抓一次快照:四个口径全部落盘,并记下抓取时刻。"""
    taken = time.strftime("%Y-%m-%dT%H:%M:%S+08:00")
    for dt in FIELDS:
        with open(f"{tag}_{dt}.json", "w", encoding="utf-8") as f:
            json.dump(fetch(dt), f, ensure_ascii=False, indent=1)
    return {"tag": tag, "taken_at": taken}

def update_times(s):
    """同一时刻,四个口径各自报的「更新时间」。"""
    return {
        "warnsum": max((v.get("updateTime", "") for v in s["warnsum"].values()),
                       default=None),
        "warningInfo": max((d.get("updateTime", "") for d in s["warningInfo"]["details"]),
                           default=None),
        "swt": max((x.get("updateTime", "") for x in (s["swt"].get("swt") or [])),
                   default=None),
        "rhrread": s["rhrread"].get("updateTime"),
    }

这段代码可以直接拿走用,建议收藏备用——把 FIELDS 换成任何一组 dataType 就是另一个数据集。环境:Python 3.13.12(macOS),仅标准库。

3. 发现一:同一时刻问四个接口,拿到三个「更新时间」

在这里插入图片描述

快照 ① 于 12:54:40 发起请求,四个口径各自报的 updateTime

warnsum       2026-09-14T11:05:00+08:00
warningInfo   2026-09-14T11:05:00+08:00
swt           2026-09-14T11:45:00+08:00
rhrread       2026-09-14T12:02:00+08:00     ← 抓取时刻是 12:54

四点同时抓,最「新」的那一个也落后真实时间 52 分钟,而 warnsumwarningInfo 落后 1 小时 49 分

一小时后(13:55:34)再抓一次,同样四个接口:

warnsum       2026-09-14T13:25:00+08:00
warningInfo   2026-09-14T13:25:00+08:00
swt           该口径返回空数组,没有时间戳
rhrread       2026-09-14T13:26:00+08:00     ← 抓取时刻是 13:55

两次分别是 3 个时间戳(差 57 分钟)2 个时间戳(差 1 分钟)。同一个接口集合,「分歧有多大」自己就在变——57 分钟是一次观测,不是能写进代码的常数

原因是四个接口各自按自己的节奏更新swt 是临时追加的提示通道,rhrread 约每小时刷新天气报告、顺手带上警告,warnsumwarningInfo 只在状态变化时(发出、延长、取消)才动。

结论:判状态只读 warnsum,它是唯一结构化给出 code + actionCode + 时间戳的口径;要给人看一句话就读 rhrread.warningMessagewarningInfo,但别拿它的 updateTime 判新鲜度——那是天气报告的刷新时间;也不要跨口径相减推「警告多久没更新」,它们不在同一条链路上。

4. 发现二:属性名不是级别

warnsum 的返回是一个以警告类别为键的对象:

{"WTS": {"name": "雷暴警告", "code": "WTS", "actionCode": "EXTEND",
         "issueTime": "2026-09-14T09:50:00+08:00",
         "expireTime": "2026-09-14T13:45:00+08:00",
         "updateTime": "2026-09-14T11:05:00+08:00"}}

WTS 出现了两次——一次是键名,一次是 code。对雷暴警告两者同值,但对另外几个警告就不是了。按天文台《开放数据应用程序介面说明书》(v1.12),11 个属性名对应 21 个级别代号:

在这里插入图片描述

属性名与 code 不同的恰好是「有级别」的那三个:

属性名(类别)可能的 code(级别)
WRAIN 暴雨警告信号WRAINA 黄 / WRAINR 红 / WRAINB
WTCSGNL 热带气旋警告信号TC1 / TC3 / TC8NE / TC8SE / TC8NW / TC8SW / TC9 / TC10
WFIRE 火灾危险警告WFIREY 黄 / WFIRER

用键名当级别,黑色暴雨和黄色暴雨会得到同一个结论——这是判断逻辑里最容易埋的错。做法是把「类别 → 级别 → 影响等级」写成显式的表,级别只认 code

# 官方警告码表:左侧是属性名(类别),右侧是该类别下可能出现的 code(级别代号)
WARNING_CODES = {
    "WFIRE":   ["WFIREY", "WFIRER"],
    "WFROST":  ["WFROST"],
    "WHOT":    ["WHOT"],
    "WCOLD":   ["WCOLD"],
    "WMSGNL":  ["WMSGNL"],
    "WRAIN":   ["WRAINA", "WRAINR", "WRAINB"],
    "WFNTSA":  ["WFNTSA"],
    "WL":      ["WL"],
    "WTCSGNL": ["TC1", "TC3", "TC8NE", "TC8SE", "TC8NW", "TC8SW", "TC9", "TC10"],
    "WTMW":    ["WTMW"],
    "WTS":     ["WTS"],
}
CODE_TO_FAMILY = {c: fam for fam, cs in WARNING_CODES.items() for c in cs}

# code -> 影响等级。规则必须是确定性的,模型只在最后一步负责措辞。
SEVERITY = {
    "TC8NE": "severe", "TC8SE": "severe", "TC8NW": "severe", "TC8SW": "severe",
    "TC9": "severe", "TC10": "severe", "WRAINB": "severe", "WTMW": "severe",
    "WRAINR": "caution", "TC3": "caution", "WL": "caution", "WFNTSA": "caution",
    "WFIRER": "caution",
    "WRAINA": "info", "WTS": "info", "WFIREY": "info", "WHOT": "info",
    "WCOLD": "info", "WMSGNL": "info", "WFROST": "info",
}
OUTDOOR = {"severe": "stop", "caution": "avoid", "info": "normal"}

def parse_warnings(s):
    """把 warnsum 解析成「类别 + 级别 + 影响等级」的结构。"""
    out = []
    for family, v in (s["warnsum"] or {}).items():
        code = v.get("code") or family
        sev = SEVERITY.get(code, "info")
        out.append({"family": family, "family_is_code": family == code,
                    "code": code, "name": v.get("name"),
                    "actionCode": v.get("actionCode"),
                    "expireTime": v.get("expireTime"),
                    "updateTime": v.get("updateTime"),
                    "severity": sev, "outdoor_work": OUTDOOR[sev]})
    return sorted(out, key=lambda x: -{"severe": 2, "caution": 1, "info": 0}[x["severity"]])

family_is_code 一眼告诉你「类别名是否恰好等于级别代号」,也就是「这条有没有丢掉级别信息」。今天这条 WTSTrue,没有级别细分。

这张码表建议收藏:把它接进任何自动化流程前,唯一需要事先写死的就是它。

5. 发现三:actionCode 有五种,两种只属于雷暴警告

actionCode 表示「这个警告发生了什么动作」,官方列出五个取值:

ISSUE / REISSUE / CANCEL / EXTEND / UPDATE

说明书写得很细:REISSUE 只用于寒冷/酷热/新界北部水浸三种;EXTENDUPDATE 只用于雷暴警告(WTS)

今天实测这条正是 WTS + "actionCode": "EXTEND":警告 09:50 发出、11:05 被延长到 13:45,issueTime 早于 updateTime 1 小时 15 分。一小时后同一条命令再抓,同一个 EXTEND 又出现一次,updateTime 变 13:25、expireTime 变 14:30。

同一条警告可以被延长很多次,每次延长只是把 expireTime 往后挪。 所以:

  • issueTime ≠ updateTime 不代表异常,它说明这条警告被延长过;
  • 只读 issueTime 会以为它已生效两小时,只读 updateTime 会以为它刚发出——两个都读,用 actionCode 解释差值;
  • expireTime 每延长一次就换一个值,它是「当前这一版的有效期终点」,不是「事件结束时刻」

还有一个边界:expireTime 不是每条警告都有(说明书标注「数值为 null 或不适用时有可能缺少」)——实测雷暴警告有,寒冷/酷热类通常没有。

6. 发现四:到期时间到了,警告却还在

在这里插入图片描述

快照 ① 读到的 expireTime13:45。等过这个时刻,13:55:34 再跑同一条命令:

口径快照 ① 12:54:40快照 ② 13:55:34
warnsum1 条 / 218 字节1 条 / 218 字节
warningInfo1 条 / 550 字节1 条 / 556 字节
swt1 条 / 207 字节0 条 / 14 字节
rhrreadwarningMessage1 条 / 5,026 字节1 条 / 4,803 字节

warnsum 里的 WTS 在第二次快照里还在——它在到期的 20 分钟前又被延长一次:

快照 ①  WTS  EXTEND  updateTime 11:05  expireTime 13:45
快照 ②  WTS  EXTEND  updateTime 13:25  expireTime 14:30

快照 ② 落在第一版到期线(13:45)与第二版到期线(14:30)之间。定时任务若在快照 ① 就把 13:45 当「解除时刻」,到点会发现警告还在——这个坑不是「没处理空结构」,而是处理了空结构、空结构却没来。

同一个事件,两条通道对「结束」的表达不同

swt 在快照 ② 返回 {"swt": []}——特别天气提示确实消失了,而 warnsumwarningInforhrread 都还在报同一条雷暴警告。「事件结束」在四个口径里不是同一个时刻——按哪个口径写任务,就按哪个口径定义结束。

rhrread 更细:warningMessage 没清空,只是文案换成了给人读的写法(「下午1時45分」→「下午2時30分」)。中文时段表述对人有价值、对代码没有。结构化字段给代码,自然语言给人,别反过来。

字节数一模一样,内容却变了

上表最该盯住这一格:warnsum218 字节变成 218 字节——内容其实变了(expireTime 13:45→14:30、updateTime 11:05→13:25),但两者都是 19 字符的 ISO 时间戳串,长度恰好抵消

用响应体大小做变更检测,在这个接口上会漏报这种更新。 要比就比结构化字段:

def state_key(s):
    """给「警告状态」算指纹:只用结构化字段,不用响应体大小。"""
    return {fam: (v.get("code"), v.get("actionCode"),
                  v.get("expireTime"), v.get("updateTime"))
            for fam, v in (s["warnsum"] or {}).items()}

这一块建议收藏——任何「轮询接口、发现变了就告警」的脚本,指纹函数都比 len(response.text) 可靠。

那空结构到底要不要处理

要,但不是靠「等它出现」。把四个口径的条目数都取出来——谁空了、谁没空本身就是信息

def channel_counts(s):
    """四个口径各自「现在有几条」。"""
    return {
        "warnsum": len(s["warnsum"] or {}),
        "warningInfo": len(s["warningInfo"].get("details") or []),
        "swt": len(s["swt"].get("swt") or []),
        "rhrread.warningMessage": len(s["rhrread"].get("warningMessage") or []),
    }

第二次快照是 {'warnsum': 1, 'warningInfo': 1, 'swt': 0, 'rhrread.warningMessage': 1}swt 那格 0,其余 1。空结构真的出现了,只是出现在你没在盯的那个口径里。

7. Claude 侧:规则用代码,措辞用模型

分工线:判级别、判户外作业能不能做 → 代码(确定性、可审计);把判定讲成人话 → 模型(措辞、要不要提醒带伞,没有唯一正确答案)。所以请求体里传入的是已经算好的事实

ALERT_TOOL = {
    "name": "report_workplace_alert",
    "description": "把香港天文台警告归一成一条工作安排提醒。等级与户外作业结论必须来自代码传入的判定,不要自行改写。",
    "input_schema": {
        "type": "object",
        "properties": {
            "severity": {"type": "string", "enum": ["info", "caution", "severe"]},
            "codes": {"type": "array", "items": {"type": "string"}},
            "outdoor_work": {"type": "string", "enum": ["normal", "avoid", "stop"]},
            "summary_zh": {"type": "string", "description": "一句中文提醒,不超过 40 字"},
            "expires_at": {"type": ["string", "null"]},
        },
        "required": ["severity", "codes", "outdoor_work", "summary_zh"],
        "additionalProperties": False,
    },
}

def build_request(warnings):
    """决策由代码给出,模型只负责措辞。"""
    worst = max((w["severity"] for w in warnings),
                key=lambda s: {"severe": 2, "caution": 1, "info": 0}[s],
                default="info")
    facts = {
        "taken_at": time.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
        "severity": worst,
        "outdoor_work": OUTDOOR[worst],
        "codes": [w["code"] for w in warnings],
        "expires_at": next((w["expireTime"] for w in warnings if w["expireTime"]), None),
        "plain_text": [w["name"] for w in warnings],
    }
    return {
        "model": "claude-sonnet-4-5", "max_tokens": 300,
        "tools": [ALERT_TOOL],
        "tool_choice": {"type": "tool", "name": ALERT_TOOL["name"]},
        "messages": [{"role": "user",
                      "content": "这是刚抓到的警告快照,请填好工具参数:\n"
                                 + json.dumps(facts, ensure_ascii=False, indent=1)}],
        "_facts": facts,
    }

def validate_request(req):
    """本地校验请求体:工具是否挂载、是否强制、required 是否都在 properties 里。"""
    errs = []
    if ALERT_TOOL["name"] not in [t["name"] for t in req.get("tools", [])]:
        errs.append("工具未挂载")
    if req.get("tool_choice", {}).get("name") != ALERT_TOOL["name"]:
        errs.append("tool_choice 未强制指定")
    props = ALERT_TOOL["input_schema"]["properties"]
    for k in ALERT_TOOL["input_schema"]["required"]:
        if k not in props:
            errs.append(f"required 字段 {k} 未在 properties 中定义")
    return errs

三个设计点:severityoutdoor_work 都是 enum,模型只能在有限集合里选,返回值可直接进 ifadditionalProperties: false 挡住模型自创字段;③ 判定结果通过 _facts 原样回带,与代码判定逐字段对账——规则有没有被模型改写,一比就知道

今天的实测还给这条线补了一个理由:expireTime 会变,expires_at 必须和 taken_at 绑在一起传——否则模型拿到的是哪个版本的到期时间,无从判断。脚本里模型部分是零调用的:validate_request() 本地校验请求体结构,0 条错误。

8. 踩坑清单与结论

现象处理
rhrread 判警告状态它的 updateTime 是天气报告刷新时间状态判定只读 warnsum
拿接口 updateTime 当新鲜度同组接口两次快照给出 3 个 / 2 个时间戳只用 warnsum 的时间戳,每轮重算
expireTime 当事件结束时刻13:45 后警告还在,13:25 又延长一次到期只表示「本版有效期」
用响应体大小做变更检测218 字节 → 218 字节,内容却变了比结构化指纹,不比长度
只盯一个口径判「事件结束」swt 已清空,warnsum 还在报四个口径的条目数一起看
用属性名当级别黑雨与黄雨共用 WRAIN,级别被抹平级别只认 code
假定每条警告都有 expireTime雷暴警告有,寒冷/酷热类没有走空值分支
把级别判定交给模型同一输入可能给出不同等级规则用代码,模型只措辞

三条:

  1. 多口径数据源按「问题」选接口,不是按「哪个更全」选。 四个接口都能看到有雷暴警告,只有 warnsum 能可靠回答「现在还有没有」「什么级别」。
  2. 同一个事实的多个时间戳可能属于不同链路。 差 57 分钟不是数据错了,是各自更新;连「有几个时间戳」都会在两次快照间从 3 变成 2。
  3. 「到期时间」描述的是当前这一版警告,不是事件的终点。 到点了要重读状态,而不是照旧值执行。

可复用的四块:四口径快照 snapshot()警告码两级映射 WARNING_CODES + SEVERITY状态指纹 state_key()请求体本地校验 validate_request()。这篇对你有用的话,收藏 + 点赞——下次接「多数据源 + 事件驱动」的官方接口,按这四块先过一遍。

香港公开数据的实测会继续更新,关注不迷路。

9. 参考链接

  1. https://www.hko.gov.hk/tc/weatherAPI/doc/files/HKO_Open_Data_API_Documentation_tc.pdf
  2. https://data.weather.gov.hk/weatherAPI/doc/HKO_Open_Data_API_Documentation.pdf
  3. https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=warnsum&lang=tc

原创声明:本文为原创技术实践。当天警告与两次快照均为 2026-09-14 的真实接口采集(快照 ① 于 12:54:40、快照 ② 于 13:55:34),警告码表与 actionCode 取值引自香港天文台《开放数据应用程序介面说明书》(v1.12)。接口字段与更新节奏可能随官方调整而变化,实际工作安排请以雇主指引为准,请以自测数据为准。

在这里插入图片描述

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Patrick在香港

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值