236 lines
8.6 KiB
JavaScript
236 lines
8.6 KiB
JavaScript
/**
|
|
* 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,
|
|
},
|
|
};
|