Files
vyndr/src/services/newsService.js
T
builtbykev f0752b804b 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>
2026-07-13 23:33:26 -04:00

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 } };