2026-08-20 15:04:14 +00:00
|
|
|
use anyhow::Result;
|
|
|
|
|
use chrono::{DateTime, Utc};
|
|
|
|
|
use feedsignal_core::Article;
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
2026-08-21 13:15:50 +00:00
|
|
|
/// 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>)> {
|
2026-08-21 13:32:39 +00:00
|
|
|
// `.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?;
|
2026-08-20 15:04:14 +00:00
|
|
|
let parsed = feed_rs::parser::parse(&bytes[..])?;
|
|
|
|
|
|
2026-08-21 13:32:39 +00:00
|
|
|
let feed_title = parsed
|
2026-08-21 13:15:50 +00:00
|
|
|
.title
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|t| t.content.clone())
|
2026-08-21 13:32:39 +00:00
|
|
|
.filter(|t| has_content(t))
|
2026-08-21 13:15:50 +00:00
|
|
|
.unwrap_or_else(|| feed_url.to_string());
|
|
|
|
|
|
2026-08-20 15:04:14 +00:00
|
|
|
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();
|
|
|
|
|
|
2026-08-21 13:32:39 +00:00
|
|
|
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()
|
2026-08-20 15:04:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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
|
|
|
|
|
}
|
2026-08-21 13:32:39 +00:00
|
|
|
|
|
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
}
|