// BestsOddsApi — cliente Node.js (>= 22; usa fetch e WebSocket nativos, sem dependências). // // Uso: // export BESTSODDSAPI_BASE_URL="http://localhost:8010" // export BESTSODDSAPI_CLIENT_ID="" // export BESTSODDSAPI_CLIENT_SECRET="" // node docs/examples/node/client.mjs # REST // node docs/examples/node/client.mjs --stream # REST + WebSocket (odds de um evento) // node docs/examples/node/client.mjs --live # REST + WebSocket (feed ao vivo do esporte) // // Nunca grave segredos neste arquivo. Preços são strings decimais: não converta // para Number para comparar valores de negócio. // // --------------------------------------------------------------------------- // WebSocket (docs/streaming/websocket-admission.md): protocolo Pusher 7 // (Laravel Reverb) atrás do gateway de admissão. POST /api/v1/ws/authorize // devolve um ticket de uso único (TTL ~45 s) e data.connection.url // (wss://HOST/app/{APP_KEY}?protocol=7&client=…&version=…&ticket=…). // O cliente abaixo implementa o subconjunto necessário do protocolo Pusher // (connection_established, pusher:subscribe SEM auth — o gateway autoriza pelo // ticket —, ping/pong, pusher:error). Cada assinatura bem-sucedida recebe primeiro // o estado atual (odds.snapshot / live.snapshot, servido do armazenamento BestsOddsApi, // nunca da origem) e depois deltas (odds.changed / live.updated) com `sequence`. // Com pusher-js/laravel-echo use // wsPath: connection.ws_path e um channelAuthorization.customHandler que // devolve { auth: '' }. // Códigos de encerramento via pusher:error: 4009 sessão revogada (não // reconectar), 4301 limite de mensagens, 4100 limite de conexões, // 4201 pong timeout, 4200 servidor reiniciando. // --------------------------------------------------------------------------- const BASE_URL = (process.env.BESTSODDSAPI_BASE_URL ?? 'http://localhost:8010').replace(/\/$/, ''); const CLIENT_ID = process.env.BESTSODDSAPI_CLIENT_ID; const CLIENT_SECRET = process.env.BESTSODDSAPI_CLIENT_SECRET; const SCOPES = process.env.BESTSODDSAPI_SCOPES ?? 'events:read odds:read results:read usage:read ws:connect'; const CONNECT_TIMEOUT_MS = 15_000; // handshake WS (ticket expira em ~45 s) if (!CLIENT_ID || !CLIENT_SECRET) { console.error('Defina BESTSODDSAPI_CLIENT_ID e BESTSODDSAPI_CLIENT_SECRET'); process.exit(1); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Exponential backoff com "full jitter": base 1 s, fator 2, teto 30 s. */ const backoff = (attempt) => Math.random() * Math.min(30_000, 1_000 * 2 ** attempt); export class BestsOddsApiError extends Error { constructor(status, code, message, requestId, retryAfter) { super(`[${status} ${code}] ${message} (request_id=${requestId})`); Object.assign(this, { status, code, requestId, retryAfter }); } } export class BestsOddsApiClient { #token = null; #tokenExpiresAt = 0; lastRateLimit = {}; async accessToken() { if (this.#token && Date.now() < this.#tokenExpiresAt - 60_000) return this.#token; const res = await fetch(`${BASE_URL}/oauth/token`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: CLIENT_ID, client_secret: CLIENT_SECRET, scope: SCOPES, }), signal: AbortSignal.timeout(10_000), }); const json = await res.json().catch(() => ({})); if (!res.ok || !json.access_token) { throw new BestsOddsApiError(res.status, json.error ?? 'token_error', json.error_description ?? json.message ?? 'Token request failed'); } this.#token = json.access_token; this.#tokenExpiresAt = Date.now() + json.expires_in * 1000; return this.#token; } async request(method, path, { query, body } = {}, attempt = 0) { const url = new URL(`${BASE_URL}/api/v1${path}`); for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); const res = await fetch(url, { method, headers: { Accept: 'application/json', Authorization: `Bearer ${await this.accessToken()}`, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10_000), }); this.lastRateLimit = { limit: res.headers.get('x-ratelimit-limit'), remaining: res.headers.get('x-ratelimit-remaining'), reset: res.headers.get('x-ratelimit-reset'), requestId: res.headers.get('x-request-id'), }; const json = await res.json().catch(() => ({})); if (res.ok) return json; const retryAfter = res.headers.get('retry-after'); if (res.status === 401 && attempt === 0) { this.#token = null; // token expirado/revogado: renova uma vez return this.request(method, path, { query, body }, attempt + 1); } if ((res.status === 429 || res.status === 503) && attempt < 3) { await sleep(retryAfter ? Number(retryAfter) * 1000 + Math.random() * 1000 : backoff(attempt)); return this.request(method, path, { query, body }, attempt + 1); } throw new BestsOddsApiError(res.status, json.error?.code ?? 'http_error', json.error?.message ?? `HTTP ${res.status}`, json.request_id, retryAfter); } get(path, query) { return this.request('GET', path, { query }); } post(path, body = {}) { return this.request('POST', path, { body }); } /** Itera todas as páginas (paginação por cursor). */ async *paginate(path, query = {}, perPage = 100) { let cursor = null; do { const page = await this.get(path, { ...query, per_page: perPage, ...(cursor ? { cursor } : {}) }); yield* page.data; cursor = page.meta.pagination?.next_cursor ?? null; } while (cursor); } } /** * Stream de odds de um evento via protocolo Pusher (gateway de admissão BestsOddsApi). * Estado inicial = mensagem `odds.snapshot` recebida logo após a assinatura; * depois aplica `odds.changed` com sequence maior e reconcilia via REST quando * `sequence` salta (entrega at-most-once). */ export async function streamEventOdds(client, eventId, { onChange = () => {}, signal } = {}) { const book = new Map(); // chave -> odd let lastSequence = null; let attempt = 0; const keyOf = (o) => `${o.bookmaker_id ?? o.bookmaker?.id}|${o.market}|${o.selection_key}|${o.line ?? ''}`; function loadSnapshot(rows, sequence, stale, reason) { book.clear(); for (const o of rows) book.set(keyOf(o), o); lastSequence = sequence ?? null; console.log(`[snapshot] ${reason}: ${book.size} seleções, sequence=${lastSequence}, stale=${stale}`); } async function resnapshot(reason) { const snap = await client.get(`/events/${eventId}/odds`); loadSnapshot(snap.data, snap.meta.sequence, snap.meta.stale, `REST (${reason})`); } function applyDelta(msg) { if (lastSequence !== null && msg.sequence <= lastSequence) return; // duplicada/antiga if (lastSequence !== null && msg.sequence > lastSequence + 1) { // Perda de mensagens (entrega at-most-once): reconcilia pelo REST. return resnapshot(`salto de sequence ${lastSequence} -> ${msg.sequence}`); } // Cada item de `markets` é o estado completo daquele mercado ([] = mercado removido). for (const m of msg.markets ?? []) { for (const [k, row] of book) { if (row.bookmaker_id === m.bookmaker_id && row.market === m.market) book.delete(k); } for (const s of m.selections ?? []) { const row = { ...s, bookmaker_id: m.bookmaker_id, market: m.market }; book.set(keyOf(row), row); } } lastSequence = msg.sequence; onChange(msg, book); } async function connectOnce() { // 1) Ticket novo a cada (re)conexão: uso único, TTL curto. const { data: ticket } = await client.post('/ws/authorize', { event_ids: [eventId] }); const conn = ticket.connection; // channel_template já contém o customerId derivado do token. const channel = conn.channel_template.replace('{eventId}', eventId); // 2) O estado inicial chega pelo próprio WS (odds.snapshot) logo após a assinatura. return new Promise((resolve, reject) => { // URL já contém ?ticket=... Rejeições do handshake (401 ticket inválido/expirado/ // reutilizado, 403, 429 limite de conexões, 503) chegam como falha de conexão. const ws = new WebSocket(conn.url); let activityTimer; let activityTimeoutMs = 120_000; // Só envia com o socket aberto; se o handshake não concluir a tempo, aborta // (o 'close' resultante dispara a reconexão com backoff + novo ticket). const send = (event, data) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ event, data })); }; const connectTimer = setTimeout(() => { if (ws.readyState !== WebSocket.OPEN) ws.close(); }, CONNECT_TIMEOUT_MS); ws.addEventListener('open', () => clearTimeout(connectTimer)); const armHeartbeat = () => { clearTimeout(activityTimer); activityTimer = setTimeout(() => { send('pusher:ping', {}); // Sem resposta em 30 s => sessão zumbi: fecha e reconecta. activityTimer = setTimeout(() => ws.close(4000, 'zombie'), 30_000); }, activityTimeoutMs); }; ws.addEventListener('message', async (ev) => { armHeartbeat(); const msg = JSON.parse(ev.data); const data = typeof msg.data === 'string' ? JSON.parse(msg.data || '{}') : msg.data; try { switch (msg.event) { case 'pusher:connection_established': { attempt = 0; activityTimeoutMs = (data.activity_timeout ?? 120) * 1000; // 3) Assina o canal privado. `auth` não é necessário: o gateway // autoriza pelo ticket (cliente + plano) e assina internamente. send('pusher:subscribe', { channel }); break; } case 'pusher_internal:subscription_succeeded': console.log(`[ws] assinado: ${channel}`); break; case 'pusher:subscription_error': // status 403 (não autorizado), 429 (limite de canais), 503 (indisponível) throw new BestsOddsApiError(data.status ?? 0, 'subscription_error', JSON.stringify(data)); case 'pusher:ping': send('pusher:pong', {}); break; case 'pusher:error': console.error('[ws] erro do servidor', data); if (data.code === 4009) { // Sessão revogada (credencial revogada / conta suspensa): não reconectar. throw new BestsOddsApiError(403, 'session_revoked', data.message ?? 'Session revoked'); } break; case 'odds.snapshot': // Deltas com sequence <= data.sequence já estão refletidos aqui. loadSnapshot(data.odds ?? [], data.sequence, data.stale, 'odds.snapshot'); break; case 'odds.changed': await applyDelta(data); break; } } catch (err) { ws.close(4001, 'client error'); reject(err); } }); ws.addEventListener('close', (ev) => { clearTimeout(connectTimer); clearTimeout(activityTimer); resolve(ev.code); }); ws.addEventListener('error', () => {}); // 'close' sempre segue }); } while (!signal?.aborted) { try { const code = await connectOnce(); console.warn(`[ws] conexão encerrada (code=${code})`); } catch (err) { // 401/403 em /ws/authorize ou 4009: credencial revogada ou conta suspensa — não insistir. if (err instanceof BestsOddsApiError && (err.status === 401 || err.status === 403)) throw err; console.warn(`[ws] falha: ${err.message}`); } const delay = backoff(attempt++); console.warn(`[ws] reconectando em ${Math.round(delay)} ms`); await sleep(delay); } } /** * Feed de eventos ao vivo de um esporte: canal private-customer.{customerId}.live.{sport} * (connection.live_channel_template). `live.snapshot` traz a lista atual (mesmo * formato de GET /events/live); `live.updated` traz só eventos alterados * (status/placar/timer ou que entraram ao vivo) e `removed` (ids que saíram). * Sequência por esporte: salto => refaça GET /events/live?sport=… (ou reassine). */ export async function streamLiveFeed(client, sport, { onChange = () => {}, signal } = {}) { const events = new Map(); let lastSequence = null; let attempt = 0; async function connectOnce() { const { data: ticket } = await client.post('/ws/authorize', {}); const conn = ticket.connection; const channel = conn.live_channel_template.replace('{sport}', sport); return new Promise((resolve, reject) => { const ws = new WebSocket(conn.url); // Só envia com o socket aberto; se o handshake não concluir a tempo, aborta // (o 'close' resultante dispara a reconexão com backoff + novo ticket). const send = (event, data) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ event, data })); }; const connectTimer = setTimeout(() => { if (ws.readyState !== WebSocket.OPEN) ws.close(); }, CONNECT_TIMEOUT_MS); ws.addEventListener('open', () => clearTimeout(connectTimer)); const pinger = setInterval(() => send('pusher:ping', {}), 60_000); ws.addEventListener('message', async (ev) => { const msg = JSON.parse(ev.data); const data = typeof msg.data === 'string' ? JSON.parse(msg.data || '{}') : msg.data; try { switch (msg.event) { case 'pusher:connection_established': attempt = 0; send('pusher:subscribe', { channel }); break; case 'pusher:subscription_error': // 403: esporte fora do plano/desabilitado; 429: limite de canais. throw new BestsOddsApiError(data.status ?? 0, 'subscription_error', JSON.stringify(data)); case 'pusher:error': if (data.code === 4009) throw new BestsOddsApiError(403, 'session_revoked', data.message ?? 'Session revoked'); break; case 'live.snapshot': events.clear(); for (const e of data.events) events.set(e.id, e); lastSequence = data.sequence; console.log(`[live.snapshot] ${sport}: ${events.size} eventos, sequence=${lastSequence}, stale=${data.stale}`); break; case 'live.updated': if (lastSequence !== null && data.sequence <= lastSequence) break; // antiga if (lastSequence !== null && data.sequence > lastSequence + 1) { const live = await client.get('/events/live', { sport, per_page: 100 }); events.clear(); for (const e of live.data) events.set(e.id, e); console.warn(`[live] salto de sequence ${lastSequence} -> ${data.sequence}: recarregado via REST`); } else { for (const e of data.events) events.set(e.id, e); for (const id of data.removed) events.delete(id); } lastSequence = data.sequence; onChange(data, events); break; } } catch (err) { ws.close(4001, 'client error'); reject(err); } }); ws.addEventListener('close', (ev) => { clearTimeout(connectTimer); clearInterval(pinger); resolve(ev.code); }); ws.addEventListener('error', () => {}); }); } while (!signal?.aborted) { try { const code = await connectOnce(); console.warn(`[live] conexão encerrada (code=${code})`); } catch (err) { if (err instanceof BestsOddsApiError && (err.status === 401 || err.status === 403)) throw err; console.warn(`[live] falha: ${err.message}`); } await sleep(backoff(attempt++)); } } // --- Demonstração ---------------------------------------------------------- const client = new BestsOddsApiClient(); try { const live = await client.get('/events/live', { sport: 'soccer', per_page: 10 }); console.log(`Eventos ao vivo: ${live.data.length} (stale=${live.meta.stale}, source_updated_at=${live.meta.source_updated_at})`); console.log('Rate limit:', client.lastRateLimit); const event = live.data[0]; if (event) { const odds = await client.get(`/events/${event.id}/odds`, { market: '1x2' }); for (const o of odds.data) { console.log(` ${o.bookmaker.slug.padEnd(10)} ${o.market.padEnd(6)} ${o.selection_key.padEnd(6)} ${o.price}${o.suspended ? ' (suspensa)' : ''}`); } } const usage = await client.get('/usage'); console.log('Uso (dia):', usage.data.windows.day); let results = 0; const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); for await (const _ of client.paginate('/results', { date: yesterday })) results++; console.log(`Resultados de ${yesterday}: ${results}`); if (process.argv.includes('--live')) { await streamLiveFeed(client, 'soccer', { onChange: (msg) => console.log(`[live.updated] seq=${msg.sequence} alterados=${msg.events.length} removidos=${msg.removed.length}`), }); } if (process.argv.includes('--stream') && event) { await streamEventOdds(client, event.id, { onChange: (msg) => console.log(`[odds.changed] seq=${msg.sequence} mercados=${(msg.markets ?? []).map((m) => m.market).join(',')} stale=${msg.stale}`), }); } } catch (err) { console.error(err.message); process.exit(1); }