Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)

This commit is contained in:
Kev
2026-06-10 14:50:13 -04:00
parent b9084408bf
commit ad5ea8d5a8
28 changed files with 3175 additions and 49 deletions
+216
View File
@@ -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,
},
};