Wave 2A: offseason data feeds — news wire + quota-disciplined futures
FREE ESPN news wire + championship-winner futures for the never-dark
offseason hub. Both graceful/empty, never fabricate a market value.
- newsService (mirrors injuryService): per-sport ESPN /news FEEDS, pure
parseNews → { sport, items:[{id,headline,description,published,type,
athlete?{name,key},team?,href}] }; athlete/team from categories[] only
(absent when not present). Cache 15m, injectable, offline-tested.
- oddsNormalizer.normalizeOutrights: NEW branch — outrights outcomes are
{name,price} with no point, so normalizeProps drops them; keeps them with
best-price-across-allowed-books per selection. + americanToDecimal.
- oddsService.FUTURES_KEYS: separate map (mlb/nba/wnba championship winner),
OUT of the daily SPORT_KEYS/snapshot budget.
- futuresService: getFutures(sport,deps) → { sport, updated_at, markets:
[{key,title,selections:[{name,price,prevPrice?,move?}]}] }. One outrights
call per 12h TTL (quota-disciplined), FUTURES_ENABLED gate. Price-move
(shortening/drifting/flat) mirrors computeLineDeltas SHAPE on odds not
line; prev prices persisted inside the futures:{sport} value (no new key).
linkNewsToMoves pure causal-tie helper.
- Routes /api/news/:sport + /api/futures/:sport (registered) + Next proxies.
- Tests: newsService, futuresService, oddsNormalizerOutrights (fail→pass,
no network). Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* newsService — the FREE ESPN news wire (Wave 2A / offseason-hub).
|
||||
*
|
||||
* Mirrors injuryService's pattern EXACTLY: per-sport FEEDS map → ESPN's
|
||||
* free `/news` endpoint, injectable axios/http + cache 15 min, pure
|
||||
* `parseNews(json)` → the Wave-2 news contract. NO odds-api credits.
|
||||
*
|
||||
* Contract (consumed by the parallel Wave 2B ExploreHub):
|
||||
* { sport, items: [{ id, headline, description, published (ISO), type,
|
||||
* athlete?: { name, key }, team?: string, href }] }
|
||||
* Newest first, cap ~25. Empty feed → { sport, items: [] } (never errors).
|
||||
*
|
||||
* Governing rules: never fabricate — athlete/team are extracted ONLY from
|
||||
* ESPN `categories[]` where present, and are ABSENT (undefined) otherwise.
|
||||
*/
|
||||
|
||||
const { nameKey } = require('../utils/playerName');
|
||||
|
||||
const TTL = 900; // 15 min — same cadence as injuryService.
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CAP = 25;
|
||||
|
||||
// ESPN site API `/news` per sport (FREE, no auth). Soccer/nfl/nhl are
|
||||
// optional — absent keys degrade to an empty feed, never an error.
|
||||
const FEEDS = {
|
||||
mlb: 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/news',
|
||||
nba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/news',
|
||||
wnba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/news',
|
||||
nfl: 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/news',
|
||||
nhl: 'https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/news',
|
||||
};
|
||||
|
||||
/** First `categories[]` entry of a given ESPN type, or null. */
|
||||
function firstCategory(categories, type) {
|
||||
for (const c of Array.isArray(categories) ? categories : []) {
|
||||
if (c && c.type === type) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: one ESPN article → the contract's item, or null if unusable
|
||||
* (no headline). athlete/team are attached ONLY when a matching category
|
||||
* is present — never invented.
|
||||
*/
|
||||
function parseArticle(article) {
|
||||
if (!article || !article.headline) return null;
|
||||
|
||||
const cats = article.categories;
|
||||
const item = {
|
||||
id: article.id != null ? String(article.id) : null,
|
||||
headline: String(article.headline),
|
||||
description: article.description ? String(article.description) : '',
|
||||
published: article.published || article.lastModified || null,
|
||||
type: article.type || 'Story',
|
||||
href: (article.links && article.links.web && article.links.web.href) || null,
|
||||
};
|
||||
|
||||
// Athlete — from the first `type:'athlete'` category (has `description` = name).
|
||||
const ath = firstCategory(cats, 'athlete');
|
||||
const athName = ath && (ath.description || (ath.athlete && ath.athlete.description));
|
||||
if (athName) {
|
||||
item.athlete = { name: String(athName), key: nameKey(String(athName)) };
|
||||
}
|
||||
|
||||
// Team — from the first `type:'team'` category (full display name, or abbr).
|
||||
const tm = firstCategory(cats, 'team');
|
||||
const tmName = tm && (tm.description || (tm.team && (tm.team.description || tm.team.abbreviation)));
|
||||
if (tmName) {
|
||||
item.team = String(tmName);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Pure: ESPN news JSON → the contract's items[] (newest first, capped). */
|
||||
function parseNews(json) {
|
||||
const articles = (json && Array.isArray(json.articles)) ? json.articles : [];
|
||||
const items = [];
|
||||
for (const a of articles) {
|
||||
const it = parseArticle(a);
|
||||
if (it) items.push(it);
|
||||
}
|
||||
// ESPN returns newest-first already; sort defensively by `published` desc
|
||||
// (items with no timestamp sink to the bottom, order preserved otherwise).
|
||||
items.sort((x, y) => {
|
||||
const tx = x.published ? Date.parse(x.published) : 0;
|
||||
const ty = y.published ? Date.parse(y.published) : 0;
|
||||
return (ty || 0) - (tx || 0);
|
||||
});
|
||||
return items.slice(0, CAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch (cache-aside) the news wire for a sport → { sport, items: [] }.
|
||||
* Injectable axios/cacheGet/cacheSet → unit-tested fully offline. Never
|
||||
* throws: an unknown sport or a failed fetch returns an empty feed.
|
||||
*/
|
||||
async function getNews(sport, opts = {}) {
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
const url = FEEDS[sp];
|
||||
if (!url) return { sport: sp, items: [] };
|
||||
|
||||
const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet;
|
||||
const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet;
|
||||
const key = `news:${sp}`;
|
||||
|
||||
try {
|
||||
const cached = await cacheGet(key);
|
||||
if (cached && Array.isArray(cached.items)) return { sport: sp, items: cached.items };
|
||||
} catch (_) { /* cache miss / redis down → fetch live */ }
|
||||
|
||||
const axios = opts.axios || require('axios');
|
||||
try {
|
||||
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
|
||||
const items = parseNews(res.data);
|
||||
try { await cacheSet(key, { items }, TTL); } catch (_) { /* best-effort */ }
|
||||
return { sport: sp, items };
|
||||
} catch (e) {
|
||||
console.warn(`[news] ${sp} fetch failed:`, e.message);
|
||||
return { sport: sp, items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getNews, parseNews, parseArticle, __internals: { TTL, FEEDS, CAP } };
|
||||
Reference in New Issue
Block a user