use anyhow::Result; use chrono::{DateTime, Utc}; use feedsignal_core::Article; use uuid::Uuid; /// Fetches and parses one RSS/Atom feed, returning its title alongside the /// entries. `feed-rs` normalizes both RSS and Atom 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<(String, Vec
)> { // `.error_for_status()` turns a 404/5xx into a clear `Err` here rather // than letting a non-feed error page fall through to a confusing // feed-rs parse failure below. let response = reqwest::get(feed_url).await?.error_for_status()?; let bytes = response.bytes().await?; let parsed = feed_rs::parser::parse(&bytes[..])?; let feed_title = parsed .title .as_ref() .map(|t| t.content.clone()) .filter(|t| has_content(t)) .unwrap_or_else(|| feed_url.to_string()); 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((feed_title, articles)) } /// True if `s` has any non-whitespace content — used to reject a /// present-but-blank feed `` rather than surface it as-is. fn has_content(s: &str) -> bool { !s.trim().is_empty() } /// 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 } #[cfg(test)] mod tests { use super::*; /// An empty feed title should be treated the same as a missing one. #[test] fn has_content_rejects_empty_string() { assert!(!has_content("")); } /// A whitespace-only feed title should be treated the same as a /// missing one, not surfaced as a blank-looking name in the UI. #[test] fn has_content_rejects_whitespace_only_string() { assert!(!has_content(" \n\t")); } /// A real title is accepted as-is. #[test] fn has_content_accepts_non_blank_string() { assert!(has_content("Rust Blog")); } }