/** * Soccer fixture poller — one process under PM2. * * Polls the configured leagues (SOCCER_LEAGUES env, default 'WC') and * writes per-team `soccer:nextmatch:{team}` and `soccer:lastfixture:{team}` * keys to Redis. The feature extractor reads those keys on the user * request path; this poller is the ONLY thing that hits external APIs * during normal operation (the daily prefetch is the other; it owns * player/squad/scorer data). * * Sources per league: * WC → worldcup2026 OSS API (no key, no rate limit) — `WORLDCUP_API_URL` * anything else → football-data.org via the in-tree adapter * * Poll frequency: * no live matches: 30 min (POLL_INTERVAL_OFF_MS) * live matches: 5 min (POLL_INTERVAL_LIVE_MS) * * On missing API key or upstream failure: log + continue. The next tick * picks up where this one left off. We do not throw out of tick(). */ const axios = require('axios'); const { cacheSet } = require('../src/utils/redis'); const fbd = require('../src/services/adapters/footballDataAdapter'); const HTTP_TIMEOUT_MS = 10_000; const POLL_INTERVAL_OFF_MS = 30 * 60_000; const POLL_INTERVAL_LIVE_MS = 5 * 60_000; // 24h TTL on fixture pointers so a stalled poller doesn't poison reads // with old data forever. The poller refreshes on every tick. const NEXT_MATCH_TTL_SEC = 24 * 3600; const LAST_FIXTURE_TTL_SEC = 7 * 24 * 3600; const WORLDCUP_API_URL = process.env.WORLDCUP_API_URL || 'https://worldcup2026-api.up.railway.app/api/matches'; function parseLeagues() { const raw = process.env.SOCCER_LEAGUES || 'WC'; return raw.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean); } // Status normalization across upstream variants. function classifyStatus(status) { const s = String(status || '').toUpperCase(); if (s.includes('IN_PLAY') || s.includes('LIVE') || s.includes('PAUSED')) return 'live'; if (s.includes('FINISHED') || s.includes('FINAL') || s.includes('COMPLETED')) return 'finished'; return 'scheduled'; } // Fetch WC fixtures from the OSS API. Returns the same projected shape // as football-data adapter: { id, homeTeam, awayTeam, utcDate, status, // score, matchday, venue, competition }. async function fetchWorldCupFixtures() { try { const res = await axios.get(WORLDCUP_API_URL, { timeout: HTTP_TIMEOUT_MS }); const matches = Array.isArray(res.data) ? res.data : Array.isArray(res.data?.matches) ? res.data.matches : []; return matches.map((m) => ({ id: m.id ?? m.match_id ?? null, homeTeam: m.home_team || m.homeTeam || m.home?.name || null, awayTeam: m.away_team || m.awayTeam || m.away?.name || null, utcDate: m.utc_date || m.utcDate || m.date || null, status: m.status || m.match_status || 'SCHEDULED', score: m.score || null, matchday: m.matchday ?? m.round ?? null, venue: m.venue || m.stadium || null, competition: 'WC', })); } catch (err) { console.warn('[poller-soccer] worldcup API fetch failed:', err.message); return null; } } // Fetch via league code through the football-data adapter (NULL when // no key configured — the adapter handles that). For WC we prefer the // OSS API to save football-data quota. async function fetchLeagueFixtures(league) { if (league === 'WC') { const wc = await fetchWorldCupFixtures(); if (wc !== null) return wc; // OSS down → fall back to football-data if a key is configured. return fbd.getWorldCupFixtures(); } return fbd.getLeagueFixtures(league); } // Index fixtures into per-team `nextmatch` / `lastfixture` keys. Returns // { scheduled, live, finished } counts for the tick summary. async function indexFixturesForLeague(league, fixtures) { const counts = { scheduled: 0, live: 0, finished: 0 }; if (!Array.isArray(fixtures)) return counts; // Sort by date so the FIRST scheduled fixture per team is "next", // and the LATEST finished one is "last". const sorted = fixtures.slice().sort((a, b) => { const da = Date.parse(a.utcDate || '') || 0; const db = Date.parse(b.utcDate || '') || 0; return da - db; }); const now = Date.now(); const nextByTeam = new Map(); const lastByTeam = new Map(); for (const f of sorted) { if (!f.homeTeam || !f.awayTeam) continue; const cls = classifyStatus(f.status); counts[cls] = (counts[cls] || 0) + 1; const ts = Date.parse(f.utcDate || '') || 0; if (cls === 'scheduled' && ts >= now) { // First-seen wins (sorted ascending → earliest). if (!nextByTeam.has(f.homeTeam)) { nextByTeam.set(f.homeTeam, { opponent: f.awayTeam, venue: f.venue, isHome: true, utcDate: f.utcDate, status: f.status, league, daysUntil: Math.max(0, Math.round((ts - now) / 86_400_000)), referee: f.referee || null, }); } if (!nextByTeam.has(f.awayTeam)) { nextByTeam.set(f.awayTeam, { opponent: f.homeTeam, venue: f.venue, isHome: false, utcDate: f.utcDate, status: f.status, league, daysUntil: Math.max(0, Math.round((ts - now) / 86_400_000)), referee: f.referee || null, }); } } else if (cls === 'finished') { // Latest-seen wins → overwrite on each iteration since sorted asc. lastByTeam.set(f.homeTeam, { utcDate: f.utcDate, opponent: f.awayTeam, isHome: true, score: f.score, league }); lastByTeam.set(f.awayTeam, { utcDate: f.utcDate, opponent: f.homeTeam, isHome: false, score: f.score, league }); } } // Persist. Don't block on individual failures — Redis errors fail // gracefully inside cacheSet. const writes = []; for (const [team, payload] of nextByTeam) { writes.push(cacheSet(`soccer:nextmatch:${team}`, payload, NEXT_MATCH_TTL_SEC)); } for (const [team, payload] of lastByTeam) { writes.push(cacheSet(`soccer:lastfixture:${team}`, payload, LAST_FIXTURE_TTL_SEC)); } await Promise.all(writes); return counts; } async function tick() { const leagues = parseLeagues(); const summary = []; let liveSeen = false; // Session 20 — skip ticks entirely when football-data quota is // exhausted. The poller's fixture fetcher hits the football-data // adapter for any non-WC league; firing every minute against a // 10-req/min limit blows the budget. The tracker's per-minute // window auto-resets so the next minute's tick will fire. try { const quotaTracker = require('../src/services/quotaTracker'); const { allowed, interval } = await quotaTracker.shouldThrottle('football-data'); if (!allowed || interval === null) { console.log('[poller-soccer] tick skipped — football-data quota exhausted'); return { liveSeen: false, summary: ['quota_exhausted'] }; } } catch (e) { // Tracker is best-effort. If it crashes (Redis hiccup) we // proceed — the underlying adapters will surface their own // 429s and the gateway's degraded-mode fail-open kicks in. console.warn('[poller-soccer] quotaTracker check failed:', e.message); } for (const league of leagues) { const fixtures = await fetchLeagueFixtures(league); if (fixtures === null) { summary.push(`${league}: no_data`); continue; } const counts = await indexFixturesForLeague(league, fixtures); summary.push(`${league}: ${fixtures.length} matches (scheduled=${counts.scheduled} live=${counts.live} finished=${counts.finished})`); if (counts.live > 0) liveSeen = true; } console.log(`[poller-soccer] tick — ${summary.join(', ') || 'no leagues configured'}`); return { liveSeen, summary }; } // Production run loop. Self-rescheduling — interval depends on whether // any league has a live match. async function run() { let stopped = false; process.on('SIGTERM', () => { stopped = true; }); process.on('SIGINT', () => { stopped = true; }); while (!stopped) { let liveSeen = false; try { const result = await tick(); liveSeen = !!result?.liveSeen; } catch (err) { console.warn('[poller-soccer] tick error (continuing):', err.message); } const interval = liveSeen ? POLL_INTERVAL_LIVE_MS : POLL_INTERVAL_OFF_MS; await new Promise((resolve) => setTimeout(resolve, interval)); } console.log('[poller-soccer] shutting down'); } if (require.main === module) { // Only run the loop when invoked directly (PM2). Importing the module // from tests must NOT start the loop. run().catch((err) => { console.error('[poller-soccer] fatal:', err); process.exit(1); }); } module.exports = { tick, __internals: { parseLeagues, classifyStatus, fetchWorldCupFixtures, fetchLeagueFixtures, indexFixturesForLeague, POLL_INTERVAL_OFF_MS, POLL_INTERVAL_LIVE_MS, NEXT_MATCH_TTL_SEC, LAST_FIXTURE_TTL_SEC, }, };