Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user