422 lines
17 KiB
JavaScript
422 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Content template engine (Session 29).
|
|
*
|
|
* Generates STRUCTURED DATA objects (not text, not images) from live VYNDR
|
|
* data. A separate formatter (contentFormatter) renders text; a future
|
|
* design layer renders images.
|
|
*
|
|
* Templates degrade gracefully by data level:
|
|
* full → graded props exist → rich content (grade, edge, projection)
|
|
* lines → Tank01 game lines exist → line-focused content
|
|
* schedule → only ESPN schedule → matchup/time content
|
|
* empty → nothing today → minimal, never a crash
|
|
*
|
|
* The pure generators take a `data` bundle and never touch the network.
|
|
* `collectSlateData` is the thin orchestrator that gathers the bundle from
|
|
* the live services; its collectors are injectable so it stays testable.
|
|
*/
|
|
|
|
const GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, 'D+': 9, D: 10, 'D-': 11, F: 12 };
|
|
const SITE = 'vyndr.app';
|
|
|
|
// ---- field-alias normalizers (grades come from several shapes) --------
|
|
function gradeField(g, ...keys) {
|
|
for (const k of keys) if (g && g[k] != null && g[k] !== '') return g[k];
|
|
return null;
|
|
}
|
|
function normalizeGrade(g) {
|
|
return {
|
|
player: gradeField(g, 'player_name', 'player'),
|
|
stat: gradeField(g, 'stat_type', 'stat'),
|
|
line: gradeField(g, 'line'),
|
|
side: gradeField(g, 'side', 'direction'),
|
|
grade: gradeField(g, 'grade') || 'C',
|
|
confidence: Number(gradeField(g, 'confidence')) || 0,
|
|
edge: Number(gradeField(g, 'edge', 'edge_pct')) || 0,
|
|
projection: gradeField(g, 'projection'),
|
|
matchup: gradeField(g, 'matchup'),
|
|
killConditions: g?.killConditions || g?.kill_conditions_triggered || [],
|
|
analysis: gradeField(g, 'analysis', 'one_line_reason') || (g?.reasoning && g.reasoning.summary) || null,
|
|
_raw: g,
|
|
};
|
|
}
|
|
|
|
// ---- data level -------------------------------------------------------
|
|
function determineDataLevel(data) {
|
|
const grades = data?.grades || [];
|
|
const gameLines = data?.gameLines || {};
|
|
const schedule = data?.schedule || [];
|
|
if (grades.length > 0) return 'full';
|
|
if (gameLines && Object.keys(gameLines).length > 0) return 'lines';
|
|
if (schedule.length > 0) return 'schedule';
|
|
return 'empty';
|
|
}
|
|
|
|
function prettyDate(now = Date.now()) {
|
|
return new Date(now).toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
function sportLabel(sport) {
|
|
return String(sport || '').toUpperCase();
|
|
}
|
|
|
|
// ---- game-line highlight extraction -----------------------------------
|
|
function numOdds(v) {
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
function americanToDecimal(odds) {
|
|
const n = Number(odds);
|
|
if (!Number.isFinite(n) || n === 0) return null;
|
|
return n > 0 ? 1 + n / 100 : 1 + 100 / Math.abs(n);
|
|
}
|
|
|
|
// books: array of [bookName, { homeML, awayML, total, homeSpread, ... }]
|
|
function bestOdds(books, field) {
|
|
let best = null;
|
|
for (const [book, line] of books) {
|
|
const dec = americanToDecimal(line?.[field]);
|
|
if (dec == null) continue;
|
|
if (!best || dec > best.decimal) best = { book, odds: line[field], decimal: dec };
|
|
}
|
|
return best;
|
|
}
|
|
function consensus(books, field) {
|
|
const vals = books.map(([, l]) => Number(l?.[field])).filter((n) => Number.isFinite(n));
|
|
if (vals.length === 0) return null;
|
|
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10;
|
|
}
|
|
// Disagreement = spread between best & worst home-ML decimal payout.
|
|
function bookDisagreement(books) {
|
|
const decs = books.map(([, l]) => americanToDecimal(l?.homeML)).filter((n) => n != null);
|
|
if (decs.length < 2) return 0;
|
|
return Math.round((Math.max(...decs) - Math.min(...decs)) * 100) / 100;
|
|
}
|
|
|
|
function findGameLinesFor(game, gameLines) {
|
|
if (!gameLines) return null;
|
|
const h = (game.homeTeam?.abbreviation || '').toUpperCase();
|
|
const a = (game.awayTeam?.abbreviation || '').toUpperCase();
|
|
for (const entry of Object.values(gameLines)) {
|
|
const eh = String(entry.homeTeam || '').toUpperCase();
|
|
const ea = String(entry.awayTeam || '').toUpperCase();
|
|
if ((eh === h && ea === a) || (eh === a && ea === h)) return entry;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractGameLineHighlights(schedule, gameLines) {
|
|
const highlights = [];
|
|
for (const game of schedule || []) {
|
|
const lines = findGameLinesFor(game, gameLines);
|
|
const books = lines && lines.books ? Object.entries(lines.books) : [];
|
|
if (books.length === 0) continue;
|
|
highlights.push({
|
|
type: 'game_line_highlight',
|
|
game: `${game.awayTeam?.abbreviation || '?'} @ ${game.homeTeam?.abbreviation || '?'}`,
|
|
time: game.gameTime || null,
|
|
venue: game.venue || null,
|
|
bookCount: books.length,
|
|
bestHomeML: bestOdds(books, 'homeML'),
|
|
bestAwayML: bestOdds(books, 'awayML'),
|
|
total: consensus(books, 'total'),
|
|
spread: consensus(books, 'homeSpread'),
|
|
disagreement: bookDisagreement(books),
|
|
});
|
|
}
|
|
return highlights.sort((a, b) => (b.disagreement || 0) - (a.disagreement || 0));
|
|
}
|
|
|
|
// ---- hook + CTA -------------------------------------------------------
|
|
function generateHookText(sport, data) {
|
|
const label = sportLabel(sport);
|
|
const date = prettyDate();
|
|
const games = (data.schedule || []).length;
|
|
const bookCount = maxBookCount(data.gameLines);
|
|
if (data.dataLevel === 'full') {
|
|
return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games graded${bookCount ? ` · lines from ${bookCount} books` : ''}. Top reads below. 🧵`;
|
|
}
|
|
if (data.dataLevel === 'lines') {
|
|
return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games · lines from ${bookCount} sportsbooks. Here's what stands out. 🧵`;
|
|
}
|
|
if (data.dataLevel === 'schedule') {
|
|
return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games on the board today. 🧵`;
|
|
}
|
|
return `VYNDR — ${date}. No ${label} games today. Back tomorrow.`;
|
|
}
|
|
function maxBookCount(gameLines) {
|
|
if (!gameLines) return 0;
|
|
let max = 0;
|
|
for (const entry of Object.values(gameLines)) {
|
|
const n = entry && entry.books ? Object.keys(entry.books).length : 0;
|
|
if (n > max) max = n;
|
|
}
|
|
return max;
|
|
}
|
|
function generateCTAText(dataLevel) {
|
|
if (dataLevel === 'empty') return `Full slate + intelligence at ${SITE}.`;
|
|
return `Full slate live at ${SITE}. Free tier: reads on us. Founder pricing from $14.99/mo.`;
|
|
}
|
|
function generateAnalysisLine(grade) {
|
|
if (grade.analysis) return grade.analysis;
|
|
const edge = grade.edge ? `${grade.edge > 0 ? '+' : ''}${grade.edge}% edge` : null;
|
|
const proj = grade.projection != null ? `projection ${grade.projection}` : null;
|
|
return [proj, edge].filter(Boolean).join(' · ') || 'Model edge on this line.';
|
|
}
|
|
|
|
// ---- slate thread -----------------------------------------------------
|
|
function generateSlateThread(sport, data) {
|
|
const dataLevel = data.dataLevel || determineDataLevel(data);
|
|
const enriched = { ...data, dataLevel };
|
|
const thread = { type: 'slate_thread', sport, date: prettyDate(), dataLevel, posts: [] };
|
|
|
|
thread.posts.push({ role: 'hook', text: generateHookText(sport, enriched) });
|
|
|
|
if (dataLevel === 'full') {
|
|
const top = (data.grades || []).map(normalizeGrade)
|
|
.sort((a, b) => b.confidence - a.confidence)
|
|
.slice(0, 5);
|
|
for (const g of top) {
|
|
thread.posts.push({
|
|
role: 'pick',
|
|
player: g.player, stat: g.stat, line: g.line, side: g.side,
|
|
grade: g.grade, confidence: g.confidence, edge: g.edge, projection: g.projection,
|
|
analysis: generateAnalysisLine(g), cardData: g._raw,
|
|
});
|
|
}
|
|
} else if (dataLevel === 'lines') {
|
|
const highlights = extractGameLineHighlights(data.schedule, data.gameLines);
|
|
for (const h of highlights.slice(0, 4)) thread.posts.push({ role: 'game_highlight', ...h });
|
|
if ((data.movers || []).length > 0) {
|
|
thread.posts.push({ role: 'movers', movers: data.movers.slice(0, 3) });
|
|
}
|
|
} else if (dataLevel === 'schedule') {
|
|
const games = (data.schedule || []).slice(0, 8).map((g) => ({
|
|
away: g.awayTeam?.name || g.awayTeam?.abbreviation || null,
|
|
home: g.homeTeam?.name || g.homeTeam?.abbreviation || null,
|
|
time: g.gameTime || null,
|
|
venue: g.venue || null,
|
|
}));
|
|
thread.posts.push({ role: 'schedule', games });
|
|
}
|
|
|
|
thread.posts.push({ role: 'cta', text: generateCTAText(dataLevel) });
|
|
return thread;
|
|
}
|
|
|
|
// ---- prop / game of the day -------------------------------------------
|
|
function generatePOTD(sport, data) {
|
|
const dataLevel = data.dataLevel || determineDataLevel(data);
|
|
if (dataLevel === 'full' && (data.grades || []).length > 0) {
|
|
const best = (data.grades || []).map(normalizeGrade).reduce((a, b) => (b.confidence > a.confidence ? b : a));
|
|
return {
|
|
type: 'potd', dataLevel: 'full',
|
|
player: best.player, stat: best.stat, line: best.line, side: best.side,
|
|
grade: best.grade, confidence: best.confidence, edge: best.edge,
|
|
projection: best.projection, matchup: best.matchup,
|
|
killConditions: best.killConditions,
|
|
analysis: generateAnalysisLine(best), cardData: best._raw,
|
|
};
|
|
}
|
|
if (dataLevel === 'lines') {
|
|
const top = extractGameLineHighlights(data.schedule, data.gameLines)[0];
|
|
if (top) return { type: 'potd', dataLevel: 'lines', subtype: 'game_of_the_day', ...top };
|
|
}
|
|
return { type: 'potd', dataLevel: 'empty', available: false };
|
|
}
|
|
|
|
// ---- results recap (pure, takes resolved grades) ----------------------
|
|
function gradeTier(grade) {
|
|
return String(grade || '').charAt(0).toUpperCase();
|
|
}
|
|
function tierRecord(grades, tier) {
|
|
const inTier = grades.filter((g) => gradeTier(gradeField(g, 'grade')) === tier);
|
|
const wins = inTier.filter((g) => g.result === 'win').length;
|
|
const losses = inTier.filter((g) => g.result === 'loss').length;
|
|
return { wins, losses, total: inTier.length };
|
|
}
|
|
function brierScore(grades) {
|
|
// Brier = mean( (p - outcome)^2 ); p from confidence%, outcome 1 win / 0 loss.
|
|
const scored = grades.filter((g) => g.result === 'win' || g.result === 'loss');
|
|
if (scored.length === 0) return null;
|
|
const sum = scored.reduce((acc, g) => {
|
|
const p = (Number(gradeField(g, 'confidence')) || 50) / 100;
|
|
const outcome = g.result === 'win' ? 1 : 0;
|
|
return acc + (p - outcome) ** 2;
|
|
}, 0);
|
|
return Math.round((sum / scored.length) * 1000) / 1000;
|
|
}
|
|
function avgCLV(grades) {
|
|
const vals = grades.map((g) => Number(gradeField(g, 'clv'))).filter((n) => Number.isFinite(n));
|
|
if (vals.length === 0) return null;
|
|
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100) / 100;
|
|
}
|
|
|
|
function generateResultsRecap(sport, resolvedGrades) {
|
|
if (!Array.isArray(resolvedGrades) || resolvedGrades.length === 0) {
|
|
return { type: 'recap', available: false };
|
|
}
|
|
const wins = resolvedGrades.filter((g) => g.result === 'win');
|
|
const losses = resolvedGrades.filter((g) => g.result === 'loss');
|
|
const pushes = resolvedGrades.filter((g) => g.result === 'push');
|
|
const decided = wins.length + losses.length;
|
|
|
|
const byConfidence = (arr) => [...arr].sort((a, b) => (Number(gradeField(b, 'confidence')) || 0) - (Number(gradeField(a, 'confidence')) || 0));
|
|
|
|
return {
|
|
type: 'recap',
|
|
available: true,
|
|
sport,
|
|
date: prettyDate(),
|
|
record: { wins: wins.length, losses: losses.length, pushes: pushes.length },
|
|
winRate: decided > 0 ? Math.round((wins.length / decided) * 1000) / 1000 : null,
|
|
topHits: byConfidence(wins).slice(0, 3).map((g) => ({
|
|
player: gradeField(g, 'player_name', 'player'),
|
|
stat: gradeField(g, 'stat_type', 'stat'),
|
|
line: gradeField(g, 'line'),
|
|
side: gradeField(g, 'side', 'direction'),
|
|
grade: gradeField(g, 'grade'),
|
|
actual: gradeField(g, 'actual'),
|
|
})),
|
|
biggestMiss: losses.length > 0 ? (() => {
|
|
const m = byConfidence(losses)[0];
|
|
return {
|
|
player: gradeField(m, 'player_name', 'player'),
|
|
stat: gradeField(m, 'stat_type', 'stat'),
|
|
line: gradeField(m, 'line'),
|
|
side: gradeField(m, 'side', 'direction'),
|
|
grade: gradeField(m, 'grade'),
|
|
actual: gradeField(m, 'actual'),
|
|
};
|
|
})() : null,
|
|
byTier: { A: tierRecord(resolvedGrades, 'A'), B: tierRecord(resolvedGrades, 'B'), C: tierRecord(resolvedGrades, 'C') },
|
|
metrics: { brierScore: brierScore(resolvedGrades), clv: avgCLV(resolvedGrades) },
|
|
};
|
|
}
|
|
|
|
// ---- matchup preview --------------------------------------------------
|
|
function generateMatchupNarrative(game, lines, streaks) {
|
|
const away = game.awayTeam?.name || game.awayTeam?.abbreviation || 'Away';
|
|
const home = game.homeTeam?.name || game.homeTeam?.abbreviation || 'Home';
|
|
const matched = (streaks || []).length;
|
|
if (lines && lines.total != null) {
|
|
return `${away} at ${home} — consensus total ${lines.total}${matched ? `, ${matched} active player streak${matched === 1 ? '' : 's'} in play` : ''}.`;
|
|
}
|
|
return `${away} at ${home}${matched ? ` — ${matched} player${matched === 1 ? '' : 's'} riding a streak` : ''}.`;
|
|
}
|
|
|
|
function generateMatchupPreview(game, gameLines, streaks) {
|
|
const books = gameLines && gameLines.books ? Object.entries(gameLines.books) : [];
|
|
const lines = books.length > 0 ? {
|
|
spread: consensus(books, 'homeSpread'),
|
|
total: consensus(books, 'total'),
|
|
homeFavorite: (() => {
|
|
const hs = consensus(books, 'homeSpread');
|
|
return hs != null ? hs < 0 : null;
|
|
})(),
|
|
bookCount: books.length,
|
|
} : null;
|
|
|
|
const h = (game.homeTeam?.abbreviation || '').toUpperCase();
|
|
const a = (game.awayTeam?.abbreviation || '').toUpperCase();
|
|
const playerStreaks = (streaks || []).filter((s) => {
|
|
const t = (s.team || '').toUpperCase();
|
|
return t && (t === h || t === a);
|
|
});
|
|
|
|
return {
|
|
type: 'matchup_preview',
|
|
away: game.awayTeam || null,
|
|
home: game.homeTeam || null,
|
|
time: game.gameTime || null,
|
|
venue: game.venue || null,
|
|
lines,
|
|
playerStreaks,
|
|
narrative: generateMatchupNarrative(game, lines, playerStreaks),
|
|
};
|
|
}
|
|
|
|
// ---- orchestrator (injectable collectors for testability) -------------
|
|
function defaultCollectors() {
|
|
const scheduleService = require('./scheduleService');
|
|
const lineSnapshotService = require('./lineSnapshotService');
|
|
const bookComparisonService = require('./bookComparisonService');
|
|
const streaksService = require('./streaksService');
|
|
const { loadRosterLogs } = require('./rosterLogs');
|
|
const { cacheGet } = require('../utils/redis');
|
|
const nbaAdapter = require('./adapters/tank01NbaAdapter');
|
|
const mlbAdapter = require('./adapters/tank01MlbAdapter');
|
|
const { __internals: glInternals } = require('../routes/gameLines');
|
|
|
|
return {
|
|
getSchedule: (sport, date) => scheduleService.getSchedule(sport, date),
|
|
getGameLines: async (sport, date) => {
|
|
const fetcher = sport === 'nba' ? nbaAdapter.getNBABettingOdds : sport === 'mlb' ? mlbAdapter.getMLBBettingOdds : null;
|
|
if (!fetcher) return {};
|
|
const body = await fetcher(date).catch(() => null);
|
|
return body ? glInternals.normalizeGameLines(body) : {};
|
|
},
|
|
getGrades: async (sport) => {
|
|
// odds-api grades cache (when present); degrade to [] otherwise.
|
|
const utc = new Date().toISOString().split('T')[0];
|
|
const cache = (await cacheGet(`grades:${sport}:${utc}`)) ?? (await cacheGet(`grades:${sport}`));
|
|
return Array.isArray(cache) ? cache : Array.isArray(cache?.grades) ? cache.grades : [];
|
|
},
|
|
getStreaks: async (sport) => {
|
|
const roster = await loadRosterLogs(sport);
|
|
return streaksService.computeStreaks(roster, sport, {});
|
|
},
|
|
getMovers: (sport) => lineSnapshotService.getBiggestMovers(sport, { limit: 10 }),
|
|
getBestLines: async (sport) => {
|
|
const utc = new Date().toISOString().split('T')[0];
|
|
const cache = (await cacheGet(`odds:${sport}:${utc}`)) ?? (await cacheGet(`odds:${sport}`));
|
|
const props = Array.isArray(cache?.props) ? cache.props : [];
|
|
return bookComparisonService.bestLines(props, { limit: 10 });
|
|
},
|
|
};
|
|
}
|
|
|
|
async function collectSlateData(sport, deps) {
|
|
const c = deps || defaultCollectors();
|
|
const date = (() => {
|
|
try { return require('./scheduleService').todayET(); } catch { return undefined; }
|
|
})();
|
|
|
|
const [schedule, gameLines, grades, streaks, movers, bestLines] = await Promise.allSettled([
|
|
c.getSchedule(sport, date),
|
|
c.getGameLines(sport, date),
|
|
c.getGrades(sport),
|
|
c.getStreaks(sport),
|
|
c.getMovers(sport),
|
|
c.getBestLines(sport),
|
|
]);
|
|
|
|
const val = (r, fallback) => (r.status === 'fulfilled' && r.value != null ? r.value : fallback);
|
|
const data = {
|
|
sport,
|
|
schedule: val(schedule, []),
|
|
gameLines: val(gameLines, {}),
|
|
grades: val(grades, []),
|
|
streaks: val(streaks, []),
|
|
movers: val(movers, []),
|
|
bestLines: val(bestLines, []),
|
|
};
|
|
data.dataLevel = determineDataLevel(data);
|
|
return data;
|
|
}
|
|
|
|
module.exports = {
|
|
collectSlateData,
|
|
determineDataLevel,
|
|
generateSlateThread,
|
|
generatePOTD,
|
|
generateResultsRecap,
|
|
generateMatchupPreview,
|
|
__internals: {
|
|
normalizeGrade, extractGameLineHighlights, generateHookText, generateCTAText,
|
|
brierScore, avgCLV, tierRecord, bestOdds, consensus, bookDisagreement, findGameLinesFor,
|
|
},
|
|
};
|