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