91 lines
3.1 KiB
TypeScript
91 lines
3.1 KiB
TypeScript
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|
import { NewsRepository } from './NewsRepository';
|
|
import { YahooFinanceClient } from '../shared';
|
|
import type { NewsArticleRow } from '../shared/types';
|
|
|
|
interface StoryView {
|
|
headline: string;
|
|
tickers: string[];
|
|
source: string;
|
|
catalyst: string | null;
|
|
url: string;
|
|
publishedAt: string;
|
|
}
|
|
|
|
/**
|
|
* Read side of the news pipeline. Stored pipeline stories (curated, catalyst-
|
|
* tagged, historical) are merged with a live per-ticker Yahoo search on
|
|
* request — stored gives depth, live gives freshness. The RSS firehoses
|
|
* can't be queried per-ticker on demand, which is why they go through the
|
|
* polling pipeline instead.
|
|
*/
|
|
export class NewsController {
|
|
constructor(
|
|
private readonly repo: NewsRepository,
|
|
private readonly yahoo?: YahooFinanceClient,
|
|
) {}
|
|
|
|
register(app: FastifyInstance): void {
|
|
app.get('/api/news/recent', this.recent.bind(this));
|
|
app.get('/api/news/:ticker', this.byTicker.bind(this));
|
|
}
|
|
|
|
/** GET /api/news/:ticker?days=7&live=1 (live Yahoo merge on by default) */
|
|
private async byTicker(req: FastifyRequest) {
|
|
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
|
|
const query = req.query as { days?: string; live?: string };
|
|
const days = Math.min(Number(query.days ?? 7) || 7, 90);
|
|
const live = query.live !== '0';
|
|
const sinceDay = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
|
|
const stored = this.repo.newsForTicker(ticker, sinceDay).map(NewsController.serialize);
|
|
const fresh = live ? await this.fetchLive(ticker) : [];
|
|
|
|
// Merge, dedupe by URL, newest first
|
|
const byUrl = new Map<string, StoryView>();
|
|
for (const s of [...stored, ...fresh]) byUrl.set(s.url, byUrl.get(s.url) ?? s);
|
|
const stories = [...byUrl.values()].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
|
|
|
return { ticker, days, stories };
|
|
}
|
|
|
|
/** Live per-ticker Yahoo news search — freshness layer, best-effort. */
|
|
private async fetchLive(ticker: string): Promise<StoryView[]> {
|
|
if (!this.yahoo) return [];
|
|
try {
|
|
const items = await this.yahoo.search(ticker, { newsCount: 8 });
|
|
return items
|
|
.filter((n) => n.title && n.link)
|
|
.map((n) => ({
|
|
headline: n.title as string,
|
|
tickers: [ticker],
|
|
source: 'yahoo',
|
|
catalyst: null,
|
|
url: n.link as string,
|
|
publishedAt: n.providerPublishTime
|
|
? new Date(n.providerPublishTime).toISOString()
|
|
: new Date().toISOString(),
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** GET /api/news/recent?limit=50 */
|
|
private async recent(req: FastifyRequest) {
|
|
const limit = Math.min(Number((req.query as { limit?: string }).limit ?? 50) || 50, 200);
|
|
return { stories: this.repo.recent(limit).map(NewsController.serialize) };
|
|
}
|
|
|
|
private static serialize(row: NewsArticleRow) {
|
|
return {
|
|
headline: row.headline,
|
|
tickers: JSON.parse(row.ticker_list) as string[],
|
|
source: row.source,
|
|
catalyst: row.catalyst,
|
|
url: row.url,
|
|
publishedAt: row.published_at,
|
|
};
|
|
}
|
|
}
|