f0752b804b
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>
128 lines
4.8 KiB
JavaScript
128 lines
4.8 KiB
JavaScript
'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 } };
|