Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* College Football Data (CFBD) — advanced college analytics.
|
||||
*
|
||||
* 100% free API key. Covers historical betting lines, player usage, team
|
||||
* talent composites, advanced efficiency (PPA), recruiting. nba_api
|
||||
* doesn't cover college; CFBD fills the gap for NCAAB and NCAAFB props.
|
||||
*
|
||||
* Note: CFBD's primary product is college football. College *basketball*
|
||||
* coverage is via the /cbb endpoints. We expose both via the sport-aware
|
||||
* functions below.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
|
||||
const SOURCE = 'cfbd';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_SECONDS = 6 * 60 * 60; // 6h — most CFBD data is daily-fresh
|
||||
|
||||
const BASE_URL = process.env.CFBD_BASE_URL || 'https://api.collegefootballdata.com';
|
||||
|
||||
// Generous free tier — 10 req/min keeps us well under documented limits.
|
||||
const limiter = createLimiter({ tokensPerInterval: 10, interval: 60_000 });
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
function configured() {
|
||||
return !!process.env.CFBD_KEY;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(url, params, cacheKey) {
|
||||
if (!configured()) return null;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
await limiter.waitForToken();
|
||||
try {
|
||||
const data = await breaker.call(async () => {
|
||||
const res = await axios.get(url, {
|
||||
params,
|
||||
headers: { Authorization: `Bearer ${process.env.CFBD_KEY}` },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const err = new Error('cfbd rate limited');
|
||||
err.code = 'CFBD_429';
|
||||
throw err;
|
||||
}
|
||||
return res.data;
|
||||
});
|
||||
await cacheSet(cacheKey, data, CACHE_TTL_SECONDS);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err?.code === 'CIRCUIT_OPEN') return null;
|
||||
console.warn(`[${SOURCE}] fetch failed for ${cacheKey}:`, err?.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTeamStats(team, year) {
|
||||
const cacheKey = `cfbd:teamstats:${team}:${year}`;
|
||||
const data = await fetchWithGuards(`${BASE_URL}/stats/season`, { year, team }, cacheKey);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function getPlayerUsage(player, team, year) {
|
||||
const cacheKey = `cfbd:usage:${player}:${team}:${year}`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/player/usage`,
|
||||
{ year, team, player },
|
||||
cacheKey
|
||||
);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function getTalentComposite(team, year) {
|
||||
const cacheKey = `cfbd:talent:${team}:${year}`;
|
||||
const data = await fetchWithGuards(`${BASE_URL}/talent`, { year }, cacheKey);
|
||||
if (!Array.isArray(data)) return null;
|
||||
return data.find((row) => (row.school || row.team) === team) || null;
|
||||
}
|
||||
|
||||
async function getHistoricalLines(team, year) {
|
||||
const cacheKey = `cfbd:lines:${team}:${year}`;
|
||||
const data = await fetchWithGuards(`${BASE_URL}/lines`, { year, team }, cacheKey);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
getTeamStats,
|
||||
getPlayerUsage,
|
||||
getTalentComposite,
|
||||
getHistoricalLines,
|
||||
__internals: { limiter, breaker, BASE_URL },
|
||||
};
|
||||
Reference in New Issue
Block a user