
文章目录
-
- [1. 先说结论](#1. 先说结论)
- [2. 四个接口与取数代码](#2. 四个接口与取数代码)
- [3. 发现一:同一时刻问四个接口,拿到三个「更新时间」](#3. 发现一:同一时刻问四个接口,拿到三个「更新时间」)
- [4. 发现二:属性名不是级别](#4. 发现二:属性名不是级别)
- [5. 发现三:`actionCode` 有五种,两种只属于雷暴警告](#5. 发现三:
actionCode有五种,两种只属于雷暴警告) - [6. 发现四:到期时间到了,警告却还在](#6. 发现四:到期时间到了,警告却还在)
- [7. Claude 侧:规则用代码,措辞用模型](#7. Claude 侧:规则用代码,措辞用模型)
- [8. 踩坑清单与结论](#8. 踩坑清单与结论)
- [9. 参考链接](#9. 参考链接)
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 |
天气警告一览 | 每个警告一条:code、actionCode、issueTime、expireTime、updateTime |
warningInfo |
详细天气警告资讯 | details[],含 contents[] 人类可读全文 |
swt |
特别天气提示 | swt[],一句提示 + updateTime |
rhrread |
本港地区天气报告 | 气温/湿度/雨量,外加 warningMessage[] 一句话警告 |
python
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 分钟,而 warnsum 与 warningInfo 落后 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 约每小时刷新天气报告、顺手带上警告,warnsum 与 warningInfo 只在状态变化时(发出、延长、取消)才动。
结论:判状态只读 warnsum ,它是唯一结构化给出 code + actionCode + 时间戳的口径;要给人看一句话就读 rhrread.warningMessage 或 warningInfo,但别拿它的 updateTime 判新鲜度------那是天气报告的刷新时间;也不要跨口径相减推「警告多久没更新」,它们不在同一条链路上。
4. 发现二:属性名不是级别
warnsum 的返回是一个以警告类别为键的对象:
json
{"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:
python
# 官方警告码表:左侧是属性名(类别),右侧是该类别下可能出现的 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 一眼告诉你「类别名是否恰好等于级别代号」,也就是「这条有没有丢掉级别信息」。今天这条 WTS 是 True,没有级别细分。
这张码表建议收藏:把它接进任何自动化流程前,唯一需要事先写死的就是它。
5. 发现三:actionCode 有五种,两种只属于雷暴警告
actionCode 表示「这个警告发生了什么动作」,官方列出五个取值:
ISSUE / REISSUE / CANCEL / EXTEND / UPDATE
说明书写得很细:REISSUE 只用于寒冷/酷热/新界北部水浸三种;EXTEND 和 UPDATE 只用于雷暴警告(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. 发现四:到期时间到了,警告却还在

快照 ① 读到的 expireTime 是 13:45 。等过这个时刻,13:55:34 再跑同一条命令:
| 口径 | 快照 ① 12:54:40 | 快照 ② 13:55:34 |
|---|---|---|
warnsum |
1 条 / 218 字节 | 1 条 / 218 字节 |
warningInfo |
1 条 / 550 字节 | 1 条 / 556 字节 |
swt |
1 条 / 207 字节 | 0 条 / 14 字节 |
rhrread 的 warningMessage |
1 条 / 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": []}------特别天气提示确实消失了 ,而 warnsum、warningInfo、rhrread 都还在报同一条雷暴警告。「事件结束」在四个口径里不是同一个时刻------按哪个口径写任务,就按哪个口径定义结束。
rhrread 更细:warningMessage 没清空,只是文案换成了给人读的写法(「下午1時45分」→「下午2時30分」)。中文时段表述对人有价值、对代码没有。结构化字段给代码,自然语言给人,别反过来。
字节数一模一样,内容却变了
上表最该盯住这一格:warnsum 从 218 字节 变成 218 字节 ------内容其实变了(expireTime 13:45→14:30、updateTime 11:05→13:25),但两者都是 19 字符的 ISO 时间戳串,长度恰好抵消。
用响应体大小做变更检测,在这个接口上会漏报这种更新。 要比就比结构化字段:
python
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) 可靠。
那空结构到底要不要处理
要,但不是靠「等它出现」。把四个口径的条目数都取出来------谁空了、谁没空本身就是信息:
python
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 侧:规则用代码,措辞用模型
分工线:判级别、判户外作业能不能做 → 代码 (确定性、可审计);把判定讲成人话 → 模型 (措辞、要不要提醒带伞,没有唯一正确答案)。所以请求体里传入的是已经算好的事实:
python
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
三个设计点:① severity 与 outdoor_work 都是 enum ,模型只能在有限集合里选,返回值可直接进 if;② additionalProperties: 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 |
雷暴警告有,寒冷/酷热类没有 | 走空值分支 |
| 把级别判定交给模型 | 同一输入可能给出不同等级 | 规则用代码,模型只措辞 |
三条:
- 多口径数据源按「问题」选接口,不是按「哪个更全」选。 四个接口都能看到有雷暴警告,只有
warnsum能可靠回答「现在还有没有」「什么级别」。 - 同一个事实的多个时间戳可能属于不同链路。 差 57 分钟不是数据错了,是各自更新;连「有几个时间戳」都会在两次快照间从 3 变成 2。
- 「到期时间」描述的是当前这一版警告,不是事件的终点。 到点了要重读状态,而不是照旧值执行。
可复用的四块:四口径快照 snapshot() 、警告码两级映射 WARNING_CODES + SEVERITY 、状态指纹 state_key() 、请求体本地校验 validate_request() 。这篇对你有用的话,收藏 + 点赞------下次接「多数据源 + 事件驱动」的官方接口,按这四块先过一遍。
香港公开数据的实测会继续更新,关注不迷路。
9. 参考链接
- https://www.hko.gov.hk/tc/weatherAPI/doc/files/HKO_Open_Data_API_Documentation_tc.pdf
- https://data.weather.gov.hk/weatherAPI/doc/HKO_Open_Data_API_Documentation.pdf
- 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)。接口字段与更新节奏可能随官方调整而变化,实际工作安排请以雇主指引为准,请以自测数据为准。
