Session 9: api-football + FootApi + Tank01 adapters, grace period middleware, cookie consent, /pricing page, OOM fix documented (1240 tests)

This commit is contained in:
Kev
2026-06-10 19:41:37 -04:00
parent 4db1c1c539
commit b55dcbd614
25 changed files with 2463 additions and 22 deletions
+6 -2
View File
@@ -14,10 +14,14 @@ async function requireAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
// Fetch user profile from our users table
// Fetch user profile from our users table. Session 9 added
// `grace_period_until` + `stripe_customer_id` to the select so the
// grace-period middleware can read them off `req.user` without a
// second round-trip. Both fields default to null when absent so
// pre-Stripe users behave identically to before.
const { data: profile, error: profileError } = await supabase
.from('users')
.select('id, email, tier, scan_count, scan_reset_date, founder_status')
.select('id, email, tier, scan_count, scan_reset_date, founder_status, grace_period_until, stripe_customer_id')
.eq('id', user.id)
.single();
+77
View File
@@ -0,0 +1,77 @@
/**
* Grace-period downgrade middleware (Session 9).
*
* Fires at request time on tier-gated routes. The Stripe webhook
* (`customer.subscription.deleted` and `invoice.payment_failed`) sets
* `users.grace_period_until` to now + 48h on cancellation / payment
* failure. Until Session 9, nothing actively checked whether the
* grace had expired — so cancelled users could keep paid access
* indefinitely. This middleware closes that gap.
*
* Behavior:
* - No `grace_period_until` on req.user → pass through
* - Grace still in the future → pass through
* - Grace expired → atomically
* downgrade `users.tier` AND `user_profiles.tier` to 'free',
* clear the grace timestamp, set subscription_status='expired'
* on the profile mirror, and rewrite req.user so the downstream
* route immediately sees the downgraded tier.
*
* Mount AFTER `requireAuth` on tier-gated routes. Routes that don't
* gate by tier (e.g. /api/bets read-only views) don't need it.
*
* Failure semantics: if either DB write fails, we still call next()
* — the user might briefly retain paid access until the next request,
* but at least the route keeps serving. The webhook's grace pointer
* stays set, so we'll try again on the next request.
*/
const { getSupabaseServiceClient } = require('../utils/supabase');
async function checkGracePeriod(req, res, next) {
const user = req.user;
// No user (unauth route slipping through?) — bail to next.
if (!user || !user.grace_period_until) return next();
const grace = new Date(user.grace_period_until);
// Invalid date → treat as no grace.
if (!Number.isFinite(grace.getTime())) return next();
// Still in grace window — let them keep paid access.
if (grace.getTime() > Date.now()) return next();
// Expired. Downgrade in both tables. We log on failure but DO NOT
// throw — the route still serves; we re-try on the next request.
try {
const supabase = getSupabaseServiceClient();
const { error: usersErr } = await supabase
.from('users')
.update({ tier: 'free', grace_period_until: null })
.eq('id', user.id);
if (usersErr) {
console.warn('[gracePeriod] users update failed:', usersErr.message);
}
const { error: profileErr } = await supabase
.from('user_profiles')
.update({
tier: 'free',
subscription_status: 'expired',
grace_period_until: null,
})
.eq('id', user.id);
if (profileErr) {
console.warn('[gracePeriod] user_profiles update failed:', profileErr.message);
}
// Reflect on req.user so the downstream route sees the downgrade
// immediately (no race against a stale closure).
req.user.tier = 'free';
req.user.grace_period_until = null;
} catch (err) {
console.warn('[gracePeriod] downgrade error (continuing):', err.message);
}
return next();
}
module.exports = { checkGracePeriod };
+3 -2
View File
@@ -1,12 +1,13 @@
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { checkGracePeriod } = require('../middleware/gracePeriod');
const { getAlertsForUser, markAlertRead } = require('../services/alertService');
const router = express.Router();
const PAID_TIERS = new Set(['analyst', 'desk']);
router.get('/', requireAuth, async (req, res) => {
router.get('/', requireAuth, checkGracePeriod, async (req, res) => {
if (!PAID_TIERS.has(req.user.tier)) {
return res.status(403).json({ error: 'Alerts are available on Analyst and Desk tiers' });
}
@@ -20,7 +21,7 @@ router.get('/', requireAuth, async (req, res) => {
}
});
router.patch('/:id/read', requireAuth, async (req, res) => {
router.patch('/:id/read', requireAuth, checkGracePeriod, async (req, res) => {
if (!PAID_TIERS.has(req.user.tier)) {
return res.status(403).json({ error: 'Alerts are available on Analyst and Desk tiers' });
}
+2 -1
View File
@@ -1,11 +1,12 @@
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { checkGracePeriod } = require('../middleware/gracePeriod');
const { getSupabaseServiceClient } = require('../utils/supabase');
const router = express.Router();
// GET /joint-history — joint outcome history and phi coefficient
router.get('/joint-history', requireAuth, async (req, res) => {
router.get('/joint-history', requireAuth, checkGracePeriod, async (req, res) => {
const { player_a, stat_a, player_b, stat_b } = req.query;
// Block free tier
+7 -1
View File
@@ -1,5 +1,6 @@
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { checkGracePeriod } = require('../middleware/gracePeriod');
const { scanLimit } = require('../middleware/scanLimit');
const { scanParlay } = require('../services/parlayScanService');
@@ -38,7 +39,12 @@ function validateLegs(legs) {
return null;
}
router.post('/parlay', requireAuth, scanLimit(), async (req, res) => {
// requireAuth → checkGracePeriod → scanLimit ordering matters:
// requireAuth populates req.user; checkGracePeriod may downgrade
// req.user.tier; scanLimit reads tier off req.user to pick the
// per-tier quota. If grace check ran AFTER scanLimit, a just-
// expired Desk user would get unlimited quota for one final scan.
router.post('/parlay', requireAuth, checkGracePeriod, scanLimit(), async (req, res) => {
const { legs } = req.body;
const validationError = validateLegs(legs);
+363
View File
@@ -0,0 +1,363 @@
/**
* api-football.com adapter — PRIMARY soccer data source (Session 9).
*
* The original API-Football by api-sports.io, now self-hosted (it
* left RapidAPI). Free tier: 100 requests/day. Richest soccer data
* we have access to: per-fixture player stats, lineups with grids,
* referee assignments, full event timeline.
*
* Auth: `x-apisports-key` header. This is NOT RapidAPI — DO NOT add
* x-rapidapi-host / x-rapidapi-key here (that's the FootApi backup
* adapter, separate file).
*
* Env:
* API_FOOTBALL_KEY — api-sports.io API key
*
* Rate limit:
* Hard 100 req/day on the upstream side. We track usage in Redis
* via the `apifootball:daily_count` key (24h TTL) and stop at 90 to
* leave a 10-req safety margin for the daily prefetch and any
* ad-hoc operator pulls.
*
* Graceful degradation:
* - Missing key → all functions return null
* - Daily counter exhausted → stale-while-revalidate from Redis
* if available, else null
* - Upstream 4xx/5xx → log + stale-while-revalidate
* fallback, else null
* Never throws.
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const BASE_URL = 'https://v3.football.api-sports.io';
const HTTP_TIMEOUT_MS = 8_000;
// Per-endpoint cache TTLs (seconds). Match the upstream data
// volatility: fixtures drift fast, lineups freeze post-kickoff,
// season stats only matter day-over-day.
const TTL = Object.freeze({
fixtures: 6 * 3600, // 6h
lineups: 24 * 3600, // 24h — locked once posted
playerStats: 24 * 3600, // 24h — locked once final
events: 12 * 3600, // 12h
player: 24 * 3600, // 24h
standings: 12 * 3600, // 12h
});
const DAILY_LIMIT = 100;
const SAFETY_MARGIN = 10;
const SOFT_LIMIT = DAILY_LIMIT - SAFETY_MARGIN; // 90
const DAILY_COUNTER_KEY = 'apifootball:daily_count';
const DAILY_TTL_SEC = 24 * 3600;
function hasApiKey() {
return !!process.env.API_FOOTBALL_KEY;
}
async function readDailyCount() {
const v = await cacheGet(DAILY_COUNTER_KEY);
if (v == null) return 0;
if (typeof v === 'number') return v;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
async function bumpDailyCount() {
const next = (await readDailyCount()) + 1;
// 24h sliding window — every bump refreshes the TTL so the counter
// doesn't expire mid-day after the first call of the morning.
await cacheSet(DAILY_COUNTER_KEY, next, DAILY_TTL_SEC);
return next;
}
// One central HTTP path — applies the api-sports auth, rate-limit
// check, caching, and stale-while-revalidate fallback. Returns
// parsed JSON or null. Never throws.
async function fetchWithCache(path, cacheKey, ttl) {
// 1. Fresh cache hit.
const fresh = await cacheGet(cacheKey);
if (fresh !== null) return fresh;
// 2. No API key → can't fetch. Callers degrade to null.
if (!hasApiKey()) return null;
// 3. Daily counter check — fall through to stale-while-revalidate.
const used = await readDailyCount();
if (used >= SOFT_LIMIT) {
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
// 4. Network.
try {
const res = await axios.get(`${BASE_URL}${path}`, {
headers: { 'x-apisports-key': process.env.API_FOOTBALL_KEY },
timeout: HTTP_TIMEOUT_MS,
});
await bumpDailyCount();
const body = res.data;
if (body && typeof body === 'object') {
await cacheSet(cacheKey, body, ttl);
await cacheSet(`${cacheKey}:stale`, body, ttl * 4);
}
return body;
} catch (err) {
console.warn('[apiFootball] fetch failed:', path, err.message);
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
}
// ---- Public surface ----
/**
* getFixtures — fixtures by league/season/date. World Cup is
* league=1, season=2026.
*
* @param {Object} params { league, season, date? }
* @returns {Promise<Array|null>}
*/
async function getFixtures({ league, season, date } = {}) {
if (!league || !season) return null;
const dateSegment = date ? `:${date}` : '';
const query = date
? `?league=${league}&season=${season}&date=${date}`
: `?league=${league}&season=${season}`;
const data = await fetchWithCache(
`/fixtures${query}`,
`apifootball:fixtures:${league}:${season}${dateSegment}`,
TTL.fixtures,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list)) return [];
return list.map((f) => ({
id: f.fixture?.id ?? null,
utcDate: f.fixture?.date ?? null,
status: f.fixture?.status?.short ?? null,
venue: f.fixture?.venue?.name ?? null,
referee: f.fixture?.referee ?? null,
league: f.league?.name ?? null,
season: f.league?.season ?? null,
round: f.league?.round ?? null,
homeTeam: f.teams?.home?.name ?? null,
awayTeam: f.teams?.away?.name ?? null,
homeTeamId: f.teams?.home?.id ?? null,
awayTeamId: f.teams?.away?.id ?? null,
score: f.score ?? null,
}));
}
/**
* getFixtureLineups — starting XI + bench, with grid positions.
*/
async function getFixtureLineups(fixtureId) {
if (!fixtureId) return null;
const data = await fetchWithCache(
`/fixtures/lineups?fixture=${fixtureId}`,
`apifootball:lineups:${fixtureId}`,
TTL.lineups,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list)) return [];
return list.map((side) => ({
team: side.team?.name ?? null,
teamId: side.team?.id ?? null,
coach: side.coach?.name ?? null,
formation: side.formation ?? null,
startXI: (side.startXI || []).map((p) => ({
id: p.player?.id ?? null,
name: p.player?.name ?? null,
number: p.player?.number ?? null,
pos: p.player?.pos ?? null,
grid: p.player?.grid ?? null,
})),
substitutes: (side.substitutes || []).map((p) => ({
id: p.player?.id ?? null,
name: p.player?.name ?? null,
number: p.player?.number ?? null,
pos: p.player?.pos ?? null,
})),
}));
}
/**
* getFixturePlayerStats — per-player stats for a finished match.
* THE money endpoint for grade calibration: shots, goals, passes,
* tackles, cards, minutes, official rating.
*/
async function getFixturePlayerStats(fixtureId) {
if (!fixtureId) return null;
const data = await fetchWithCache(
`/fixtures/players?fixture=${fixtureId}`,
`apifootball:playerstats:${fixtureId}`,
TTL.playerStats,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list)) return [];
const out = [];
for (const side of list) {
const team = side.team?.name ?? null;
for (const player of side.players || []) {
const stats = player.statistics?.[0] || {};
out.push({
team,
playerId: player.player?.id ?? null,
name: player.player?.name ?? null,
minutes: stats.games?.minutes ?? null,
position: stats.games?.position ?? null,
rating: stats.games?.rating ?? null,
substitute: !!stats.games?.substitute,
goals: stats.goals?.total ?? 0,
assists: stats.goals?.assists ?? 0,
shots_total: stats.shots?.total ?? 0,
shots_on: stats.shots?.on ?? 0,
passes_total: stats.passes?.total ?? 0,
passes_accuracy: stats.passes?.accuracy ?? null,
tackles_total: stats.tackles?.total ?? 0,
tackles_blocks: stats.tackles?.blocks ?? 0,
tackles_interceptions: stats.tackles?.interceptions ?? 0,
yellow: stats.cards?.yellow ?? 0,
red: stats.cards?.red ?? 0,
saves: stats.goals?.saves ?? null,
});
}
}
return out;
}
/**
* getFixtureEvents — minute-by-minute goals, cards, subs.
*/
async function getFixtureEvents(fixtureId) {
if (!fixtureId) return null;
const data = await fetchWithCache(
`/fixtures/events?fixture=${fixtureId}`,
`apifootball:events:${fixtureId}`,
TTL.events,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list)) return [];
return list.map((e) => ({
minute: e.time?.elapsed ?? null,
extra: e.time?.extra ?? null,
team: e.team?.name ?? null,
player: e.player?.name ?? null,
assist: e.assist?.name ?? null,
type: e.type ?? null,
detail: e.detail ?? null,
comments: e.comments ?? null,
}));
}
/**
* getPlayerSeasonStats — season aggregate for a single player.
* The feature extractor reads this for goals_per_90, xG (if exposed
* by the league response), minutes_per_game, start_rate.
*/
async function getPlayerSeasonStats(playerId, season) {
if (!playerId || !season) return null;
const data = await fetchWithCache(
`/players?id=${playerId}&season=${season}`,
`apifootball:player:${playerId}:season:${season}`,
TTL.player,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list) || list.length === 0) return [];
// The response is an array of { player, statistics: [perCompetition] }.
// We collapse into a flat per-competition list — the caller picks
// the league they care about (e.g. WC = league.id 1).
const player = list[0].player || {};
const stats = list[0].statistics || [];
return stats.map((s) => ({
playerId: player.id ?? null,
name: player.name ?? null,
nationality: player.nationality ?? null,
team: s.team?.name ?? null,
teamId: s.team?.id ?? null,
leagueId: s.league?.id ?? null,
leagueName: s.league?.name ?? null,
appearances: s.games?.appearences ?? 0, // sic — api-football typo
lineups: s.games?.lineups ?? 0,
minutes: s.games?.minutes ?? 0,
position: s.games?.position ?? null,
rating: s.games?.rating ?? null,
goals: s.goals?.total ?? 0,
assists: s.goals?.assists ?? 0,
shots_total: s.shots?.total ?? 0,
shots_on: s.shots?.on ?? 0,
passes_total: s.passes?.total ?? 0,
tackles_total: s.tackles?.total ?? 0,
yellow: s.cards?.yellow ?? 0,
red: s.cards?.red ?? 0,
penalty_scored: s.penalty?.scored ?? 0,
penalty_missed: s.penalty?.missed ?? 0,
}));
}
/**
* getStandings — league table with goals for/against per team.
*/
async function getStandings(league, season) {
if (!league || !season) return null;
const data = await fetchWithCache(
`/standings?league=${league}&season=${season}`,
`apifootball:standings:${league}:${season}`,
TTL.standings,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list) || list.length === 0) return [];
// World Cup standings are grouped (by group A, B, C…). Flatten so
// the prefetch can compute defensive rank across the whole field.
const out = [];
for (const entry of list) {
const groups = entry.league?.standings || [];
for (const group of groups) {
for (const row of group) {
out.push({
rank: row.rank ?? null,
team: row.team?.name ?? null,
teamId: row.team?.id ?? null,
played: row.all?.played ?? 0,
win: row.all?.win ?? 0,
draw: row.all?.draw ?? 0,
lose: row.all?.lose ?? 0,
goalsFor: row.all?.goals?.for ?? 0,
goalsAgainst: row.all?.goals?.against ?? 0,
points: row.points ?? 0,
group: row.group ?? null,
});
}
}
}
return out;
}
module.exports = {
getFixtures,
getFixtureLineups,
getFixturePlayerStats,
getFixtureEvents,
getPlayerSeasonStats,
getStandings,
hasApiKey,
__internals: {
BASE_URL,
TTL,
DAILY_LIMIT,
SOFT_LIMIT,
DAILY_COUNTER_KEY,
readDailyCount,
bumpDailyCount,
resetCounterForTests: async () => cacheSet(DAILY_COUNTER_KEY, 0, DAILY_TTL_SEC),
},
};
+256
View File
@@ -0,0 +1,256 @@
/**
* FootApi adapter — BACKUP soccer data source (Session 9).
*
* Wraps the RapidAPI-hosted FootApi service (Sofascore mirror). Used
* as the fallback when api-football.com is rate-limited or returns
* thin data. Free tier: 50 requests/day.
*
* Auth: RapidAPI headers (x-rapidapi-key + x-rapidapi-host). DO NOT
* use the x-apisports-key header here — that's the primary adapter.
*
* Env:
* RAPID_API_KEY — RapidAPI marketplace key (shared across Tank01 + FootApi)
* FOOTAPI_HOST — host header (defaults to footapi7.p.rapidapi.com)
*
* Rate limit:
* Hard 50 req/day. We track in `footapi:daily_count` (24h TTL) and
* stop at 45 — same 5-req safety margin as the primary adapter
* (smaller absolute margin because the daily budget is smaller).
*
* Tournament IDs used in the URL paths:
* 16 — FIFA World Cup
* (others discovered via the schedule endpoint as needed)
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const HTTP_TIMEOUT_MS = 8_000;
const TTL = Object.freeze({
lineups: 24 * 3600,
incidents: 12 * 3600,
referee: 7 * 24 * 3600, // 7d — referee stats move slowly
schedule: 6 * 3600,
});
const DAILY_LIMIT = 50;
const SAFETY_MARGIN = 5;
const SOFT_LIMIT = DAILY_LIMIT - SAFETY_MARGIN; // 45
const DAILY_COUNTER_KEY = 'footapi:daily_count';
const DAILY_TTL_SEC = 24 * 3600;
const WC_TOURNAMENT_ID = 16;
function getHost() {
return process.env.FOOTAPI_HOST || 'footapi7.p.rapidapi.com';
}
function hasApiKey() {
return !!process.env.RAPID_API_KEY;
}
async function readDailyCount() {
const v = await cacheGet(DAILY_COUNTER_KEY);
if (v == null) return 0;
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
async function bumpDailyCount() {
const next = (await readDailyCount()) + 1;
await cacheSet(DAILY_COUNTER_KEY, next, DAILY_TTL_SEC);
return next;
}
async function fetchWithCache(path, cacheKey, ttl) {
const fresh = await cacheGet(cacheKey);
if (fresh !== null) return fresh;
if (!hasApiKey()) return null;
const used = await readDailyCount();
if (used >= SOFT_LIMIT) {
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
try {
const host = getHost();
const res = await axios.get(`https://${host}${path}`, {
headers: {
'x-rapidapi-key': process.env.RAPID_API_KEY,
'x-rapidapi-host': host,
},
timeout: HTTP_TIMEOUT_MS,
});
await bumpDailyCount();
const body = res.data;
if (body && typeof body === 'object') {
await cacheSet(cacheKey, body, ttl);
await cacheSet(`${cacheKey}:stale`, body, ttl * 4);
}
return body;
} catch (err) {
console.warn('[footApi] fetch failed:', path, err.message);
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
}
// ---- Public surface ----
/**
* getMatchLineups — players with minutesPlayed and the 28-key stats
* block FootApi exposes per player (rating, shots, passes, tackles,
* goals, assists, cards, etc.).
*/
async function getMatchLineups(matchId) {
if (!matchId) return null;
const data = await fetchWithCache(
`/api/match/${matchId}/lineups`,
`footapi:match:${matchId}:lineups`,
TTL.lineups,
);
if (data === null) return null;
// The FootApi response carries `home` and `away` sides, each with
// `players` arrays. Flatten so callers don't need to reach into
// upstream-specific structure.
const out = [];
for (const side of ['home', 'away']) {
const team = data?.[side];
if (!team || !Array.isArray(team.players)) continue;
for (const entry of team.players) {
const p = entry?.player || {};
const stats = entry?.statistics || {};
out.push({
team: team.formation ? `${side}(${team.formation})` : side,
side,
playerId: p.id ?? null,
name: p.name ?? null,
position: entry.position ?? null,
shirtNumber: entry.shirtNumber ?? null,
substitute: !!entry.substitute,
captain: !!entry.captain,
minutesPlayed: stats.minutesPlayed ?? null,
rating: stats.rating ?? null,
goals: stats.goals ?? 0,
assists: stats.goalAssist ?? 0,
shots: stats.totalShots ?? 0,
shotsOnTarget: stats.shotOnTarget ?? 0,
passes: stats.totalPass ?? 0,
accuratePasses: stats.accuratePass ?? 0,
tackles: stats.totalTackle ?? 0,
yellow: stats.yellowCards ?? 0,
red: stats.redCards ?? 0,
saves: stats.saves ?? null,
keyPasses: stats.keyPass ?? 0,
});
}
}
return out;
}
/**
* getMatchIncidents — minute-by-minute goals, cards, subs. The
* minute + addedTime carry detail the events feed needs for trap
* detection (e.g. late-game cards inflate referee_card_bias signal).
*/
async function getMatchIncidents(matchId) {
if (!matchId) return null;
const data = await fetchWithCache(
`/api/match/${matchId}/incidents`,
`footapi:match:${matchId}:incidents`,
TTL.incidents,
);
if (data === null) return null;
const list = data?.incidents;
if (!Array.isArray(list)) return [];
return list.map((i) => ({
type: i.incidentType ?? null,
classType: i.incidentClass ?? null,
minute: i.time ?? null,
addedTime: i.addedTime ?? null,
isHome: i.isHome ?? null,
player: i.player?.name ?? null,
assist: i.assist1?.name ?? null,
text: i.text ?? null,
}));
}
/**
* getRefereeStatistics — referee card + appearance history per
* tournament. The shape `{ yellowCards, redCards, appearances }`
* feeds the soccer-trap `referee_card_bias` signal.
*/
async function getRefereeStatistics(refereeId) {
if (!refereeId) return null;
const data = await fetchWithCache(
`/api/referee/${refereeId}/statistics`,
`footapi:referee:${refereeId}:stats`,
TTL.referee,
);
if (data === null) return null;
const stats = data?.statistics;
if (!Array.isArray(stats)) return [];
return stats.map((s) => ({
tournamentId: s.tournament?.id ?? null,
tournamentName: s.tournament?.name ?? null,
season: s.season?.year ?? null,
appearances: s.appearances ?? 0,
yellowCards: s.yellowCards ?? 0,
redCards: s.redCards ?? 0,
yellowCardsPerGame: s.appearances > 0 ? Math.round((s.yellowCards / s.appearances) * 100) / 100 : null,
redCardsPerGame: s.appearances > 0 ? Math.round((s.redCards / s.appearances) * 1000) / 1000 : null,
}));
}
/**
* getWorldCupSchedule — fixtures for a date. Tournament ID 16 is
* the FIFA World Cup; the path is `/api/tournament/16/schedules/dd/mm/yyyy`.
*/
async function getWorldCupSchedule(day, month, year) {
if (!day || !month || !year) return null;
const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const data = await fetchWithCache(
`/api/tournament/${WC_TOURNAMENT_ID}/schedules/${day}/${month}/${year}`,
`footapi:wc:schedule:${dateKey}`,
TTL.schedule,
);
if (data === null) return null;
const list = data?.events;
if (!Array.isArray(list)) return [];
return list.map((e) => ({
id: e.id ?? null,
startTimestamp: e.startTimestamp ?? null,
status: e.status?.type ?? null,
homeTeam: e.homeTeam?.name ?? null,
awayTeam: e.awayTeam?.name ?? null,
homeTeamId: e.homeTeam?.id ?? null,
awayTeamId: e.awayTeam?.id ?? null,
homeScore: e.homeScore?.current ?? null,
awayScore: e.awayScore?.current ?? null,
venue: e.venue?.name ?? null,
referee: e.referee?.name ?? null,
}));
}
module.exports = {
getMatchLineups,
getMatchIncidents,
getRefereeStatistics,
getWorldCupSchedule,
hasApiKey,
__internals: {
TTL,
DAILY_LIMIT,
SOFT_LIMIT,
DAILY_COUNTER_KEY,
WC_TOURNAMENT_ID,
readDailyCount,
bumpDailyCount,
getHost,
resetCounterForTests: async () => cacheSet(DAILY_COUNTER_KEY, 0, DAILY_TTL_SEC),
},
};
+185
View File
@@ -0,0 +1,185 @@
/**
* Tank01 MLB adapter (Session 9) — live in-game stats + batter-vs-pitcher.
*
* The BvP endpoint is the headline new signal: pre-game, we can show
* a batter's historical line against the starting pitcher (hits, K's,
* total bases). This was a documented Day-1 gap.
*
* Same RAPID_API_KEY as the NBA adapter; different host. Free tier
* is 1,000 req/month — we rely on cache TTLs to bound consumption.
*
* Env:
* RAPID_API_KEY — shared RapidAPI marketplace key
* TANK01_MLB_HOST — host (default `tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com`)
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const HTTP_TIMEOUT_MS = 8_000;
const DEFAULT_HOST = 'tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com';
const TTL = Object.freeze({
boxScoreLive: 5 * 60,
boxScoreFinal: 24 * 3600,
scoreboard: 1 * 3600,
bvp: 24 * 3600, // BvP doesn't change mid-day — 24h cache is fine
});
function getHost() {
return process.env.TANK01_MLB_HOST || DEFAULT_HOST;
}
function hasApiKey() {
return !!process.env.RAPID_API_KEY;
}
async function fetchWithCache(path, cacheKey, ttl) {
const fresh = await cacheGet(cacheKey);
if (fresh !== null) return fresh;
if (!hasApiKey()) return null;
try {
const host = getHost();
const res = await axios.get(`https://${host}${path}`, {
headers: {
'x-rapidapi-key': process.env.RAPID_API_KEY,
'x-rapidapi-host': host,
},
timeout: HTTP_TIMEOUT_MS,
});
const body = res.data;
if (body && typeof body === 'object') {
await cacheSet(cacheKey, body, ttl);
await cacheSet(`${cacheKey}:stale`, body, ttl * 4);
}
return body;
} catch (err) {
console.warn('[tank01MLB] fetch failed:', path, err.message);
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
}
/**
* getMLBBoxScore — per-player batting + pitching lines.
* Status-aware TTL: same pattern as the NBA adapter (5 min live,
* 24 h once Final).
*/
async function getMLBBoxScore(gameId) {
if (!gameId) return null;
const cacheKey = `tank01:mlb:boxscore:${gameId}`;
const data = await fetchWithCache(
`/getMLBBoxScore?gameID=${encodeURIComponent(gameId)}`,
cacheKey,
TTL.boxScoreLive,
);
if (data === null) return null;
const body = data?.body || data;
const isFinal = (() => {
const s = body?.gameStatus || body?.status || '';
return typeof s === 'string' && /final/i.test(s);
})();
if (isFinal) await cacheSet(cacheKey, data, TTL.boxScoreFinal);
// Project batters + pitchers into a flat list. Tank01 splits these
// into `playerStats.{batting,pitching}` — we tag the role so the
// consumer can filter.
const stats = body?.playerStats || {};
const out = [];
for (const [id, entry] of Object.entries(stats.batting || stats.batters || {})) {
out.push({ role: 'batter', playerId: id, name: entry.longName || entry.name || null, team: entry.teamAbv || null, _raw: entry, _final: isFinal });
}
for (const [id, entry] of Object.entries(stats.pitching || stats.pitchers || {})) {
out.push({ role: 'pitcher', playerId: id, name: entry.longName || entry.name || null, team: entry.teamAbv || null, _raw: entry, _final: isFinal });
}
return out;
}
/**
* getMLBBatterVsPitcher — historical BvP matchup. The headline new
* MLB signal: a batter's plate appearances, hits, K's, HRs, total
* bases against a specific pitcher's career. Use ID, not name.
*/
async function getMLBBatterVsPitcher(batterId, pitcherId) {
if (!batterId || !pitcherId) return null;
const data = await fetchWithCache(
`/getMLBBatterVsPitcher?batterID=${encodeURIComponent(batterId)}&pitcherID=${encodeURIComponent(pitcherId)}`,
`tank01:mlb:bvp:${batterId}:${pitcherId}`,
TTL.bvp,
);
if (data === null) return null;
const body = data?.body || data;
// The response shape can be either a single object or a `matchups`
// array depending on schema version — normalize.
if (Array.isArray(body)) {
return body.map(projectBvP);
}
return projectBvP(body);
}
function projectBvP(row) {
if (!row || typeof row !== 'object') return null;
return {
batterId: row.batterID ?? null,
pitcherId: row.pitcherID ?? null,
plateAppearances: num(row.PA ?? row.pa, 0),
atBats: num(row.AB ?? row.ab, 0),
hits: num(row.H ?? row.hits, 0),
doubles: num(row['2B'] ?? row.doubles, 0),
triples: num(row['3B'] ?? row.triples, 0),
homeRuns: num(row.HR ?? row.homeRuns, 0),
rbi: num(row.RBI ?? row.rbi, 0),
walks: num(row.BB ?? row.walks, 0),
strikeouts: num(row.SO ?? row.K ?? row.strikeouts, 0),
avg: row.AVG ?? row.avg ?? null,
ops: row.OPS ?? row.ops ?? null,
};
}
function num(v, fallback = 0) {
if (v == null || v === '') return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
/**
* getMLBDailyScoreboard — schedule + scores for a date.
*/
async function getMLBDailyScoreboard(date) {
if (!date) return null;
const ymd = String(date).replace(/-/g, '');
const data = await fetchWithCache(
`/getMLBScoresOnly?gameDate=${ymd}`,
`tank01:mlb:scoreboard:${ymd}`,
TTL.scoreboard,
);
if (data === null) return null;
const body = data?.body || data;
// Body shape varies: array of games OR map keyed by gameID. Normalize to array.
const entries = Array.isArray(body) ? body : Object.values(body || {});
return entries.map((g) => ({
gameId: g.gameID ?? null,
homeTeam: g.home ?? null,
awayTeam: g.away ?? null,
gameTime: g.gameTime ?? null,
gameStatus: g.gameStatus ?? null,
homeScore: num(g.homePts, null),
awayScore: num(g.awayPts, null),
}));
}
module.exports = {
getMLBBoxScore,
getMLBBatterVsPitcher,
getMLBDailyScoreboard,
hasApiKey,
__internals: {
TTL,
DEFAULT_HOST,
getHost,
projectBvP,
num,
},
};
+188
View File
@@ -0,0 +1,188 @@
/**
* Tank01 NBA adapter (Session 9) — RapidAPI-hosted, live in-game stats.
*
* Sits behind the same RAPID_API_KEY as `footApiAdapter`. Free tier
* is 1,000 req/month — much more generous than FootApi's daily cap,
* so we don't keep a tight in-process counter here; we lean on cache
* TTLs to bound consumption.
*
* Env:
* RAPID_API_KEY — shared RapidAPI marketplace key
* TANK01_NBA_HOST — host (default `tank01-fantasy-stats.p.rapidapi.com`)
*
* TTL policy — box scores are special:
* - Mid-game (status not Final) → 5 min cache so live stats refresh
* - Post-game (status Final) → 24 h cache so we stop pulling
* - Games-for-date → 1 h
* - Betting odds → 15 min
*
* Wired into computeFeatures via Session 9 cascade — Tank01 box-score
* data is preferred when ESPN's scoreboard is sparse (e.g. early in
* the day before tip-off has populated minute-by-minute stats).
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const HTTP_TIMEOUT_MS = 8_000;
const DEFAULT_HOST = 'tank01-fantasy-stats.p.rapidapi.com';
const TTL = Object.freeze({
boxScoreLive: 5 * 60, // 5min while in-game
boxScoreFinal: 24 * 3600, // 24h once final
games: 1 * 3600, // 1h
odds: 15 * 60, // 15min
});
function getHost() {
return process.env.TANK01_NBA_HOST || DEFAULT_HOST;
}
function hasApiKey() {
return !!process.env.RAPID_API_KEY;
}
// Centralized fetch — RapidAPI auth + cache + stale-while-revalidate.
async function fetchWithCache(path, cacheKey, ttl) {
const fresh = await cacheGet(cacheKey);
if (fresh !== null) return fresh;
if (!hasApiKey()) return null;
try {
const host = getHost();
const res = await axios.get(`https://${host}${path}`, {
headers: {
'x-rapidapi-key': process.env.RAPID_API_KEY,
'x-rapidapi-host': host,
},
timeout: HTTP_TIMEOUT_MS,
});
const body = res.data;
if (body && typeof body === 'object') {
await cacheSet(cacheKey, body, ttl);
await cacheSet(`${cacheKey}:stale`, body, ttl * 4);
}
return body;
} catch (err) {
console.warn('[tank01NBA] fetch failed:', path, err.message);
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
}
/**
* getNBABoxScore — live per-player stats for a single game.
*
* Status-aware caching: a mid-game refresh that pulls "in-progress"
* data caches for 5min so the next read isn't stale; a "Final" pull
* caches for 24h because nothing is changing. The status field varies
* by Tank01 schema version — we check `gameStatus` first, fall back
* to looking for "Final" anywhere in a top-level string field.
*/
async function getNBABoxScore(gameId) {
if (!gameId) return null;
// We don't yet know if the game is final, so request with the live
// TTL — if the response reports Final we re-cache under the long TTL.
const cacheKey = `tank01:nba:boxscore:${gameId}`;
const data = await fetchWithCache(
`/getNBABoxScore?gameID=${encodeURIComponent(gameId)}`,
cacheKey,
TTL.boxScoreLive,
);
if (data === null) return null;
const body = data?.body || data;
const isFinal = (() => {
const s = body?.gameStatus || body?.status || '';
return typeof s === 'string' && /final/i.test(s);
})();
// Upgrade the cache TTL on Final.
if (isFinal) {
await cacheSet(cacheKey, data, TTL.boxScoreFinal);
}
// Project: top-level `playerStats` is a map keyed by playerID.
const players = body?.playerStats || body?.playerStatistics || {};
const out = [];
for (const [id, p] of Object.entries(players)) {
out.push({
playerId: id,
name: p.longName || p.shortName || null,
team: p.teamAbv || null,
mins: p.mins ?? null,
pts: numOr(p.pts),
reb: numOr(p.reb),
ast: numOr(p.ast),
stl: numOr(p.stl),
blk: numOr(p.blk),
tov: numOr(p.TOV ?? p.tov),
threes: numOr(p.tptfgm),
fga: numOr(p.fga),
fgm: numOr(p.fgm),
fta: numOr(p.fta),
ftm: numOr(p.ftm),
_final: isFinal,
});
}
return out;
}
function numOr(v, fallback = 0) {
if (v == null) return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
/**
* getNBAGamesForDate — schedule + scoreline. Date format is YYYYMMDD
* per Tank01's schema (NOT ISO).
*/
async function getNBAGamesForDate(date) {
if (!date) return null;
const ymd = String(date).replace(/-/g, '');
const data = await fetchWithCache(
`/getNBAGamesForDate?gameDate=${ymd}`,
`tank01:nba:games:${ymd}`,
TTL.games,
);
if (data === null) return null;
const list = data?.body;
if (!Array.isArray(list)) return [];
return list.map((g) => ({
gameId: g.gameID ?? null,
homeTeam: g.home ?? null,
awayTeam: g.away ?? null,
gameTime: g.gameTime_epoch ?? g.gameTime ?? null,
gameStatus: g.gameStatus ?? null,
homeScore: numOr(g.homePts, null),
awayScore: numOr(g.awayPts, null),
}));
}
/**
* getNBABettingOdds — Tank01's odds feed (book-by-book). Useful as a
* sanity-check signal alongside our odds-api primary.
*/
async function getNBABettingOdds(date) {
if (!date) return null;
const ymd = String(date).replace(/-/g, '');
const data = await fetchWithCache(
`/getNBABettingOdds?gameDate=${ymd}`,
`tank01:nba:odds:${ymd}`,
TTL.odds,
);
if (data === null) return null;
return data?.body || data;
}
module.exports = {
getNBABoxScore,
getNBAGamesForDate,
getNBABettingOdds,
hasApiKey,
__internals: {
TTL,
DEFAULT_HOST,
getHost,
numOr,
},
};
@@ -39,27 +39,66 @@ async function safeCacheGet(key) {
}
}
// Read per-player season aggregate. The prefetch writes a flat shape
// that already collapses played + minutes into per-90 rates so we don't
// recompute on every request.
// Source-priority cascade (Session 9). Each load function checks
// adapter-specific keys in priority order:
// 1. api-football.com (PRIMARY — 100 req/day, richest payload)
// 2. FootApi via RapidAPI (BACKUP — 50 req/day, Sofascore mirror)
// 3. football-data.org (TERTIARY — fixtures/standings only)
//
// The richer adapters write name-keyed aliases (`apifootball:player_by_name:…`,
// `footapi:player_by_name:…`) during the daily prefetch so the request
// path can resolve without an upstream call. When the alias key is
// missing — either because the prefetch hasn't run, the adapter has
// no key configured, or the player isn't covered — we fall through to
// the next source. Final fallback is the legacy `soccer:player:{name}`
// key the football-data prefetch already populates.
//
// Every value is tagged with `_source` so trap detection and reasoning
// can attribute the data origin (and so we can spot when a source is
// silently failing in production logs).
async function loadFromCascade(keys) {
for (const { key, source } of keys) {
const v = await safeCacheGet(key);
if (v && typeof v === 'object') {
return { ...v, _source: v._source || source };
}
}
return null;
}
async function loadPlayerProfile(playerName) {
if (!playerName) return null;
return safeCacheGet(`soccer:player:${normalizeName(playerName)}`);
const n = normalizeName(playerName);
return loadFromCascade([
{ key: `apifootball:player_by_name:${n}`, source: 'api-football' },
{ key: `footapi:player_by_name:${n}`, source: 'footapi' },
{ key: `soccer:player:${n}`, source: 'football-data' },
]);
}
async function loadNextMatch(teamName) {
if (!teamName) return null;
return safeCacheGet(`soccer:nextmatch:${teamName}`);
return loadFromCascade([
{ key: `apifootball:nextmatch:${teamName}`, source: 'api-football' },
{ key: `soccer:nextmatch:${teamName}`, source: 'football-data' },
]);
}
async function loadLastFixture(teamName) {
if (!teamName) return null;
return safeCacheGet(`soccer:lastfixture:${teamName}`);
return loadFromCascade([
{ key: `apifootball:lastfixture:${teamName}`, source: 'api-football' },
{ key: `soccer:lastfixture:${teamName}`, source: 'football-data' },
]);
}
async function loadRefereeProfile(refName) {
if (!refName) return null;
return safeCacheGet(`soccer:referee:${refName}`);
return loadFromCascade([
{ key: `apifootball:referee_by_name:${refName}`, source: 'api-football' },
{ key: `footapi:referee_by_name:${refName}`, source: 'footapi' },
{ key: `soccer:referee:${refName}`, source: 'football-data' },
]);
}
async function loadTeamDefense(league, teamName) {
@@ -221,6 +260,16 @@ async function extractSoccerFeatures(input = {}) {
isHome,
gameLogs: [],
errors,
// Session 9 — which source the cascade actually resolved for
// each load. Useful for debugging "why does Messi's profile
// look thin?" — if `player_source: 'football-data'` it means
// the api-football prefetch hasn't populated his row yet.
sources: {
player: profile?._source || null,
nextMatch: nextMatch?._source || null,
lastFixture: lastFixture?._source || null,
referee: refProfile?._source || null,
},
},
};
}