/** * combatAdapter — ESPN MMA/UFC (FREE JSON, no auth) → normalized fight cards * + tale-of-the-tape (Wave 6, combat intelligence). * * DATA SEMANTICS: fighter records / physicals are REAL sourced facts. We never * fabricate. `Number(null) === 0` is the trap — an absent stat (reach, stance, * finish counts) stays ABSENT (null), never coerced to 0. ESPN's MMA striking/ * grappling granularity is THINNER than ufcstats; when a field is absent the * tape says less, never invents. * * Source: site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard * - The scoreboard `events[]` are UFC CARDS. Each event carries many * `competitions[]` — one per BOUT. Each bout has 2 competitors (fighters) * with athlete name, W-L-D record, weight class, scheduled rounds, and the * ESPN athlete id (parsed from the player-card link href). Stance/reach are * NOT in the free scoreboard → left null (absent), wired for a later enrich. * * Odds (moneyline + round total) come from the odds-api MMA feed and are parsed * by the PURE `normalizeCombatOdds` here (odds-api event shape → per-bout ML + * round total). VYNDR never generates odds — these are REAL book numbers. * * Everything is defensive: an unrecognized shape yields an empty result, never * a throw. `fetchImpl` is injectable so tests never touch the network. */ const axios = require('axios'); const { cacheGet, cacheSet } = require('../../utils/redis'); const { ALLOWED_BOOKS } = require('../../utils/oddsNormalizer'); const ESPN_MMA_SCOREBOARD = 'https://site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard'; const HTTP_TIMEOUT_MS = 10_000; const CARDS_TTL = 15 * 60; // 15 min — cards move slowly; keep it cheap const STALE_TTL = 6 * 3600; // stale-while-error fallback /** Real finite number or null — the absent-beats-zero guard. */ function numOrNull(v) { if (v === null || v === undefined || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null; } /** Today's ET date (YYYY-MM-DD). Fight days roll on ET like the other sports. */ function todayET() { return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()); } /** ET date (YYYY-MM-DD) of an ISO timestamp, or null if unparseable. */ function dateET(iso) { if (!iso) return null; const t = new Date(iso); if (Number.isNaN(t.getTime())) return null; return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', }).format(t); } /** Extract the ESPN athlete id from a fighter's link hrefs (…/id/4801725/…). */ function parseAthleteId(links) { if (!Array.isArray(links)) return null; for (const l of links) { const m = /\/id\/(\d+)\//.exec(l && l.href ? String(l.href) : ''); if (m) return m[1]; } return null; } /** Parse a "W-L-D" record summary into structured parts. Absent → nulls. */ function parseRecord(competitor) { const recs = competitor && Array.isArray(competitor.records) ? competitor.records : []; const overall = recs.find((r) => r && (r.type === 'total' || r.name === 'overall')) || recs[0]; const summary = overall && typeof overall.summary === 'string' ? overall.summary : null; let wins = null, losses = null, draws = null; if (summary) { const m = /^(\d+)\s*-\s*(\d+)(?:\s*-\s*(\d+))?/.exec(summary.trim()); if (m) { wins = numOrNull(m[1]); losses = numOrNull(m[2]); draws = m[3] != null ? numOrNull(m[3]) : 0; } } // Display form (26–1 with an en-dash, or 26–1–0 when a draw exists). let display = null; if (wins != null && losses != null) { display = draws ? `${wins}–${losses}–${draws}` : `${wins}–${losses}`; } return { wins, losses, draws, summary, display }; } /** Normalize ONE fighter (an ESPN competitor). Defensive; unknown fields null. */ function normalizeFighter(competitor) { if (!competitor || typeof competitor !== 'object') return null; const a = competitor.athlete || {}; const name = a.displayName || a.fullName || a.shortName || null; if (!name) return null; return { id: parseAthleteId(a.links), name, shortName: a.shortName || null, record: parseRecord(competitor), winner: competitor.winner === true ? true : (competitor.winner === false ? false : null), // Physicals absent from the free scoreboard — kept for a future enrich. stance: null, reach: null, }; } /** Normalize ONE bout (an ESPN competition). Returns null on an unusable shape. */ function normalizeBout(comp) { if (!comp || typeof comp !== 'object') return null; const competitors = Array.isArray(comp.competitors) ? comp.competitors : []; if (competitors.length < 2) return null; const ordered = [...competitors].sort((x, y) => (x.order ?? 0) - (y.order ?? 0)); const fighters = ordered.map(normalizeFighter).filter(Boolean); if (fighters.length < 2) return null; const st = comp.status && comp.status.type ? comp.status.type : {}; return { id: comp.id != null ? String(comp.id) : null, weightClass: (comp.type && (comp.type.text || comp.type.abbreviation)) || null, rounds: numOrNull(comp.format && comp.format.regulation && comp.format.regulation.periods), status: st.state || null, // pre | in | post completed: st.completed === true, fighters, }; } /** * Normalize a raw ESPN scoreboard payload into VYNDR fight cards. PURE + * defensive — a shape it doesn't recognize returns { events: [] }, never throws. */ function normalizeScoreboard(raw, { date } = {}) { const events = raw && Array.isArray(raw.events) ? raw.events : []; const cards = []; for (const ev of events) { if (!ev || typeof ev !== 'object') continue; const evDate = ev.date || null; // Date-pin defensively (same discipline as scheduleService S57): when a // date is requested, only events on that ET date survive. if (date && dateET(evDate) !== date) continue; const comps = Array.isArray(ev.competitions) ? ev.competitions : []; const bouts = comps.map(normalizeBout).filter(Boolean); if (bouts.length === 0 && !ev.id) continue; const comp0 = comps[0] || {}; cards.push({ id: ev.id != null ? String(ev.id) : null, name: ev.name || null, shortName: ev.shortName || null, date: evDate, dateET: dateET(evDate), venue: (comp0.venue && (comp0.venue.fullName || comp0.venue.shortName)) || null, bouts, }); } return { events: cards }; } async function doFetch(url, fetchImpl) { if (fetchImpl) return fetchImpl(url); const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS }); return res.data; } /** * getFightCards(date, opts) — cache-aside UFC cards for an ET date. * opts: { fetchImpl, cacheGet, cacheSet }. NO odds-api credits (ESPN is free). * Off-card windows return { events: [] } (honest empty), never a throw. */ async function getFightCards(date = todayET(), opts = {}) { const cGet = opts.cacheGet || cacheGet; const cSet = opts.cacheSet || cacheSet; const key = `combat:cards:${date}`; try { const cached = await cGet(key); if (cached && Array.isArray(cached.events)) return { ...cached, source: 'cache' }; } catch { /* cache miss → live */ } try { const sep = ESPN_MMA_SCOREBOARD.includes('?') ? '&' : '?'; const url = `${ESPN_MMA_SCOREBOARD}${sep}dates=${String(date).replace(/-/g, '')}`; const raw = await doFetch(url, opts.fetchImpl); const normalized = normalizeScoreboard(raw, { date }); const payload = { date, events: normalized.events, source: 'espn' }; try { await cSet(key, payload, CARDS_TTL); } catch { /* best-effort */ } return payload; } catch (err) { // Stale-while-error, else honest empty. try { const stale = await cGet(key); if (stale && Array.isArray(stale.events)) return { ...stale, source: 'stale' }; } catch { /* ignore */ } console.warn('[combatAdapter] getFightCards failed:', err && err.message); return { date, events: [], source: 'espn' }; } } /** * getFightCard(eventId, opts) — one UFC card (all its bouts). The scoreboard * carries every bout, so we locate the event by id. opts may pass `date` to * pin the scoreboard fetch. Returns null when the id isn't found. */ async function getFightCard(eventId, opts = {}) { const id = String(eventId || ''); if (!id) return null; const cGet = opts.cacheGet || cacheGet; const cSet = opts.cacheSet || cacheSet; try { const sep = ESPN_MMA_SCOREBOARD.includes('?') ? '&' : '?'; const url = opts.date ? `${ESPN_MMA_SCOREBOARD}${sep}dates=${String(opts.date).replace(/-/g, '')}` : ESPN_MMA_SCOREBOARD; const key = `combat:card:${id}`; if (!opts.fetchImpl) { try { const cached = await cGet(key); if (cached && cached.id) return { ...cached, source: 'cache' }; } catch { /* miss */ } } const raw = await doFetch(url, opts.fetchImpl); // Don't date-filter here — we're locating a specific event by id. const normalized = normalizeScoreboard(raw, {}); const card = normalized.events.find((e) => e.id === id) || null; if (card) { try { await cSet(key, card, CARDS_TTL); } catch { /* ignore */ } } return card; } catch (err) { console.warn('[combatAdapter] getFightCard failed:', err && err.message); return null; } } /** Best (highest) American-odds price wins for a given side. Absent → null. */ function bestPrice(current, price) { const p = numOrNull(price); if (p == null) return current; if (current == null) return p; return p > current ? p : current; // best payout for the bettor } /** * normalizeCombatOdds(eventsWithOdds) — PURE parse of the odds-api MMA event * odds array into per-bout moneyline + round total. VYNDR never generates * these — they are REAL book numbers. Only allow-listed US books count. * Returns a map keyed by matchup ("fighterA|fighterB", lowercased) so the * combat surface can join odds to the ESPN bout. Absent market → absent side. */ function normalizeCombatOdds(eventsWithOdds) { const out = {}; const list = Array.isArray(eventsWithOdds) ? eventsWithOdds : []; for (const ev of list) { if (!ev || typeof ev !== 'object') continue; const home = ev.home_team || null; const away = ev.away_team || null; if (!home && !away) continue; const ml = { home: null, away: null }; const roundTotal = { line: null, over: null, under: null }; const books = Array.isArray(ev.bookmakers) ? ev.bookmakers : []; for (const bk of books) { if (!bk || !ALLOWED_BOOKS.has(bk.key)) continue; const markets = Array.isArray(bk.markets) ? bk.markets : []; for (const mk of markets) { const outcomes = Array.isArray(mk && mk.outcomes) ? mk.outcomes : []; if (mk.key === 'h2h') { for (const o of outcomes) { if (o.name === home) ml.home = bestPrice(ml.home, o.price); else if (o.name === away) ml.away = bestPrice(ml.away, o.price); } } else if (mk.key === 'totals') { for (const o of outcomes) { const point = numOrNull(o.point); if (point == null) continue; if (roundTotal.line == null) roundTotal.line = point; // Only pair the primary posted line (first seen). if (point !== roundTotal.line) continue; if (o.name === 'Over') roundTotal.over = bestPrice(roundTotal.over, o.price); else if (o.name === 'Under') roundTotal.under = bestPrice(roundTotal.under, o.price); } } } } const key = `${String(home || '').toLowerCase()}|${String(away || '').toLowerCase()}`; out[key] = { eventId: ev.id != null ? String(ev.id) : null, home, away, commence_time: ev.commence_time || null, moneyline: ml, roundTotal: (roundTotal.over != null || roundTotal.under != null) ? roundTotal : null, }; } return out; } const ODDS_API_MMA_ODDS = 'https://api.the-odds-api.com/v4/sports/mma_mixed_martial_arts/odds'; const ODDS_TTL = 30 * 60; // 30 min — combat ML/round totals move slowly; conserve odds-api credits /** * getCombatOdds(opts) — BEST-EFFORT, CACHED combat moneyline + round totals. * Cache-aside on `combat:odds`; on a cold cache it hits the odds-api MMA BULK * odds endpoint ONCE (h2h + totals in a single request) rather than per-event, * to conserve the paid odds-api quota. No key / any error → {} (odds absent; * cards still render, the ML cell shows an honest "—"). Never throws. * opts: { fetchImpl, cacheGet, cacheSet, apiKey }. */ async function getCombatOdds(opts = {}) { const cGet = opts.cacheGet || cacheGet; const cSet = opts.cacheSet || cacheSet; const key = 'combat:odds'; try { const cached = await cGet(key); if (cached && typeof cached === 'object' && !opts.fetchImpl) return cached; } catch { /* miss → live */ } const apiKey = opts.apiKey || process.env.ODDS_API_KEY; if (!apiKey && !opts.fetchImpl) return {}; try { const url = `${ODDS_API_MMA_ODDS}?apiKey=${encodeURIComponent(apiKey || '')}®ions=us&markets=h2h,totals&oddsFormat=american`; const raw = await doFetch(url, opts.fetchImpl); const map = normalizeCombatOdds(Array.isArray(raw) ? raw : []); try { await cSet(key, map, ODDS_TTL); } catch { /* best-effort */ } return map; } catch (err) { console.warn('[combatAdapter] getCombatOdds failed:', err && err.message); return {}; } } /** * matchBoutOdds(bout, oddsMap) — join the odds map onto an ESPN bout by fighter * names (either orientation). Returns the odds record or null. Never throws. */ function matchBoutOdds(bout, oddsMap) { if (!bout || !oddsMap || typeof oddsMap !== 'object') return null; const fs = Array.isArray(bout.fighters) ? bout.fighters : []; if (fs.length < 2) return null; const a = String(fs[0].name || '').toLowerCase(); const b = String(fs[1].name || '').toLowerCase(); return oddsMap[`${a}|${b}`] || oddsMap[`${b}|${a}`] || null; } module.exports = { // read paths getFightCards, getFightCard, getCombatOdds, matchBoutOdds, // pure, tested transforms normalizeScoreboard, normalizeBout, normalizeFighter, normalizeCombatOdds, parseAthleteId, parseRecord, // helpers todayET, dateET, numOrNull, ESPN_MMA_SCOREBOARD, };