
目录
这篇文章最后会交付什么
本文不是把实时接口请求下来后打印几行数字,而是完成一条可以复核的数据处理链路:
- 保留官方JSON中的原始等待时间文本;
- 将分钟、小时统一为分钟,但不抹掉“少于某值”和“不可用”的语义;
- 同时记录官方更新时间和本地观测时间;
- 只对定义明确的
reported记录计算跨医院统计量; - 输出
JSONL原始快照和CSV汇总文件。
这几个步骤看起来比直接json.loads()多,但它们解决的是生产环境里最难排查的错误:程序不报错,数字也能画图,含义却已经变了。
环境信息
- Python 3.10及以上
- Windows、macOS、Linux均可运行
- 只使用Python标准库:
urllib、json、re、statistics、csv、zoneinfo - 数据接口:香港医院管理局公开的急症室轮候时间 JSON
- 数据更新频率:官方数据目录标注为每15分钟更新一次
运行环境只需要Python标准库,不依赖pandas、requests或爬虫框架:
python --version
python -m py_compile ha_waiting_analysis.py
python ha_waiting_analysis.py
先说一个容易被忽略的事实:“等待时间”不是一个可以随手转成整数的字段。
同一份数据里,可能同时出现“0分钟”“少于15分钟”“0.5小时”,还会出现N/A。如果直接把这些值塞进int(),程序要么报错,要么为了让程序继续跑而偷偷改写含义。
更麻烦的是,医院管理局的字段还区分了不同分流类别,以及“半数等待者可在此时间内接受诊症”和“大多数等待者可在此时间内接受诊症”。这两个数字不是一个统计量的两个写法。
这篇文章不讨论哪家医院“更好”,也不根据一张快照给出就医判断。我们只做一件技术上更可靠的事:把公开JSON读懂,做一次可复现的横截面分析,并留下抓取证据。

1. 先看懂数据,而不是先画图
接口返回的顶层结构很简单:waitTime是医院数组,updateTime是本次数据的官方更新时间。医院记录中比较关键的字段如下:
| 字段 | 含义 | 单位/边界 |
|---|---|---|
hospName | 医院名称 | 文本 |
t1wt、t2wt | 第一、第二类别的估算等待时间 | 通常以分钟表达;可能出现特殊状态 |
t3p50、t3p95 | 第三类别的估算等待时间 | 分钟;一个代表半数,一个代表大多数 |
t45p50、t45p95 | 第四、第五类别的估算等待时间 | 小时;官方说明按0.5小时向上取整 |
updateTime | 官方数据更新时间 | 不是脚本抓取时刻 |
我在2026年8月26日13:30(香港时间)重新抓取的一次快照包含18家医院。这个时间点的t3p50跨医院中位数为22.5分钟,t3p95跨医院中位数为49分钟;IV/V类的t45p95源字段最大值为9小时。它们都只代表这次快照,不代表当天平均水平,更不是医院长期排名。
这些数字只能描述该次快照。它们不能回答“今天哪家一定更快”,更不能替代急症分流和医护人员判断。把统计边界写在文章里,和把数字算出来同样重要。
2. 为什么要同时保存两个时间
实时数据分析里,我建议至少保存两个时间:
source_update_time:数据发布方在JSON中声明的更新时间;observed_at:脚本真正收到并解析数据的时间。
如果只保存第二个时间,读者不知道这份数据在数据发布端已经“新鲜”了多久;如果只保存第一个时间,又无法知道请求是否因为网络延迟而晚到。

