Python爬虫框架实战:优雅抓取香港政府公开API数据的通用方案

文章目录

环境信息

项目 版本/说明
Python 3.10+
requests 2.31+
数据源 data.gov.hk(香港政府公开数据平台)
演示API 城巴实时到站 / 差饷署租金指数 / 医管局急症室轮候
认证 三个API全部免注册、免Key

前言:5700个数据集摆在那,但你只会cpp一个URL

那天我在 data.gov.hk 上统计了一下:5700个数据集,其中2500个提供API接口。城巴的实时到站、差饷署的30年租金指数、医管局的急症室轮候时间------全是免费、公开、实时更新的数据。

然后我干了一件每个程序员都会干的事:对着其中一两个API写了几个 requests.get() 脚本,跑完了截图发群里。然后就没有然后了------脚本散落在桌面各处,每个都是复制粘贴的 try/except 包着一行 json.loads()

直到上周,我需要同时拉三个不同来源的API数据做分析------城巴的到站时间、差饷署的租金走势、医管局的急症室等候时间。三个API,三种响应格式,三种刷新频率。如果每个API单独写一个脚本,维护成本会指数爆炸。

这就是我写这个通用框架的原因。不到80行核心代码,覆盖连接池/限速/缓存/多格式解析,改两行URL就能接上data.gov.hk上任何一个API。下面用三个完全不同领域的真实API来演示。

收藏提示①:三个API全是免注册的公共接口,直接复制端点URL就能跑。

城巴、差饷署、医管局------三个API分别覆盖公交/住房/医疗,全部免注册、免Key、开箱即用。

一、框架设计:四个组件解决所有痛点

组件1:Session管理器(连接池+指数退避)

python 复制代码
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

class APISession:
    def __init__(self):
        self.session = requests.Session()
        retry = Retry(total=3, backoff_factor=1,
                      status_forcelist=[429, 500, 502, 503, 504])
        self.session.mount("https://", HTTPAdapter(max_retries=retry, pool_connections=5))
        self.session.headers.update({
            'User-Agent': 'HKOpenData-Framework/1.0',
            'Accept': 'application/json'
        })
    
    def get(self, url, timeout=15, **kw):
        return self.session.get(url, timeout=timeout, **kw)

组件2:RateLimiter(按域名限速)

python 复制代码
import time
from urllib.parse import urlparse
from collections import defaultdict

class RateLimiter:
    def __init__(self, default_interval=1.0):
        self._last = defaultdict(float)
        self._default = default_interval
    
    def wait(self, url):
        domain = urlparse(url).netloc
        elapsed = time.time() - self._last[domain]
        if elapsed < self._default:
            time.sleep(self._default - elapsed)
        self._last[domain] = time.time()

组件3:TTL缓存

python 复制代码
import hashlib, json

class APICache:
    def __init__(self, ttl=30):
        self._store = {}
        self._ttl = ttl
    
    def _key(self, url, params=None):
        raw = url + json.dumps(params or {}, sort_keys=True)
        return hashlib.md5(raw.encode()).hexdigest()
    
    def get(self, url, params=None):
        k = self._key(url, params)
        if k in self._store and time.time() - self._store[k][0] < self._ttl:
            return self._store[k][1]
        return None
    
    def set(self, url, data, params=None):
        self._store[self._key(url, params)] = (time.time(), data)

组件4:多格式响应解析

城巴API返回JSON、租金指数是CSV、急症室轮候也是JSON但字段结构完全不同------解析器需要统一处理:

python 复制代码
import csv, io

class ResponseParser:
    @staticmethod
    def parse(resp, expect_format='json'):
        if resp.status_code != 200:
            raise APIError(f"HTTP {resp.status_code}: {resp.url}")
        if expect_format == 'json':
            try:
                data = resp.json()
            except Exception as e:
                raise APIError(f"JSON parse failed: {e}")
            if isinstance(data, list) and len(data) == 0:
                return None  # 空数组 = 无数据
            return data
        elif expect_format == 'csv':
            resp.encoding = 'utf-8'
            return list(csv.DictReader(io.StringIO(resp.text)))
        return resp.text

class APIError(Exception):
    pass

二、组装框架:HKOpenDataClient

python 复制代码
class HKOpenDataClient:
    def __init__(self, cache_ttl=30, rate_interval=1.0):
        self.session = APISession()
        self.cache = APICache(ttl=cache_ttl)
        self.limiter = RateLimiter(default_interval=rate_interval)
    
    def fetch(self, url, params=None, fmt='json', use_cache=True):
        if use_cache:
            cached = self.cache.get(url, params)
            if cached is not None:
                return cached
        self.limiter.wait(url)
        resp = self.session.get(url, params=params)
        data = ResponseParser.parse(resp, fmt)
        if data is not None:
            self.cache.set(url, data, params)
        return data

