Session 9: api-football + FootApi + Tank01 adapters, grace period middleware, cookie consent, /pricing page, OOM fix documented (1240 tests)
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user