这也是为什么代码里没有直接写一个now字段就结束,而是把来源时间和观测时间都写入每一行快照。后续要做历史趋势时,可以按source_update_time去重,再检查相邻快照是否真的来自不同的官方更新时间。
3. 用语义标签保护原始含义
解析函数不应该只返回一个浮点数,还应该返回它的语义标签:
"22 分钟" -> (22.0, "reported")
"0.5 小时" -> (30.0, "reported")
"少于 15 分钟" -> (15.0, "upper_bound")
"N/A" -> (None, "unavailable")
注意第三行:数值15只是上界,不是精确等待15分钟。本文的横截面统计只纳入reported,这样不会把“少于15分钟”和“刚好15分钟”混在一起。这套“数值+语义标签”的设计也适合监控接口、价格接口和政府开放数据:原始文本用于审计,标准化数值用于计算,标签用于决定哪些记录可以进入统计。
4. 完整Python代码:抓取、校验、统计、落盘
下面的代码可以直接运行。它不会构造模拟数据,而是请求医院管理局公开JSON,并输出两个文件:
ha_waiting_output/snapshot.jsonl:保留医院、字段、原始文本、标准化分钟数和两个时间;ha_waiting_output/summary.csv:只对语义明确的数值做跨医院中位数、P90、最小值和最大值统计。
from __future__ import annotations
import csv
import json
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from statistics import median, quantiles
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
SOURCE_URL = "https://www.ha.org.hk/opendata/aed/aedwtdata2-sc.json"
OUTPUT_DIR = Path("ha_waiting_output")
try:
HK_TZ = ZoneInfo("Asia/Hong_Kong")
except ZoneInfoNotFoundError:
# 极简容器没有时区数据库时,香港全年使用UTC+8。
HK_TZ = timezone(timedelta(hours=8))
WAIT_FIELDS = ("t3p50", "t3p95", "t45p50", "t45p95")
def fetch_json(url: str) -> dict:
request = Request(url, headers={"User-Agent": "ha-waiting-analysis/1.0"})
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8-sig")
except HTTPError as exc:
raise RuntimeError(f"HTTP {exc.code} while reading {url}") from exc
except (URLError, TimeoutError) as exc:
raise RuntimeError(f"network error while reading {url}: {exc}") from exc
data = json.loads(payload)
if not isinstance(data, dict) or not isinstance(data.get("waitTime"), list):
raise ValueError("unexpected JSON shape: waitTime must be a list")
return data
def parse_update_time(text: str) -> datetime:
pattern = r"(\d{4})年(\d{1,2})月(\d{1,2})日\s*(上午|下午)(\d{1,2})时(\d{1,2})分"
match = re.fullmatch(pattern, text.strip())
if not match:
raise ValueError(f"cannot parse updateTime: {text!r}")
year, month, day, part, hour, minute = match.groups()
hour = int(hour)
if part == "下午" and hour < 12:
hour += 12
if part == "上午" and hour == 12:
hour = 0
return datetime(
int(year), int(month), int(day), hour, int(minute), tzinfo=HK_TZ
)
def parse_wait_value(raw: object) -> tuple[float | None, str]:
"""返回(分钟数, 语义标签),不把少于15分钟伪装成精确15分钟。"""
text = str(raw or "").strip()
if not text or text.upper() in {"N/A", "NA", "-"} or text in {"不适用", "暂无"}:
return None, "unavailable"
less_than = re.fullmatch(r"少于\s*(\d+(?:\.\d+)?)\s*分钟", text)
if less_than:
return float(less_than.group(1)), "upper_bound"
hours = re.fullmatch(r"(\d+(?:\.\d+)?)\s*小时", text)
if hours:
return float(hours.group(1)) * 60, "reported"
minutes = re.fullmatch(r"(\d+(?:\.\d+)?)\s*分钟", text)
if minutes:
return float(minutes.group(1)), "reported"
raise ValueError(f"unknown waiting-time value: {text!r}")
def flatten_snapshot(data: dict) -> list[dict]:
updated_at = parse_update_time(str(data.get("updateTime", "")))
observed_at = datetime.now(HK_TZ)
rows: list[dict] = []
for item in data["waitTime"]:
if not isinstance(item, dict) or not item.get("hospName"):
continue
missing = [field for field in WAIT_FIELDS if field not in item]
if missing:
raise ValueError(f"hospital record is missing fields: {missing}")
for field in WAIT_FIELDS:
minutes, qualifier = parse_wait_value(item.get(field))
rows.append(
{
"hospital": str(item["hospName"]).strip(),
"field": field,
"raw_value": str(item.get(field, "")),
"wait_minutes": minutes,
"qualifier": qualifier,
"source_update_time": updated_at.isoformat(),
"observed_at": observed_at.isoformat(),
}
)
if not rows:
raise ValueError("no usable hospital rows")
return rows
def summarize(rows: list[dict]) -> list[dict]:
output = []
for field in WAIT_FIELDS:
values = [
float(row["wait_minutes"])
for row in rows
if row["field"] == field
and row["qualifier"] == "reported"
and row["wait_minutes"] is not None
]
if not values:
continue
p90 = quantiles(values, n=10, method="inclusive")[8] if len(values) > 1 else values[0]
output.append(
{
"field": field,
"count": len(values),
"median_minutes": round(median(values), 1),
"p90_minutes": round(p90, 1),
"min_minutes": min(values),
"max_minutes": max(values),
}
)
return output
def write_outputs(rows: list[dict], summary: list[dict]) -> None:
OUTPUT_DIR.mkdir(exist_ok=True)
with (OUTPUT_DIR / "snapshot.jsonl").open("w", encoding="utf-8") as fp:
for row in rows:
fp.write(json.dumps(row, ensure_ascii=False) + "\n")
with (OUTPUT_DIR / "summary.csv").open("w", encoding="utf-8-sig", newline="") as fp:
if not summary:
return
writer = csv.DictWriter(fp, fieldnames=summary[0].keys())
writer.writeheader()
writer.writerows(summary)
def main() -> None:
data = fetch_json(SOURCE_URL)
rows = flatten_snapshot(data)
summary = summarize(rows)
write_outputs(rows, summary)
print(f"source_update_time={data['updateTime']}")
print(f"hospitals={len({row['hospital'] for row in rows})}")
for item in summary:
print(
f"{item['field']}: median={item['median_minutes']} min, "
f"cross-hospital-p90={item['p90_minutes']} min, "
f"range={item['min_minutes']}-{item['max_minutes']} min"
)
print(f"written={OUTPUT_DIR / 'snapshot.jsonl'}")
print(f"written={OUTPUT_DIR / 'summary.csv'}")
if __name__ == "__main__":
main()
这段代码只使用标准库,保存为ha_waiting_analysis.py即可运行。换成其他实时JSON接口时,至少要同步修改更新时间解析、等待值正则和字段字典,不能只替换URL。
5. 运行结果:统计的是横截面,不是医院排名
运行命令:
python ha_waiting_analysis.py
我在2026年8月26日13:30(香港时间)验证时,得到的关键输出如下。由于源数据会更新,读者本地运行时数字可能不同,这是正常现象:
source_update_time=2026年8月26日 下午1时30分
hospitals=18
t3p50: median=22.5 min, cross-hospital-p90=36.7 min, range=11.0-50.0 min
t3p95: median=49.0 min, cross-hospital-p90=67.2 min, range=21.0-73.0 min
t45p50: median=135.0 min, cross-hospital-p90=270.0 min, range=60.0-300.0 min
t45p95: median=210.0 min, cross-hospital-p90=519.0 min, range=90.0-540.0 min
written=ha_waiting_output/snapshot.jsonl
written=ha_waiting_output/summary.csv
这组结果是怎样算出来的
以t3p50为例,脚本先从18家医院记录中取出原始文本能够被识别为精确分钟数的记录,再将这些分钟数排序,取中间位置得到跨医院中位数;cross-hospital-p90则是同一批医院数值的第90百分位。
这不是“18家医院所有患者的平均等待时间”,也不是某位患者的预计等待时间。统计对象是接口在同一时点返回的医院级估算值,分母是有效医院记录数。把统计对象写清楚,结果才有可复核性。
这里的cross-hospital-p90是我对18家医院当前字段做的横截面P90,不是医院管理局原始字段里的t3p95或t45p95。为了避免读者误解,代码和输出特意使用了两个不同名字:一个是源字段含义,一个是我在医院维度上的统计。
6. 这段代码里最值得留下的三个细节
6.1 utf-8-sig比直接写utf-8更稳
公开数据有时会带BOM。使用utf-8-sig读取时,有BOM会被自动处理,没有BOM也能正常读取。它不是这份数据的核心难点,但能减少部署到不同环境后的偶发解析问题。
6.2 统计前过滤语义,而不是过滤异常数字
upper_bound不是异常值,而是信息不完整的上界;unavailable也不是0。它们都应该保留在原始快照里,只是不参与本文的精确横截面统计。
这种处理方式比“全部转成数字再算平均”多了一点代码,却保住了数据原意。以后如果要做区间统计,还可以对上界单独建模,而不是回头从丢失的原始文本里猜。
6.3 只保存一张快照,不能画趋势线
接口每15分钟更新并不等于我们已经拥有历史数据。要做趋势,至少需要定时抓取并保存多张快照,再处理:
官方更新时间去重
↓
相邻快照时间间隔检查
↓
医院和字段完整性检查
↓
再计算分位数和波动范围
否则把“今天11:45的一次读取”和“今天12:00的另一次读取”连成线,图形看起来像趋势,实际上只是两张快照的差异。
7. 常见错误与修正
| 错误写法 | 可能造成的误读 | 修正 |
|---|---|---|
int("0.5 小时") | 运行时报错,或被粗暴改成0 | 明确单位,统一成分钟但保留raw_value |
少于15分钟直接当15分钟 | 把上界说成精确值 | 标记为upper_bound,精确统计时排除 |
| 缺失值替换成0 | 误以为不需要等待 | 使用None + unavailable |
用本地时间覆盖updateTime | 看不出数据是否过期 | 同时保留来源时间和观测时间 |
把p95写成“95%的人要等这么久” | 忽略官方字段对“majority”的描述 | 引用数据字典,按原始定义解释 |
| 依据一次快照判断医院长期表现 | 把横截面当成趋势 | 连续抓取后再做时间序列分析 |
这张错误对照表建议收藏,实时数据清洗最容易踩的就是这几个坑——单位混用、上界当精确值、缺失值补零,每一条都会悄悄改变结论。
还有一个非代码层面的边界:急症室采用分流机制,处理优先级取决于临床情况,不是简单的先到先得。因此本文只分析公开数据结构,不根据等待分钟数建议读者选择医院、延后就诊或自行判断病情。遇到紧急情况,应以官方医疗渠道和现场医护人员判断为准。
总结
这次练习真正难的地方不是请求JSON,而是拒绝把所有字段压扁成一个“分钟数”:
- 用医院管理局官方JSON,不用拼接网页截图或模拟数据;
- 同时记录
source_update_time和observed_at,让快照可追溯; - 用
reported、upper_bound、unavailable保留原始语义; - 对不同分流类别分别统计,不把源字段
t3p95、t45p95和跨医院P90混叫成同一个指标; - 只把结果解释为当前公开快照,不把它扩展成医疗判断或长期趋势。
如果你准备把脚本放进定时任务,下一步应补上失败重试上限、源更新时间不变化告警、快照去重和CSV分区存储。到那时,问题才从“一次读取”进入“可维护的数据管道”。
参考资料
数据说明:本文示例结果对应2026年8月26日13:30(香港时间)抓取的公开快照。医院管理局数据会持续更新,发布后阅读者运行代码得到的数值可能不同;这不影响代码结构和字段解释。


140

被折叠的 条评论
为什么被折叠?



