1use futures::future::join_all;
30use serde::{Deserialize, Serialize};
31use std::collections::HashSet;
32use std::sync::OnceLock;
33use std::time::Duration;
34
35use crate::error::Result;
36
37mod parser;
38
39static FEED_UA: OnceLock<String> = OnceLock::new();
48
49const FEED_TIMEOUT_SECONDS: u64 = 30;
50
51fn feed_user_agent() -> &'static str {
52 FEED_UA.get_or_init(|| {
53 match std::env::var("EDGAR_EMAIL") {
58 Ok(email) if !email.trim().is_empty() => {
59 format!(
60 "finance-query/{} ({})",
61 env!("CARGO_PKG_VERSION"),
62 email.trim()
63 )
64 }
65 _ => concat!(
66 "finance-query/",
67 env!("CARGO_PKG_VERSION"),
68 " (+https://github.com/Verdenroz/finance-query)"
69 )
70 .to_string(),
71 }
72 })
73}
74
75fn build_feed_client() -> Result<reqwest::Client> {
76 reqwest::Client::builder()
77 .user_agent(feed_user_agent())
78 .timeout(Duration::from_secs(FEED_TIMEOUT_SECONDS))
79 .build()
80 .map_err(crate::error::FinanceError::HttpError)
81}
82
83#[derive(Debug, Clone)]
85#[non_exhaustive]
86pub enum FeedSource {
87 FederalReserve,
89 SecPressReleases,
91 SecFilings(String),
93 MarketWatch,
95 Cnbc,
97 Bloomberg,
99 FinancialTimes,
101 NytBusiness,
103 GuardianBusiness,
105 Investing,
107 Bea,
109 Ecb,
111 Cfpb,
113 WsjMarkets,
115 Fortune,
117 BusinessWire,
119 CoinDesk,
121 CoinTelegraph,
123 TechCrunch,
125 HackerNews,
127 OilPrice,
129 CalculatedRisk,
131 Scmp,
133 NikkeiAsia,
135 BankOfEngland,
137 VentureBeat,
139 YCombinator,
141 TheEconomist,
143 FinancialPost,
145 FtLex,
147 RitholtzBigPicture,
149 Custom(String),
151}
152
153impl FeedSource {
154 pub fn url(&self) -> String {
156 match self {
157 Self::FederalReserve => {
158 "https://www.federalreserve.gov/feeds/press_all.xml".to_string()
159 }
160 Self::SecPressReleases => "https://www.sec.gov/news/pressreleases.rss".to_string(),
161 Self::SecFilings(form_type) => format!(
162 "https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type={form_type}&output=atom"
163 ),
164 Self::MarketWatch => {
165 "https://feeds.content.dowjones.io/public/rss/mw_topstories".to_string()
166 }
167 Self::Cnbc => "https://www.cnbc.com/id/100003114/device/rss/rss.html".to_string(),
168 Self::Bloomberg => "https://feeds.bloomberg.com/markets/news.rss".to_string(),
169 Self::FinancialTimes => "https://www.ft.com/markets?format=rss".to_string(),
170 Self::NytBusiness => {
171 "https://rss.nytimes.com/services/xml/rss/nyt/Business.xml".to_string()
172 }
173 Self::GuardianBusiness => "https://www.theguardian.com/business/rss".to_string(),
174 Self::Investing => "https://www.investing.com/rss/news.rss".to_string(),
175 Self::Bea => "https://apps.bea.gov/rss/rss.xml".to_string(),
176 Self::Ecb => "https://www.ecb.europa.eu/rss/press.html".to_string(),
177 Self::Cfpb => "https://www.consumerfinance.gov/about-us/newsroom/feed/".to_string(),
178 Self::WsjMarkets => "https://feeds.a.dj.com/rss/RSSMarketsMain.xml".to_string(),
179 Self::Fortune => "https://fortune.com/feed".to_string(),
180 Self::BusinessWire => {
181 "https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeGVtQXw==".to_string()
182 }
183 Self::CoinDesk => "https://www.coindesk.com/arc/outboundfeeds/rss/".to_string(),
184 Self::CoinTelegraph => "https://cointelegraph.com/rss".to_string(),
185 Self::TechCrunch => "https://techcrunch.com/feed/".to_string(),
186 Self::HackerNews => "https://hnrss.org/newest?points=100".to_string(),
187 Self::OilPrice => "https://oilprice.com/rss/main".to_string(),
188 Self::CalculatedRisk => "https://calculatedrisk.substack.com/feed".to_string(),
189 Self::Scmp => "https://www.scmp.com/rss/91/feed".to_string(),
190 Self::NikkeiAsia => "https://asia.nikkei.com/rss/feed/nar".to_string(),
191 Self::BankOfEngland => "https://www.bankofengland.co.uk/rss/news".to_string(),
192 Self::VentureBeat => "https://venturebeat.com/feed/".to_string(),
193 Self::YCombinator => "https://blog.ycombinator.com/feed/".to_string(),
194 Self::TheEconomist => {
195 "https://www.economist.com/sections/economics/rss.xml".to_string()
196 }
197 Self::FinancialPost => "https://financialpost.com/feed".to_string(),
198 Self::FtLex => "https://www.ft.com/lex?format=rss".to_string(),
199 Self::RitholtzBigPicture => "https://ritholtz.com/feed/".to_string(),
200 Self::Custom(url) => url.clone(),
201 }
202 }
203
204 pub fn name(&self) -> String {
206 match self {
207 Self::FederalReserve => "Federal Reserve".to_string(),
208 Self::SecPressReleases => "SEC".to_string(),
209 Self::SecFilings(form) => format!("SEC EDGAR ({form})"),
210 Self::MarketWatch => "MarketWatch".to_string(),
211 Self::Cnbc => "CNBC".to_string(),
212 Self::Bloomberg => "Bloomberg".to_string(),
213 Self::FinancialTimes => "Financial Times".to_string(),
214 Self::NytBusiness => "New York Times".to_string(),
215 Self::GuardianBusiness => "The Guardian".to_string(),
216 Self::Investing => "Investing.com".to_string(),
217 Self::Bea => "Bureau of Economic Analysis".to_string(),
218 Self::Ecb => "European Central Bank".to_string(),
219 Self::Cfpb => "CFPB".to_string(),
220 Self::WsjMarkets => "Wall Street Journal".to_string(),
221 Self::Fortune => "Fortune".to_string(),
222 Self::BusinessWire => "Business Wire".to_string(),
223 Self::CoinDesk => "CoinDesk".to_string(),
224 Self::CoinTelegraph => "CoinTelegraph".to_string(),
225 Self::TechCrunch => "TechCrunch".to_string(),
226 Self::HackerNews => "Hacker News".to_string(),
227 Self::OilPrice => "OilPrice.com".to_string(),
228 Self::CalculatedRisk => "Calculated Risk".to_string(),
229 Self::Scmp => "South China Morning Post".to_string(),
230 Self::NikkeiAsia => "Nikkei Asia".to_string(),
231 Self::BankOfEngland => "Bank of England".to_string(),
232 Self::VentureBeat => "VentureBeat".to_string(),
233 Self::YCombinator => "Y Combinator".to_string(),
234 Self::TheEconomist => "The Economist".to_string(),
235 Self::FinancialPost => "Financial Post".to_string(),
236 Self::FtLex => "Financial Times Lex".to_string(),
237 Self::RitholtzBigPicture => "The Big Picture".to_string(),
238 Self::Custom(url) => url.clone(),
239 }
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
245#[non_exhaustive]
246pub struct FeedEntry {
247 pub title: String,
249 pub url: String,
251 pub published: Option<String>,
253 pub summary: Option<String>,
255 pub source: String,
257}
258
259pub async fn fetch(source: FeedSource) -> Result<Vec<FeedEntry>> {
263 let client = build_feed_client()?;
264 fetch_with_client(&client, &source.url(), &source.name()).await
265}
266
267pub async fn fetch_all(sources: impl IntoIterator<Item = FeedSource>) -> Result<Vec<FeedEntry>> {
275 let client = build_feed_client()?;
276 let pairs: Vec<(String, String)> = sources.into_iter().map(|s| (s.url(), s.name())).collect();
277 let futures: Vec<_> = pairs
278 .iter()
279 .map(|(url, name)| fetch_with_client(&client, url, name))
280 .collect();
281
282 let results = join_all(futures).await;
283
284 let mut seen_urls: HashSet<String> = HashSet::new();
285 let mut entries: Vec<FeedEntry> = results
286 .into_iter()
287 .flat_map(|r| r.unwrap_or_default())
288 .filter(|e| seen_urls.insert(e.url.clone()))
289 .collect();
290
291 entries.sort_by(|a, b| b.published.cmp(&a.published));
293
294 Ok(entries)
295}
296
297async fn fetch_with_client(
298 client: &reqwest::Client,
299 url: &str,
300 source_name: &str,
301) -> Result<Vec<FeedEntry>> {
302 let text = client.get(url).send().await?.text().await?;
303 parser::parse(text.as_bytes(), source_name)
304}
305
306pub fn parse_bytes(bytes: &[u8], source_name: &str) -> Result<Vec<FeedEntry>> {
312 parser::parse(bytes, source_name)
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn test_feed_source_urls() {
321 assert!(FeedSource::FederalReserve.url().starts_with("https://"));
322 assert!(FeedSource::SecPressReleases.url().starts_with("https://"));
323 assert!(
324 FeedSource::SecFilings("10-K".to_string())
325 .url()
326 .contains("10-K")
327 );
328 assert_eq!(
329 FeedSource::Custom("https://example.com/feed.rss".to_string()).url(),
330 "https://example.com/feed.rss"
331 );
332 assert!(FeedSource::Bloomberg.url().starts_with("https://"));
333 assert!(FeedSource::FinancialTimes.url().starts_with("https://"));
334 assert!(FeedSource::NytBusiness.url().starts_with("https://"));
335 assert!(FeedSource::GuardianBusiness.url().starts_with("https://"));
336 assert!(FeedSource::Investing.url().starts_with("https://"));
337 assert!(FeedSource::Bea.url().starts_with("https://"));
338 assert!(FeedSource::Ecb.url().starts_with("https://"));
339 assert!(FeedSource::Cfpb.url().starts_with("https://"));
340 assert!(FeedSource::WsjMarkets.url().contains("dj.com"));
342 assert!(FeedSource::Fortune.url().contains("fortune.com"));
343 assert!(FeedSource::BusinessWire.url().contains("businesswire.com"));
344 assert!(FeedSource::CoinDesk.url().contains("coindesk.com"));
345 assert!(
346 FeedSource::CoinTelegraph
347 .url()
348 .contains("cointelegraph.com")
349 );
350 assert!(FeedSource::TechCrunch.url().contains("techcrunch.com"));
351 assert!(FeedSource::HackerNews.url().contains("hnrss.org"));
352 }
353
354 #[test]
355 fn test_feed_source_names() {
356 assert_eq!(FeedSource::FederalReserve.name(), "Federal Reserve");
357 assert_eq!(FeedSource::MarketWatch.name(), "MarketWatch");
358 assert_eq!(FeedSource::Bloomberg.name(), "Bloomberg");
359 assert_eq!(FeedSource::FinancialTimes.name(), "Financial Times");
360 assert_eq!(FeedSource::NytBusiness.name(), "New York Times");
361 assert_eq!(FeedSource::GuardianBusiness.name(), "The Guardian");
362 assert_eq!(FeedSource::Investing.name(), "Investing.com");
363 assert_eq!(FeedSource::Bea.name(), "Bureau of Economic Analysis");
364 assert_eq!(FeedSource::Ecb.name(), "European Central Bank");
365 assert_eq!(FeedSource::Cfpb.name(), "CFPB");
366 assert_eq!(FeedSource::WsjMarkets.name(), "Wall Street Journal");
368 assert_eq!(FeedSource::Fortune.name(), "Fortune");
369 assert_eq!(FeedSource::BusinessWire.name(), "Business Wire");
370 assert_eq!(FeedSource::CoinDesk.name(), "CoinDesk");
371 assert_eq!(FeedSource::CoinTelegraph.name(), "CoinTelegraph");
372 assert_eq!(FeedSource::TechCrunch.name(), "TechCrunch");
373 assert_eq!(FeedSource::HackerNews.name(), "Hacker News");
374 }
375
376 #[tokio::test]
377 #[ignore = "requires network access"]
378 async fn test_fetch_fed_reserve() {
379 let entries = fetch(FeedSource::FederalReserve).await;
380 assert!(entries.is_ok(), "Expected ok, got: {:?}", entries.err());
381 let entries = entries.unwrap();
382 assert!(!entries.is_empty());
383 for e in entries.iter().take(3) {
384 assert!(!e.title.is_empty());
385 assert!(!e.url.is_empty());
386 }
387 }
388
389 #[tokio::test]
390 #[ignore = "requires network access"]
391 async fn test_fetch_bloomberg() {
392 let entries = fetch(FeedSource::Bloomberg).await;
393 assert!(entries.is_ok(), "Expected ok, got: {:?}", entries.err());
394 let entries = entries.unwrap();
395 assert!(!entries.is_empty());
396 for e in entries.iter().take(3) {
397 assert!(!e.title.is_empty());
398 assert!(!e.url.is_empty());
399 assert_eq!(e.source, "Bloomberg");
400 }
401 }
402
403 #[tokio::test]
404 #[ignore = "requires network access"]
405 async fn test_fetch_financial_times() {
406 let entries = fetch(FeedSource::FinancialTimes).await;
407 assert!(entries.is_ok(), "Expected ok, got: {:?}", entries.err());
408 let entries = entries.unwrap();
409 assert!(!entries.is_empty());
410 }
411
412 #[tokio::test]
413 #[ignore = "requires network access"]
414 async fn test_fetch_bea() {
415 let entries = fetch(FeedSource::Bea).await;
416 assert!(entries.is_ok(), "Expected ok, got: {:?}", entries.err());
417 let entries = entries.unwrap();
418 assert!(!entries.is_empty());
419 assert_eq!(entries[0].source, "Bureau of Economic Analysis");
420 }
421
422 #[tokio::test]
423 #[ignore = "requires network access"]
424 async fn test_fetch_ecb() {
425 let entries = fetch(FeedSource::Ecb).await;
426 assert!(entries.is_ok(), "Expected ok, got: {:?}", entries.err());
427 let entries = entries.unwrap();
428 assert!(!entries.is_empty());
429 }
430}