Перейти к основному содержимому

Live feed матчей через WebSocket

Пример: получить актуальное состояние всех live-футбольных матчей и подписаться на обновления через WebSocket.

Два шага: REST-сид + WS

Подписка на весь спорт (sport:football) не присылает начальные snapshot по уже идущим матчам — сервер шлёт их только при подписке на конкретный матч (см. Snapshot vs Delta). Поэтому актуальное состояние сначала засеваем через REST (GET /v2/football/matches?status=inprogress), а затем применяем WS-delta поверх.

Node.js (ws + deepmerge)

import WebSocket from 'ws';
import deepmerge from 'deepmerge';

const KEY = process.env.API_SPORT_KEY; // Node 18+ — global fetch
const matches = new Map(); // matchId → полная структура Match

// Засеять текущее состояние live-матчей через REST: sport-подписка не присылает
// начальные snapshot, поэтому без сидинга delta по уже идущим матчам применить не к чему.
async function seedLiveMatches() {
const res = await fetch('https://api.api-sport.ru/v2/football/matches?status=inprogress', {
headers: { Authorization: KEY },
});
const { matches: live = [] } = await res.json();
for (const m of live) { matches.set(m.id, m); renderMatch(m); }
console.log(`Seeded ${live.length} live matches via REST`);
}

function connect(retryDelayMs = 1000) {
const ws = new WebSocket(`wss://ws.api.api-sport.ru/?apiKey=${KEY}`);

ws.on('message', (raw) => {
const msg = JSON.parse(raw);

switch (msg.type) {
case 'connected':
retryDelayMs = 1000; // успешно подключились — сбрасываем backoff
// Сначала REST-сид, затем подписка — чтобы delta было к чему применять
// (и пересеваем после каждого reconnect).
seedLiveMatches().finally(() => ws.send(JSON.stringify({
action: 'subscribe',
type: 'sport',
sportSlug: 'football',
})));
break;

case 'subscribed':
console.log('Subscribed to', msg.topic);
break;

case 'match_snapshot':
matches.set(msg.matchId, msg.data);
renderMatch(msg.data);
break;

case 'match_delta': {
const prev = matches.get(msg.matchId);
if (!prev) return; // delta до snapshot — игнорируем
let next = prev;
if (msg.changes?.added) next = deepmerge(next, msg.changes.added);
if (msg.changes?.updated) next = deepmerge(next, msg.changes.updated);
matches.set(msg.matchId, next);
renderMatch(next);
break;
}

case 'error':
console.error(`[ws-error] ${msg.code}: ${msg.message}`);
break;
}
});

ws.on('close', (code, reason) => {
console.warn('WS closed:', code, reason.toString());
if (code === 1008) {
// Auth / access — переподключение не поможет
console.error('Auth error, abort');
return;
}
setTimeout(() => connect(Math.min(retryDelayMs * 2, 60000)), retryDelayMs);
});

ws.on('error', (err) => console.error('WS error:', err));
}

function renderMatch(m) {
console.log(
`${m.homeTeam.name} ${m.homeScore.current} : ${m.awayScore.current} ` +
`${m.awayTeam.name} (${m.currentMatchMinute ?? '?'}')`
);
}

connect();

Python (websockets + deepmerge)

import asyncio, json, os, urllib.request
from websockets.asyncio.client import connect

KEY = os.environ['API_SPORT_KEY']
matches = {}

def seed_live_matches():
# sport-подписка не присылает начальные snapshot — засеваем состояние через REST.
req = urllib.request.Request(
'https://api.api-sport.ru/v2/football/matches?status=inprogress',
headers={'Authorization': KEY},
)
with urllib.request.urlopen(req) as r:
data = json.load(r)
live = data.get('matches', [])
for m in live:
matches[m['id']] = m
render(m)
print(f"Seeded {len(live)} live matches")

def deep_merge(target, source):
for k, v in source.items():
if isinstance(v, dict) and isinstance(target.get(k), dict):
deep_merge(target[k], v)
else:
target[k] = v
return target

async def main():
backoff = 1
while True:
try:
async with connect(f'wss://ws.api.api-sport.ru/?apiKey={KEY}') as ws:
async for raw in ws:
msg = json.loads(raw)
msg_type = msg.get('type')

if msg_type == 'connected':
backoff = 1
await asyncio.to_thread(seed_live_matches) # REST-сид перед подпиской
await ws.send(json.dumps({
'action': 'subscribe',
'type': 'sport',
'sportSlug': 'football',
}))
elif msg_type == 'match_snapshot':
matches[msg['matchId']] = msg['data']
render(msg['data'])
elif msg_type == 'match_delta':
prev = matches.get(msg['matchId'])
if prev is None:
continue
changes = msg.get('changes') or {}
if changes.get('added'):
deep_merge(prev, changes['added'])
if changes.get('updated'):
deep_merge(prev, changes['updated'])
render(prev)
elif msg_type == 'error':
print('error:', msg['code'], msg['message'])
except Exception as e:
print('connection lost:', e)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)

def render(m):
print(
f"{m['homeTeam']['name']} {m['homeScore']['current']} : "
f"{m['awayScore']['current']} {m['awayTeam']['name']} "
f"({m.get('currentMatchMinute', '?')}')"
)

asyncio.run(main())

Что важно

  • REST-сид для sport-подпискиsport:<slug> не присылает начальные snapshot; засейте live-матчи через GET /v2/{sport}/matches?status=inprogress и пересевайте после reconnect.
  • Авторизация — через query-параметр ?apiKey=..., не через JSON-сообщение.
  • Auto-reconnect: на любой close-код кроме 1008 — переоткрыть с экспоненциальным backoff.
  • State sync: после reconnect — перезалить state из новых match_snapshot'ов, не доверять старому.
  • Дельта-merge: ВСЕГДА deep-merge, не shallow replace.
  • Heartbeat: WebSocket-клиент отвечает на ping-frame автоматически. Прикладной код не нужен.

См. также WebSocket → snapshot vs delta и WebSocket → error codes.