☰
篮球赛事 API 实战:从鉴权到封装一个可用的 Python SDK
2026/10/14 21:22:12 网站建设 项目流程

为什么需要赛事数据 API

做体育类应用、数据分析或自动化推送时,第一道坎往往不是业务逻辑,而是数据源。自己爬网页要面对反爬、页面改版、时区混乱、字段缺失等问题。一个结构稳定的 HTTP API 能把这些问题收敛掉,让开发者专注在业务侧。

本文以极数本源篮球赛事 API(https://apizero.cn/marketplace/basketball)为例,完整走一遍从读文档、调通接口到封装 SDK 的流程。接口覆盖 NBA、WNBA、NCAA 男篮、CBA,返回开赛时间、比分、比赛状态与场馆,不含赔率或门票,适合资讯展示类场景。

接口速览

先建立一张全局认知表,后面所有代码都围绕它展开。

项目值
请求地址https://v1.apizero.cn/api/basketball
请求方式POST(也支持 GET 形式传参)
参数格式FORM / QUERY / JSON
返回格式application/json
鉴权方式Authorization: Bearer <API Key>
免费额度登录后 20 次/日,QPS 40

请求头鉴权是主推方式,同时兼容X-API-Key以及 Query 参数api_key/apikey/key(其中key仅在值为sk_live_/sk_test_/sk_stag_形态时生效)。生产环境请只用请求头,把 Key 放进 URL 会留在日志和浏览器历史里。

请求参数

参数类型必填说明
datestring否比赛日期,YYYY-MM-DD或YYYYMMDD,缺省为北京时间当日
limitint否返回条数,1–200,缺省 80

注意date是北京日期,不是 UTC。这意味着你在服务器上做定时任务时,不能直接用datetime.utcnow().date(),否则跨零点会查错比赛日。

返回结构

顶层是统一的code/msg/data/tips信封,data里分两部分:

  • summary:本次查询的元信息,含sport、date、league、status、count、total、live、generated_at
  • matches:场次数组,排序规则是进行中 → 未开赛按时间 → 已结束

单场比赛的关键字段:

{ "id": "401810001", "league_name": "NBA", "start_at": "2026-09-11 09:00:00", "status": "finished", "home": { "name": "Los Angeles Lakers", "short": "LAL", "score": 112 }, "away": { "name": "Golden State Warriors", "short": "GSW", "score": 108 } }

status只有三个枚举值:live、upcoming、finished,前端可以直接映射成样式,不需要自己猜状态字符串。

用 curl 先跑通

在写任何代码之前,先用最短路径验证 Key 和网络。

curl -X POST 'https://v1.apizero.cn/api/basketball' \ -H 'Authorization: Bearer sk_live_xxxxxxxx' \ -H 'Content-Type: application/json' \ -d '{"date": "2026-09-11", "limit": 80}'

如果返回code: 0,说明链路通。若返回鉴权失败,按顺序检查:Key 是否带了Bearer前缀、是否有多余空格、是否是已激活的套餐。免费用户未携带 Key 时每天只有 5 次、QPS 1,很容易误判为接口故障。

封装一个 Python SDK

直接用requests散落调用会带来三个问题:重复的鉴权代码、无重试、无类型提示。下面封装一个轻量客户端。

依赖与基础客户端

# basketball_client.py from __future__ import annotations import time import logging from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any import requests logger = logging.getLogger(__name__) BASE_URL = "https://v1.apizero.cn/api/basketball" BEIJING_TZ = timezone(timedelta(hours=8)) class ApiZeroError(RuntimeError): """接口返回 code != 0 时抛出。""" def __init__(self, code: int, msg: str): super().__init__(f"[{code}] {msg}") self.code = code self.msg = msg @dataclass class Team: name: str short: str | None score: int | None @dataclass class Match: id: str league_name: str start_at: str status: str home: Team away: Team @property def is_live(self) -> bool: return self.status == "live" @property def display(self) -> str: hs = self.home.score if self.home.score is not None else "-" as_ = self.away.score if self.away.score is not None else "-" return f"{self.home.short or self.home.name} {hs} : {as_} {self.away.short or self.away.name}"

核心请求逻辑

关键点是:把「北京日期」的计算固化到客户端内部,业务侧永远不用关心时区;同时加上指数退避重试,只对网络错误和 5xx 重试,不对 4xx 重试——参数错了重试一百次也没用。

class BasketballClient: def __init__(self, api_key: str, timeout: int = 10, max_retries: int = 3): if not api_key: raise ValueError("api_key 不能为空") self.api_key = api_key self.timeout = timeout self.max_retries = max_retries self._session = requests.Session() self._session.headers.update({ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json", }) @staticmethod def beijing_today() -> str: """接口的 date 参数是北京日期,不要用 UTC。""" return datetime.now(BEIJING_TZ).strftime("%Y-%m-%d") def _post(self, payload: dict[str, Any]) -> dict[str, Any]: last_exc: Exception | None = None for attempt in range(1, self.max_retries + 1): try: resp = self._session.post(BASE_URL, json=payload, timeout=self.timeout) # 4xx 是客户端问题,直接抛出不重试 if 400 <= resp.status_code < 500: resp.raise_for_status() resp.raise_for_status() body = resp.json() if body.get("code") != 0: raise ApiZeroError(body.get("code", -1), body.get("msg", "unknown")) return body["data"] except ApiZeroError: raise except (requests.ConnectionError, requests.Timeout, requests.HTTPError) as exc: last_exc = exc # 429 限流需要更长的冷却 wait = 2 ** attempt if isinstance(exc, requests.HTTPError) and exc.response is not None \ and exc.response.status_code == 429: wait = 5 * attempt logger.warning("第 %d 次请求失败:%s,%.1fs 后重试", attempt, exc, wait) if attempt < self.max_retries: time.sleep(wait) raise RuntimeError(f"请求失败,已重试 {self.max_retries} 次") from last_exc def fetch_matches(self, date: str | None = None, limit: int = 80) -> list[Match]: if not 1 <= limit <= 200: raise ValueError("limit 必须在 1–200 之间") data = self._post({"date": date or self.beijing_today(), "limit": limit}) return [self._parse_match(m) for m in data.get("matches", [])] @staticmethod def _parse_match(raw: dict[str, Any]) -> Match: def to_team(obj: dict[str, Any]) -> Team: return Team( name=obj.get("name", ""), short=obj.get("short"), score=obj.get("score"), ) return Match( id=str(raw.get("id", "")), league_name=raw.get("league_name", ""), start_at=raw.get("start_at", ""), status=raw.get("status", ""), home=to_team(raw.get("home", {})), away=to_team(raw.get("away", {})), )

这里有个容易踩的坑:score在upcoming状态下可能缺失或为 0。上面用obj.get("score")保留None,前端就能区分「0 分」和「还没开始」。

落地场景一:每日赛程看板

把当天的比赛按状态分组,是资讯类页面最常见的需求。

def render_board(client: BasketballClient, date: str | None = None) -> str: matches = client.fetch_matches(date=date, limit=200) groups: dict[str, list[Match]] = {"live": [], "upcoming": [], "finished": []} for m in matches: groups.setdefault(m.status, []).append(m) lines: list[str] = [] title = {"live": "🔴 进行中", "upcoming": "🕐 未开赛", "finished": "✅ 已结束"} for key in ("live", "upcoming", "finished"): items = groups.get(key) or [] if not items: continue lines.append(f"\n{title[key]}({len(items)} 场)") for m in items: lines.append(f" [{m.league_name}] {m.start_at} {m.display}") return "\n".join(lines) or "今日暂无比赛" if __name__ == "__main__": import os client = BasketballClient(os.environ["APIZERO_KEY"]) print(render_board(client))

运行前设置环境变量:export APIZERO_KEY=sk_live_xxx。永远不要把 Key 硬编码进仓库。

落地场景二:实时比分轮询

live字段告诉你当前有几场进行中。做实时看板时,只需要在存在进行中场次时提高轮询频率,否则降到低频,这样能显著节省免费额度。

def watch(client: BasketballClient, interval_live: int = 30, interval_idle: int = 600): """有直播时 30s 轮询,否则 10 分钟一次。注意免费额度 20 次/日。""" last_snapshot: dict[str, tuple[int | None, int | None]] = {} while True: matches = client.fetch_matches(limit=200) live = [m for m in matches if m.is_live] for m in live: cur = (m.home.score, m.away.score) if last_snapshot.get(m.id) != cur: print(f"比分更新 {m.display} ({m.start_at})") last_snapshot[m.id] = cur time.sleep(interval_live if live else interval_idle)

免费方案每天 20 次,30 秒轮询会在 10 分钟内耗尽额度。生产环境建议:本地缓存 + 只在summary.live > 0时短轮询,或直接升级到黄金会员(4,000 次/日、QPS 80)。

工程化注意事项

缓存策略

赛程数据的时间局部性很强:已结束的比赛不会变,未开赛的比赛只有start_at可能微调。可以按「日期 + 状态」做分层缓存:

  • finished:缓存 24 小时,几乎不用再查
  • upcoming:缓存 10 分钟
  • live:缓存 15–30 秒

时区统一

接口返回的start_at已经是北京时间字符串,不要再做二次时区转换。如果前端要按用户本地时区展示,应在浏览器侧解析,而不是在服务端改动原始值。

错误处理清单

现象可能原因处理方式
鉴权失败未带 Key / Key 失效 / 免费额度用尽检查请求头,确认套餐状态
429超出 QPS 或日额度指数退避,降低轮询频率
code != 0参数非法校验date格式与limit范围
数据为空该日确无比赛(如休赛期)按空态展示,不要当错误

免责声明的工程含义

官方明确说明:赛程与比分来自公开体育数据源,可能有延迟或漏场;CBA 为独立备源,覆盖可能少于 NBA;仅供资讯展示,不构成投注建议。落到代码上就是两点:不要把它当作强一致数据源做资金类决策,以及前端必须展示数据延迟提示。

小结

本文从接口文档出发,完成了三件事:

  1. 理清了鉴权、参数与返回结构,尤其是date是北京日期这个易错点
  2. 封装了一个带类型提示、指数退避和 429 特殊处理的可运行 SDK
  3. 给出赛程看板与实时轮询两个落地场景,并讨论了缓存、时区与额度控制

接口本身不复杂,真正决定项目稳定性的往往是这些工程细节。把 SDK 抽出来之后,业务层只需要调client.fetch_matches(),剩下的时区、重试、解析都由客户端兜住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询