import { RssParser } from '../rss'; import type { Logger, NormalizedStory } from '../../shared/types'; /** * PR-wire RSS poller (FREE-DATA-STACK §1.4 / P1.2 Tier 3) — press releases * that the other free feeds miss, mostly small-caps. * * Ticker extraction relies on the wire convention of exchange tags in the * text: "(NYSE: ABC)", "(Nasdaq: XYZ)". Stories without an exchange tag * produce no tickers and are dropped by the pipeline's universe filter — * that's intentional; untagged wire stories are rarely decision-grade. * * Feed list is overridable: NEWS_PRWIRE_FEEDS="url1,url2" in .env * (wire RSS URLs change occasionally — if a feed 404s, update the env var). */ export class PrWirePoller { private static readonly DEFAULT_FEEDS = [ // GlobeNewswire — public-company news 'https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies', // PR Newswire — all news releases 'https://www.prnewswire.com/rss/news-releases-list.rss', ]; private static readonly EXCHANGE_TAG = /\((?:NYSE(?:\s+American)?|NASDAQ|Nasdaq|AMEX|CBOE|OTC(?:QB|QX|MKTS)?)\s*:\s*([A-Za-z][A-Za-z.]{0,5})\)/g; private readonly feeds: string[]; constructor( private readonly logger: Logger, feeds?: string[], ) { const env = process.env.NEWS_PRWIRE_FEEDS; this.feeds = feeds ?? (env ? env.split(',').map((s) => s.trim()) : PrWirePoller.DEFAULT_FEEDS); } async poll(): Promise { const stories: NormalizedStory[] = []; for (const feed of this.feeds) { try { const xml = await this.fetchText(feed); stories.push(...PrWirePoller.parseFeed(xml)); } catch (err) { this.logger.warn(`PR-wire feed failed (${feed}):`, (err as Error).message); } } return stories; } /** Parse one RSS feed. Public static for fixture tests. */ static parseFeed(xml: string): NormalizedStory[] { const stories: NormalizedStory[] = []; for (const item of RssParser.blocks(xml, 'item')) { const title = RssParser.tag(item, 'title'); const url = RssParser.link(item); const pubDate = RssParser.tag(item, 'pubDate'); if (!title || !url) continue; const description = RssParser.tag(item, 'description') ?? ''; const tickers = PrWirePoller.extractTickers(`${title} ${description}`); if (tickers.length === 0) continue; // no exchange tag → skip early stories.push({ tickers, headline: title, body: description || null, source: 'prwire', url, publishedAt: pubDate ? new Date(pubDate).toISOString() : new Date().toISOString(), }); } return stories; } /** "(NYSE: ABC)" / "(Nasdaq: XYZ)" → ['ABC', 'XYZ']. Public for tests. */ static extractTickers(text: string): string[] { const out = new Set(); for (const m of text.matchAll(PrWirePoller.EXCHANGE_TAG)) { out.add(m[1].toUpperCase()); } return [...out]; } private async fetchText(url: string): Promise { const res = await fetch(url, { headers: { 'User-Agent': 'market-screener/1.0 (+rss reader)' }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); } }