feedsignal/crates/feeds/src/lib.rs

86 lines
2.9 KiB
Rust
Raw Normal View History

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<Article>)> {
// `.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<DateTime<Utc>> = 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 `<title>` 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::*;
/// A blank or whitespace-only feed title should be treated the same as
/// a missing one, not surfaced as an empty string in the UI.
#[test]
fn has_content_rejects_blank_strings() {
assert!(!has_content(""));
assert!(!has_content(" \n\t"));
assert!(has_content("Rust Blog"));
}
}