实时推送
比分、事件、统计、文字直播、动画数据三种方式推送:WebSocket(双向,可随时改订阅)、SSE(单向,最简单)、MQTT(适合已有 MQTT 基础设施)。三种方式数据相同。
开通与频道
密钥规则:API Key 只存在于你的服务端。浏览器和 App 不拿 Key:由你的服务端用 Key 换一次性票据(60 秒内有效、只能用一次)交给前端,前端只用票据连接 WebSocket。SSE 和 MQTT 要带 Key,所以只在服务端用。
- 实时推送需要 key 开通包月实时权限;未开通时返回 403「实时推送需包月订阅」。
- 连上后先收到一条
subscribed回执,接着是当前进行中比赛的快照(snapshot: true),之后是实时变动。 - 消息都是 JSON:
{"type": 频道名, "data": {...}},比赛 id 在data.match_id(与 REST 接口的比赛 id 相同)。 - 订阅范围:频道(channels)、比赛(matches,比赛 id)、联赛(leagues,联赛名);都不填就是全部。
| 频道 | 说明 |
|---|---|
| match | 比分、状态、比赛分钟,主客队 7 元组 [比分, 半场, 红牌, 黄牌, 角球, 加时, 点球](保留最新一条) |
| incident | 进球、红黄牌、换人、VAR 等事件,发生即推 |
| stats | 本场技术统计(保留最新一条) |
| tlive | 文字直播。需单独开通:没开通的 key 收不到这个频道(文字直播也可以用 REST 接口取) |
| animation | 动画直播数据:球的位置、进攻势能、事件线 |
| odds / odds_market / props | 需要 key 开通对应数据域 |
WebSocket
- 地址
wss://api.sports.csap.ai/ws?ticket=<ticket>- 鉴权
- 先
POST https://api.sports.csap.ai/api/v1/realtime/ticket(带 X-API-Key)换一次性票据,60 秒内用它连接,用过即失效。key 不会出现在 WebSocket 地址里。 - 订阅
- 连接后发送 JSON:
{"action": "set", "channels": ["match"], "matches": ["4230"]}。action 可选 set(替换)、subscribe(追加)、unsubscribe(去掉)、all(恢复全部)。也可以在地址上带&channels=&matches=&leagues=。
// 1) 在你的服务端(Node 18+):用 key 换一次性票据(60 秒内有效,只能用一次)
const API_KEY = process.env.API_KEY
const r = await fetch("https://api.sports.csap.ai/api/v1/realtime/ticket", {
method: "POST",
headers: { "X-API-Key": API_KEY },
})
const { data } = await r.json()
// 2) 在浏览器 / App 里,或在服务端:只用票据连接(把 data.ws_url 交给前端,不要给 key)。初始订阅写在地址上,连上时推的快照也会按它过滤
const ws = new WebSocket("wss://api.sports.csap.ai" + data.ws_url + "&channels=match,incident")
// 之后要改订阅:ws.send(JSON.stringify({ action: "set", channels: ["match"], matches: ["4230"] }))
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data)
if (msg.type === "match") console.log(msg.data.match_id, msg.data.home_scores, msg.data.away_scores)
}
// 断线后重新取票据再连;重连后的快照会把断线期间的变化补上(见下面「断线、重连与补齐」)消息样例(实测)
{"type":"subscribed","filtered":false,"rules":[],"available_channels":["match","incident","odds","animation","tlive","stats","odds_market","props"]}
{
"type": "match",
"snapshot": true,
"data": {
"match_id": "4230",
"gmid": "4230",
"league": "Mongolian Premier League",
"home_team": "Khaan Khuns-Erchim FC",
"away_team": "Khovd Western",
"status": "inprogress",
"status_id": 2,
"minute": "16",
"home_scores": [0, 0, 0, 0, 0, null, 0],
"away_scores": [0, 0, 0, 0, 0, null, 0],
"incidents": [{ "type": "match_started", "time": null, "is_home": false, "player_name": null }],
"stats": [{ "key": "goals", "name": "Goals", "home": "0", "away": "0", "home_num": 0, "away_num": 0 }],
"version": 31
}
}试连
断线、重连与补齐
服务端没有消息序号,也没有重放缓冲:断线期间的消息不会补发。补齐靠两件事:重连后服务端再推一遍的快照,和客户端按下面的键去重。各语言的 SDK 已经按这套规则实现,自己写客户端时请照着做。 用 SDK 就不用自己实现 →
- 每次连接都要一张新票据:票据 60 秒内有效、只能用一次,重连前重新调
POST /api/v1/realtime/ticket。 - 重连间隔建议用指数退避加随机抖动:1 秒起、每次翻倍、最长 30 秒;重新收到数据后从 1 秒重新算。
- 连上后重发全部订阅:第一条规则用
set,其余用subscribe。没在地址上带订阅的连接,服务端最多等 1 秒收第一条订阅消息,再按它推快照;之后追加的规则不会再触发快照,对应的数据要等下一次变动或下次重连。 - 快照(
snapshot: true)和实时变动的结构完全一样。比赛快照带整场事件表incidents和统计stats,断线期间的进球、红黄牌都能从快照里对出来。 - 事件频道
incident不推快照,断线期间的单条事件不会补发。需要逐条事件时,用比赛快照里的incidents对账。 - 这些情况不要重连:换票据返回 401 / 403(key 无效或已停用、没开实时推送、IP 不在 key 的白名单);换票据返回 429 且是每日配额用完(当天换不到票据,次日再连);WebSocket 升级返回 403(IP 不在白名单;浏览器看不到这个状态码,只能照常重试);服务端以关闭码 1008 断开(服务端每 30 秒复核一次 key 的实时权限,权限被收回时这样断开)。其余情况都重试:换票据遇到每分钟限速、5xx、网络错误;升级返回 401(票据过期或已用过)、503(连接数满)、连接超时。
按什么去重
重连后的快照会把你已经收到过的内容再推一遍,服务端平时也可能重发同一条。只丢弃「内容完全相同」的重复,不要按版本号或序号一刀切:
| 频道 | 连上推快照 | 去重键和规则 |
|---|---|---|
| match | 是 | 按 match_id:version 比已见最大值小的丢弃;相等时,只有内容和上一条保留的完全相同才丢弃;更大的照常处理。没有 version 的不丢 |
| incident | 否 | 按 dkey:完全重复(内容相同)的丢弃;dkey 相同但内容不同的照常处理,它是补全 / 更正,或同一分钟的另一个事件(SDK 标 revised)。没有 dkey 的不丢 |
| stats | 否(在 match 快照里) | 都处理;每条覆盖这场的上一条 |
| animation | 是 | kind 为 ball:只有和这场上一帧完全相同才丢弃(seq 是场面码,不是序号)。其它 kind 都处理 |
| odds / odds_market / props | 是 | 都处理;每条覆盖这场的上一条 |
- 「内容相同」指消息的
data作为 JSON 值深度相等(对象不看键的顺序,1和1.0相等);snapshot标记在data外面,不参与比较。 match.version只在比分、状态、分钟变化时才加一。只补了事件或统计时,服务端会用同一个 version 再推一次,所以不能「version 相等就丢」。- 动画
ball帧的seq是场面码,不是递增序号,不能拿它排序或去重。 - 同一分钟同一方的两个同类事件(比如一次换两个人)
dkey相同;同一事件后来补全字段再推一次,dkey也相同。SDK 把这种情况标成revised交给你,由你按内容判断(比较球员,或以比赛消息里的整场事件表为准)。 - 连接快照里的比赛消息有时只有比分、没有事件表,和之前收到的同版本消息内容不同,会照常给你。合并状态时,不要用缺少
incidents的同版本快照清空已有的事件表。
订阅消息的四个坑
unsubscribe只按频道去规则。它去掉「频道与channels有交集」的规则;没写频道(全频道)的规则不会被去掉;matches、leagues不参与匹配。channels、matches、leagues都不带 = 全退订。- 先收全量、再退订一个频道 = 什么都收不到。连接是全量(
all,或一直没发过订阅)时发unsubscribe,结果是「已过滤、零条规则」,不是「全量减去这个频道」。想收除某个频道以外的全部,用set/subscribe把要的频道列出来。 - 服务端会忽略不认识的频道名。
set/subscribe里的频道全写错 = 全频道;unsubscribe里的频道全写错 = 全退订。SDK 会在本地拒绝不在频道清单里的名字;自己写客户端时先校验。 - 一条订阅消息不能超过 8192 字节(UTF-8)。超过时服务端以关闭码 1009 断开;重连后重发同一条又被断,变成死循环。大约 700 个比赛 id 就会超限,请拆成多条规则分别
subscribe。SDK 会在本地拒绝超长的消息。
心跳,和一个容易踩的坑
- 服务端每 30 秒发一次 WebSocket ping,70 秒内收不到任何回应就断开。浏览器和大多数 WebSocket 库会自动回 pong,不用自己处理。
- 浏览器里看不到 ping 帧,没有比赛时又可能很久没有数据。想主动发现已经断掉的连接,可以定时发一条订阅消息当心跳:每条订阅消息服务端都会回一条
subscribed,发出后 10 秒内什么都没收到,就断开重连。 - 注意:
set的意思是「把全部规则替换成这一条」。连接上只有一条规则时,重发它没有副作用,也不会重推快照;有两条及以上规则时,重发第一条规则的set会把其余规则全部去掉。这时心跳要改发下面第二条消息:它只带一个不存在的比赛 id、不带频道,服务端什么都不会去掉,照常回subscribed。
// 连接上只有一条规则:重发它没有副作用
{"action": "set", "channels": ["match"], "matches": ["4230"]}
// 两条及以上规则:发一条不改变订阅的消息,服务端照常回 subscribed
{"action": "unsubscribe", "matches": ["__heartbeat__"]}SSE(Server-Sent Events)
- 地址
GET https://api.sports.csap.ai/sse?channels=match,incident&matches=&leagues=- 鉴权
- 请求头 X-API-Key。SSE 要带 key,所以适合服务端,或者由你的服务端转发给浏览器;浏览器直接看实时数据请用 WebSocket 加票据。浏览器的 EventSource 不能带请求头,只能写
?key=:仍可用,但不建议,key 会留在地址和日志里。 - 订阅
- 由地址参数决定:
channels、matches、leagues,逗号分隔;要改订阅就重新连接。服务器每 25 秒发一次: ping注释行保活。
curl -N "https://api.sports.csap.ai/sse?channels=match,incident" -H "X-API-Key: YOUR_API_KEY"
消息样例(实测)
retry: 3000
: connected
data: {"type":"subscribed","filtered":true,"rules":[{"channels":["match","incident"],"scope":"*"}],"available_channels":["match","incident","odds","animation","tlive","stats","odds_market","props"]}
data: {"type":"match","snapshot":true,"data":{"match_id":"4230","gmid":"4230","status":"inprogress","status_id":2,"minute":"16", …}}
: ping试连
MQTT
- 地址
wss://api.sports.csap.ai/mqttMQTT over WebSocket(3.1.1);暂不开放 TCP 1883。- 鉴权
- 用户名和密码都填你的 key;clientId 自定,每个连接唯一。只能订阅,不能发布。
- 主题
football/<频道>/<联赛名>/<比赛 id>,联赛名里的空格、/、+、# 换成下划线,没有联赛时为_。例:football/match/#订阅全部比分,football/+/+/4230只订一场比赛。- 规划中
- 兼容主题
sports/football/match.v5:规划中,现在可以订阅但还不会收到消息。
// 在你的服务端运行(MQTT 要带 API Key)
// npm i mqtt
import mqtt from "mqtt"
const API_KEY = "YOUR_API_KEY"
const client = mqtt.connect("wss://api.sports.csap.ai/mqtt", {
username: API_KEY,
password: API_KEY,
clientId: "my-app-" + Math.random().toString(16).slice(2, 10),
protocolVersion: 4,
})
client.on("connect", () => client.subscribe("football/match/#"))
client.on("message", (topic, payload) => {
const msg = JSON.parse(payload.toString())
console.log(topic, msg.data.home_scores, msg.data.away_scores)
})消息样例(实测)
topic: football/match/Mongolian_Premier_League/4230
{
"type": "match",
"snapshot": true,
"data": {
"match_id": "4230",
"gmid": "4230",
"league": "Mongolian Premier League",
"home_team": "Khaan Khuns-Erchim FC",
"away_team": "Khovd Western",
"status": "inprogress",
"status_id": 2,
"minute": "16",
"home_scores": [0, 0, 0, 0, 0, null, 0],
"away_scores": [0, 0, 0, 0, 0, null, 0],
"incidents": [{ "type": "match_started", "time": null, "is_home": false, "player_name": null }],
"stats": [{ "key": "goals", "name": "Goals", "home": "0", "away": "0", "home_num": 0, "away_num": 0 }],
"version": 31
}
}