# 一行初始化
client = HKOpenDataClient()

三、实战一:城巴实时到站------每60秒刷新的JSON

data.gov.hk上的城巴ETA API提供5个JSON端点:公司列表、路线列表、车站列表、指定路线车站、指定车站的ETA。调用链是先拿到路线和车站ID,再查ETA:

python 复制代码
CTB_BASE = "https://rt.data.gov.hk/v1/transport/citybus-nwfb/eta/ctb"

def get_ctb_routes(client):
    """获取城巴所有路线"""
    return client.fetch(f"{CTB_BASE}/route")

def get_ctb_stops(client, route_id):
    """获取指定路线的所有车站"""
    return client.fetch(f"{CTB_BASE}/stop/{route_id}")

def get_ctb_eta(client, stop_id, route_id):
    """获取指定车站+路线的实时到站"""
    return client.fetch(f"{CTB_BASE}/{stop_id}/{route_id}")

# 使用示例:查 962 路(屯门↔铜锣湾)在上环的到站时间
routes = get_ctb_routes(client)
route_962 = next((r for r in routes['data'] if r['route'] == '962'), None)
if route_962:
    stops = get_ctb_stops(client, '962')
    # 取第一个站的ETA
    eta = get_ctb_eta(client, stops['data'][0]['stop'], '962')
    for bus in eta.get('data', [])[:3]:
        print(f"路线{bus['route']} | {bus['eta']} | {bus['dest_tc']}")

踩坑记录:城巴API的 eta 字段是一个字符串格式的ISO时间戳("2026-08-11T14:20:00+08:00"),不是Unix timestamp。我第一次直接当数字用,Python直接炸了。后来改成用 datetime.fromisoformat() 解析,再算差值才是正确的"还剩几分钟"。

收藏提示②:香港巴士API的端点设计都是"先查元数据再查实时数据"的两级模式。MTR、KMB、Citybus都一样------不要试图跳过第一步直接用magic ID,元数据会变。

四、实战二:差饷署租金指数------30年CSV数据

差饷物业估价署(RVD)的私人住宅租金指数------从1993年至今的月度数据,CSV文件直接暴露在公网上,连API调用都不算,就是 requests.get() 一个CSV文件:

python 复制代码
RVD_RENTAL_URL = "http://www.rvd.gov.hk/datagovhk/1.3M.csv"

def fetch_rental_index(client):
    """获取全港私人住宅租金指数(1993年起)"""
    data = client.fetch(RVD_RENTAL_URL, fmt='csv')
    
    records = []
    for row in data:
        try:
            records.append({
                'year': int(row.get('Year', 0)),
                'month': int(row.get('Month', 0)),
                'index_all': float(row.get('All classes', 0)),
                'index_a': float(row.get('Class A', 0)),   # <40㎡
                'index_b': float(row.get('Class B', 0)),   # 40-69.9㎡
                'index_c': float(row.get('Class C', 0)),   # 70-99.9㎡
                'index_d': float(row.get('Class D', 0)),   # 100-159.9㎡
                'index_e': float(row.get('Class E', 0)),   # ≥160㎡
            })
        except (ValueError, KeyError):
            continue
    
    return records

rentals = fetch_rental_index(client)
# 最近12个月的趋势
recent = [r for r in rentals if r['year'] == 2026]
for r in recent:
    print(f"2026-{r['month']:02d} | A类(<40㎡): {r['index_a']:.1f} | 全港: {r['index_all']:.1f}")

# 30年涨幅计算
start = rentals[0]
end = rentals[-1]
increase = (end['index_all'] - start['index_all']) / start['index_all'] * 100
print(f"1993→2026 租金指数涨幅: {increase:.0f}%")

这个CSV文件有个精妙之处:Class A到E是按面积分的五类单位。A类是40平米以下(香港的"纳米楼"主力),E类是160平以上。对比A类和E类的涨幅曲线,能看出不同面积段的市场分化------

A类(纳米楼)全年涨6.4%,E类(豪宅)仅涨3.0%------面积越小涨得越狠。这个角度我准备在0813的CSDN文章里做深度分析,今天先把数据拉下来。

五、实战三:医管局急症室轮候------JSON格式的实时医疗数据

医院管理局在 data.gov.hk 上公开了急症室的实时轮候时间。全港18间公立医院急症室,JSON格式,每15分钟更新一次:

python 复制代码
HA_AED_URL = "https://www.ha.org.hk/opendata/aed/aedwtdata-en.json"

