use std::collections::HashSet;
use url::Url;
use crate::error::RssError;
use crate::fetch::HttpClient;
use crate::model::{DiscoverOutput, DiscoveredFeed};
pub async fn discover(site_url: &str, http: &HttpClient) -> Result<DiscoverOutput, RssError> {
let (bytes, final_url) = http.get_bytes(site_url).await?;
let html = String::from_utf8_lossy(&bytes);
let feeds = extract_feeds(&html, &final_url);
Ok(DiscoverOutput::new(site_url, feeds))
}
fn extract_feeds(html: &str, base_url: &str) -> Vec<DiscoveredFeed> {
let dom = match tl::parse(html, tl::ParserOptions::default()) {
Ok(dom) => dom,
Err(_) => return Vec::new(),
};
let base = Url::parse(base_url).ok();
let mut feeds: Vec<DiscoveredFeed> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for node in dom.nodes() {
let Some(tag) = node.as_tag() else { continue };
if !tag.name().as_bytes().eq_ignore_ascii_case(b"link") {
continue;
}
let attrs = tag.attributes();
let rel = attr(attrs, "rel");
if !rel
.as_deref()
.is_some_and(|r| r.to_ascii_lowercase().contains("alternate"))
{
continue;
}
let href = match attr(attrs, "href") {
Some(h) if !h.trim().is_empty() => h,
_ => continue,
};
let ty = attr(attrs, "type");
let feed_type = match classify(ty.as_deref(), &href) {
Some(t) => t,
None => continue,
};
let resolved = match &base {
Some(b) => match b.join(href.trim()) {
Ok(u) => u.to_string(),
Err(_) => continue,
},
None => href.trim().to_string(),
};
if !seen.insert(resolved.clone()) {
continue;
}
feeds.push(DiscoveredFeed {
url: resolved,
feed_type: feed_type.to_string(),
title: attr(attrs, "title"),
});
}
feeds
}
fn attr(attrs: &tl::Attributes<'_>, key: &str) -> Option<String> {
attrs
.get(key)
.flatten()
.map(|b| b.as_utf8_str().into_owned())
}
fn classify(ty: Option<&str>, href: &str) -> Option<&'static str> {
if let Some(ty) = ty {
let mime = ty
.split(';')
.next()
.unwrap_or(ty)
.trim()
.to_ascii_lowercase();
return match mime.as_str() {
"application/rss+xml" => Some("rss"),
"application/atom+xml" => Some("atom"),
"application/json" | "application/feed+json" => Some("json"),
_ => None,
};
}
let lower = href
.split(['?', '#'])
.next()
.unwrap_or(href)
.to_ascii_lowercase();
if lower.ends_with(".rss") || lower.ends_with(".atom") || lower.ends_with(".xml") {
Some("unknown")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_rss_and_atom_links() {
let html = r#"
<html><head>
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/feed.rss">
<link rel="alternate" type="application/atom+xml" title="Atom Feed" href="https://other.example/atom.xml">
</head><body></body></html>
"#;
let feeds = extract_feeds(html, "https://example.com/blog/");
assert_eq!(feeds.len(), 2);
assert_eq!(feeds[0].feed_type, "rss");
assert_eq!(feeds[0].title.as_deref(), Some("RSS Feed"));
assert_eq!(feeds[0].url, "https://example.com/feed.rss");
assert_eq!(feeds[1].feed_type, "atom");
assert_eq!(feeds[1].url, "https://other.example/atom.xml");
}
#[test]
fn accepts_json_and_strips_charset() {
let html = r#"<link rel="alternate" type="application/feed+json; charset=utf-8" href="/feed.json">"#;
let feeds = extract_feeds(html, "https://example.com/");
assert_eq!(feeds.len(), 1);
assert_eq!(feeds[0].feed_type, "json");
assert_eq!(feeds[0].url, "https://example.com/feed.json");
}
#[test]
fn rejects_non_feed_links() {
let html = r#"
<link rel="stylesheet" href="/style.css">
<link rel="alternate" type="text/html" href="/index.html">
<link rel="alternate" hreflang="fr" href="/fr/">
"#;
let feeds = extract_feeds(html, "https://example.com/");
assert!(feeds.is_empty(), "expected no feeds, got {feeds:?}");
}
#[test]
fn extension_heuristic_when_type_missing() {
let html = r#"<link rel="alternate" href="/posts.atom">"#;
let feeds = extract_feeds(html, "https://example.com/");
assert_eq!(feeds.len(), 1);
assert_eq!(feeds[0].feed_type, "unknown");
}
#[test]
fn dedup_is_order_preserving() {
let html = r#"
<link rel="alternate" type="application/rss+xml" href="/feed.rss">
<link rel="alternate" type="application/rss+xml" href="/feed.rss">
<link rel="alternate" type="application/atom+xml" href="/feed.atom">
"#;
let feeds = extract_feeds(html, "https://example.com/");
assert_eq!(feeds.len(), 2);
assert_eq!(feeds[0].url, "https://example.com/feed.rss");
assert_eq!(feeds[1].url, "https://example.com/feed.atom");
}
}