92 lines
3.2 KiB
TypeScript
92 lines
3.2 KiB
TypeScript
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<NormalizedStory[]> {
|
|
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<string>();
|
|
for (const m of text.matchAll(PrWirePoller.EXCHANGE_TAG)) {
|
|
out.add(m[1].toUpperCase());
|
|
}
|
|
return [...out];
|
|
}
|
|
|
|
private async fetchText(url: string): Promise<string> {
|
|
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();
|
|
}
|
|
}
|