def fetch_aed_waiting(client):
    """获取全港急症室实时轮候时间"""
    data = client.fetch(HA_AED_URL, fmt='json', use_cache=False)  # 急症室数据必须实时
    if not data or 'waitingTime' not in data:
        return []
    
    results = []
    for entry in data['waitingTime']:
        hosp = entry.get('hospName', '')
        top_wait = entry.get('topWait', '').replace('Around ', '').replace('Over ', '>')
        results.append({
            'hospital': hosp,
            'top_wait': top_wait
        })
    
    return sorted(results, key=lambda x: x['top_wait'].replace('>','').replace(' hours','').replace(' hour',''), reverse=True)

waiting = fetch_aed_waiting(client)
print("全港急症室轮候时间(实时):")
for i, w in enumerate(waiting[:8], 1):
    print(f"  {i}. {w['hospital']}: {w['top_wait']}")

这个API的响应里有个特殊的地方------轮候时间超过8小时时 topWait 字段会变成 "Over 8 hours" 而不是数字。所以在排序时要手动处理这个字符串,否则会报错(我第一条评论里问的就是这个坑)。

收藏提示③:这三个API覆盖了三种典型的香港公开数据格式:两级JSON(城巴)、CSV文件(租金)、单层JSON(急症室)。框架的 fmt 参数只需传 'json''csv' 就能自动切换解析器。

六、框架总览

把四个组件拼起来,核心不到80行:

python 复制代码
import requests, time, json, hashlib, csv, io
from urllib.parse import urlparse
from collections import defaultdict
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

class HKOpenDataClient:
    def __init__(self, cache_ttl=30, rate_interval=1.0):
        self.s = requests.Session()
        self.s.mount("https://", HTTPAdapter(max_retries=Retry(total=3, backoff_factor=1,
            status_forcelist=[429,500,502,503,504])))
        self.s.headers.update({'User-Agent': 'HKOpenData/1.0', 'Accept': 'application/json'})
        self._cache, self._ttl, self._last, self._rate = {}, cache_ttl, defaultdict(float), rate_interval
    
    def fetch(self, url, params=None, fmt='json', use_cache=True):
        k = hashlib.md5((url + json.dumps(params or {}))).hexdigest()
        if use_cache and k in self._cache and time.time() - self._cache[k][0] < self._ttl:
            return self._cache[k][1]
        domain = urlparse(url).netloc
        elapsed = time.time() - self._last[domain]
        if elapsed < self._rate: time.sleep(self._rate - elapsed)
        self._last[domain] = time.time()
        resp = self.s.get(url, params=params, timeout=15)
        if resp.status_code != 200: return None
        if fmt == 'csv':
            resp.encoding = 'utf-8'
            data = list(csv.DictReader(io.StringIO(resp.text)))
        else:
            data = resp.json()
        if data: self._cache[k] = (time.time(), data)
        return data

七、三条使用铁律

  1. 尊重刷新周期。城巴每1分钟、急症室每15分钟、租金每月------设缓存TTL不要短于刷新周期的一半。否则你拿到的只是重复数据,服务器还会记恨你。

  2. 先查元数据再查实时数据。城巴/九巴/港铁的API都是两级结构------先拿路线列表、再拿车站列表、最后查ETA。跳过前两步直接写死ID可能在你的机器上跑得通,但换个车站就崩。

  3. 空数据≠错误 。香港政府API在极端情况(巴士末班车已过、急症室无等待数据)会返回空数组 [] 而不是404。你的代码必须兼容两种响应------有数据正常处理,空数组也要优雅跳过。


数据来源:香港政府公开数据平台(data.gov.hk) · 城巴有限公司 · 差饷物业估价署 · 医院管理局。三个演示API全部免注册、免Key、可直接调用。

相关推荐
hey_sml1 小时前
MySQL常用操作速记:从字符集到游标全解析
java·开发语言·mysql
广东程序猿小展1 小时前
《007初露锋芒》风灵月影13项修改器下载 007初露锋芒修改器使用方法
开发语言·javascript·ecmascript
掉鱼的猫1 小时前
Solon AOT & Native:三段式编译,从 Java 到原生可执行文件
java·云原生
Java内核笔记1 小时前
容错能力进入 spring-core:Spring Boot 4 原生重试机制全解析
java·后端
李剑一1 小时前
AI生成的配图没有灵魂?我找到一个能生成灵魂文章配图的Skill
aigc·ai编程
Flynt1 小时前
给AI编程工具装了张"代码地图"后,它终于不瞎猜了
ai编程·claude·mcp
未秃头的程序猿1 小时前
虚拟线程上线一周后翻车了——pinning问题排查实录
java·后端·架构
wuminyu1 小时前
JDK21解决虚拟线程IO阻塞原理剖析
java·linux·c语言·jvm·c++
李白的天不白1 小时前
python下载脚本
python