use anyhow::Result; use chrono::{DateTime, Utc}; use feedsignal_core::Article; use uuid::Uuid; /// Fetches and parses one RSS/Atom feed. `feed-rs` normalizes both formats /// into a common model so callers don't need to branch on feed type. pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result> { let bytes = reqwest::get(feed_url).await?.bytes().await?; let parsed = feed_rs::parser::parse(&bytes[..])?; let articles = parsed .entries .into_iter() .filter_map(|entry| { let url = entry.links.first()?.href.clone(); let title = entry.title.map(|t| t.content).unwrap_or_default(); let summary = entry .summary .map(|s| s.content) .or_else(|| entry.content.and_then(|c| c.body)) .unwrap_or_default(); let published_at: Option> = entry.published.or(entry.updated); let estimated_read_seconds = Some(estimate_read_seconds(&summary)); Some(Article { id: Uuid::new_v4(), feed_id, url, title, summary, published_at, topics: Vec::new(), embedding_score: None, llm_score: None, final_score: None, estimated_read_seconds, }) }) .collect(); Ok(articles) } /// Rough estimate from word count at ~200 wpm, used as the denominator for /// dwell-time-based engagement scoring (see `feedsignal_core::affinity`). /// This is a placeholder — the full article body isn't fetched here, so /// it's a floor rather than an accurate estimate until content extraction /// is added. fn estimate_read_seconds(text: &str) -> u32 { let words = text.split_whitespace().count() as u32; ((words.max(1) as f32 / 200.0) * 60.0) as u32 }