//! Feed fetching and parsing. Replaces FeedData.__getFeed / __getEntries. use anyhow::{Context, Result, anyhow}; use reqwest::StatusCode; use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED}; use crate::config::Feed as FeedCfg; #[derive(Debug, Default)] pub struct ParsedFeed { pub title: Option, pub ttl_mins: Option, pub image: Option, /// The channel's first ``, for the Directory's chips. pub category: Option, pub entries: Vec, } #[derive(Debug, Default)] pub struct Entry { pub guid: String, pub title: Option, pub link: Option, pub published: Option, pub description: Option, pub categories: Vec, pub explicit: bool, /// Episode artwork; falls back to the feed's in the UI. pub image: Option, /// Seconds. pub duration: Option, pub episode: Option, pub season: Option, pub enclosures: Vec, } #[derive(Debug, Default, Clone, PartialEq)] pub struct Enclosure { pub url: String, pub mime: Option, pub length: Option, } pub enum Fetched { /// Server said 304, or returned a body we already have. NotModified, Body { bytes: Vec, etag: Option, last_modified: Option, }, } /// Conditional GET. reqwest handles gzip and redirects; the original's hand-rolled /// CONNECT/socket.ssl proxy path is gone -- `system-proxy` reads http_proxy/https_proxy. pub async fn fetch( client: &reqwest::Client, cfg: &FeedCfg, etag: Option<&str>, last_modified: Option<&str>, ) -> Result { let mut req = client.get(&cfg.url); if let Some(tag) = etag { req = req.header(IF_NONE_MATCH, tag); } if let Some(lm) = last_modified { req = req.header(IF_MODIFIED_SINCE, lm); } if let Some(user) = &cfg.username { req = req.basic_auth(user, cfg.password()); } let resp = req.send().await.context("connecting")?; if resp.status() == StatusCode::NOT_MODIFIED { return Ok(Fetched::NotModified); } let status = resp.status(); if !status.is_success() { // The original surfaced 401/407 specially; the code is enough for a UI to switch on. return Err(anyhow!("HTTP {status}")); } let header = |h: reqwest::header::HeaderName| { resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned) }; let etag = header(ETAG); let last_modified = header(LAST_MODIFIED); let bytes = resp.bytes().await.context("reading body")?.to_vec(); Ok(Fetched::Body { bytes, etag, last_modified }) } /// A stored `last_error`, translated into plain words for whoever subscribes: whose problem /// it is, and whether there is a new address to switch to. pub struct Failure { pub reason: &'static str, pub new_url: Option, } /// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the /// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling /// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled -- /// comes back `None`: transient by nature, or with nothing more useful to say than the raw /// text already shown once a feed is open. /// /// ponytail: matches on the fixed strings this crate itself produces (`anyhow!("HTTP /// {status}")`, and `parse`'s "got a web page" and "the site sent") plus the substrings a DNS failure /// reliably contains. Fragile if reqwest's own wording changes; the fallback is just showing /// nothing extra, so a miss costs a clearer message, not a wrong one. pub fn explain_failure(msg: &str) -> Option { if let Some(rest) = msg.strip_prefix("got a web page, not a feed") { let new_url = rest .strip_prefix("; it links ") .and_then(|r| r.strip_suffix(" as its feed")) .map(str::to_owned); return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url }); } if msg.contains("the site sent ") { return Some(Failure { reason: "The site sent a message instead of the feed; the publisher has to fix it.", new_url: None }); } let low = msg.to_ascii_lowercase(); if low.contains("http 404") { return Some(Failure { reason: "The publisher took this feed down, or moved it.", new_url: None }); } if low.contains("http 401") || low.contains("http 403") { return Some(Failure { reason: "The site refuses ipx's requests.", new_url: None }); } if low.contains("http 402") { return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None }); } if low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated") { return Some(Failure { reason: "This address no longer resolves; the site is gone.", new_url: None }); } None } /// True when a body is an OPML document rather than a feed. /// /// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an /// OPML served from a URL without that extension. Sniffing the body catches both. pub fn is_opml(bytes: &[u8]) -> bool { let head = &bytes[..bytes.len().min(1024)]; let text = String::from_utf8_lossy(head).to_lowercase(); text.contains(" Result> { let text = String::from_utf8_lossy(bytes); let doc = opml::OPML::from_str(&text) .map_err(|e| anyhow!("that does not parse as OPML: {e}"))?; let mut out = vec![]; crate::collect_outlines(&doc.body.outlines, &mut out); Ok(out) } /// The of an OPML document. pub fn opml_title(bytes: &[u8]) -> Option<String> { let text = String::from_utf8_lossy(bytes); let doc = opml::OPML::from_str(&text).ok()?; doc.head .and_then(|h| h.title) .map(|t| t.trim().to_owned()) .filter(|t| !t.is_empty()) } /// The token and show of a Patreon feed link, or None for any other URL. /// /// Patreon gives each patron one token per creator. With no show it stands for the creator, /// whose feed carries every show at once. fn patreon_parts(url: &str) -> Option<(String, Option<String>)> { let u = url::Url::parse(url).ok()?; if !matches!(u.host_str()?, "patreon.com" | "www.patreon.com") || !u.path().starts_with("/rss") { return None; } let param = |name: &str| u.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned()); Some((param("auth")?, param("show"))) } /// A Patreon link naming a creator but no show. pub fn is_patreon_creator(url: &str) -> bool { matches!(patreon_parts(url), Some((_, None))) } /// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's /// feed, since the token alone says whose it is. pub fn expand_input(input: &str) -> String { let s = input.trim(); let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); if token { format!("https://www.patreon.com/rss?auth={s}") } else { s.to_owned() } } /// Whether two URLs are the same feed. One Patreon show has several spellings -- by the /// creator's name, by number, or with no creator at all -- and the token and show are what /// identify it. pub fn same_feed(a: &str, b: &str) -> bool { a == b || patreon_parts(a).is_some_and(|p| Some(p) == patreon_parts(b)) } /// A Patreon creator's name and shows, each show as (title, feed URL). /// /// ponytail: Patreon's own web API, undocumented, asked without signing in. If it changes, /// finding shows stops and the show feeds already found keep working. The documented API /// needs an OAuth client per install and does not list shows. pub async fn patreon_shows( client: &reqwest::Client, url: &str, ) -> Result<(Option<String>, Vec<(String, String)>)> { // The creator feed names its campaign by number in its self link, a few hundred bytes in. // The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up. let mut resp = client.get(url).send().await.context("connecting")?; if !resp.status().is_success() { return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status())); } let mut head = Vec::new(); while patreon_campaign(&head).is_none() && head.len() < 64 * 1024 { let Some(chunk) = resp.chunk().await.context("reading the feed")? else { break }; head.extend_from_slice(&chunk); } let campaign = patreon_campaign(&head) .ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?; let api = format!( "https://www.patreon.com/api/campaigns/{campaign}\ ?include=shows&fields%5Bcampaign%5D=name&fields%5Bcollection%5D=title" ); let resp = client.get(api).send().await.context("asking Patreon for the shows")?; if !resp.status().is_success() { return Err(anyhow!("Patreon would not list the shows: HTTP {}", resp.status())); } let (name, shows) = parse_patreon_shows(&resp.bytes().await.context("reading the shows")?)?; Ok((name, shows.into_iter().map(|(id, title)| (title, format!("{url}&show={id}"))).collect())) } /// The campaign number in the start of a Patreon feed. fn patreon_campaign(head: &[u8]) -> Option<String> { let text = String::from_utf8_lossy(head); text.match_indices("patreon.com/rss/").find_map(|(i, m)| { let id: String = text[i + m.len()..].chars().take_while(char::is_ascii_digit).collect(); (!id.is_empty()).then_some(id) }) } /// A campaign's name and its shows as (id, title), from Patreon's JSON:API answer. fn parse_patreon_shows(json: &[u8]) -> Result<(Option<String>, Vec<(String, String)>)> { let v: serde_json::Value = serde_json::from_slice(json).context("Patreon's answer is not JSON")?; // Missing is not the same as none. Read as no shows, the creator feed would be scanned as // a plain feed, claim every show's files, and leave the shows empty once the list returned. let ids = v["data"]["relationships"]["shows"]["data"] .as_array() .ok_or_else(|| anyhow!("Patreon's answer does not list the shows"))?; let title = |id: &str| -> Option<String> { let show = v["included"].as_array()?.iter().find(|x| x["type"] == "collection" && x["id"] == id)?; show["attributes"]["title"].as_str().map(|t| t.trim().to_owned()) }; let shows = ids .iter() .filter_map(|s| s["id"].as_str()) .map(|id| (id.to_owned(), title(id).unwrap_or_else(|| format!("Show {id}")))) .collect(); Ok((v["data"]["attributes"]["name"].as_str().map(str::to_owned), shows)) } /// RSS first, then Atom -- the same split the original made on `parsedFeed.version`. pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> { match rss::Channel::read_from(bytes) { Ok(ch) => Ok(from_rss(ch, bytes)), Err(rss_err) => match atom_syndication::Feed::read_from(bytes) { Ok(feed) => Ok(from_atom(feed)), Err(atom_err) => { if let Some(said) = plain_text(bytes) { return Err(anyhow!("the site sent {said} instead of a feed")); } // Some publishers (kcpw, feedland) write a bare "&" in a URL instead of // "&". Strict XML parsers refuse it; browsers don't. Retry once with // every offending "&" escaped rather than fail outright. let escaped = escape_bare_ampersands(bytes); if escaped != bytes { if let Ok(ch) = rss::Channel::read_from(escaped.as_slice()) { return Ok(from_rss(ch, &escaped)); } if let Ok(feed) = atom_syndication::Feed::read_from(escaped.as_slice()) { return Ok(from_atom(feed)); } } Err(match alternate_feed_link(bytes) { Some(href) if looks_like_html(bytes) => { anyhow!("got a web page, not a feed; it links {href} as its feed") } None if looks_like_html(bytes) => anyhow!("got a web page, not a feed"), _ => anyhow!("not RSS ({rss_err}) and not Atom ({atom_err})"), }) } }, } } /// Whether a body is a web page rather than a feed: most of the errors traced back to a feed /// that moved or a domain that lapsed, with the old URL now serving the site instead (or a /// redirect to it). `is_opml` already sniffs the other "not actually a feed" case. fn looks_like_html(bytes: &[u8]) -> bool { let head = String::from_utf8_lossy(&bytes[..bytes.len().min(2048)]).to_lowercase(); head.contains("<!doctype html") || head.contains("<html") } /// What a site sent when it sent a sentence instead of markup. doghouse's feed answered 200 with /// "Unable to establish a DB connection", and the two parsers' errors about end of input buried /// it. Anything starting with `<` is markup, however broken, and keeps the parsers' errors. fn plain_text(bytes: &[u8]) -> Option<String> { let head = String::from_utf8_lossy(&bytes[..bytes.len().min(512)]); let text = head.trim_start_matches(|c: char| c.is_whitespace() || c == '\u{feff}'); if text.starts_with('<') { return None; } let line = text.lines().next().unwrap_or("").trim_end(); if line.is_empty() { return Some("an empty reply".into()); } let mut said: String = line.chars().take(80).collect(); if said.len() < line.len() { said.push('…'); } Some(format!("\"{said}\"")) } /// The feed a web page names as its own via `<link rel="alternate" type="application/rss+xml" /// href="...">` (or the Atom equivalent) -- how the new address was found for om.co, ms.now, /// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos. fn alternate_feed_link(bytes: &[u8]) -> Option<String> { let text = String::from_utf8_lossy(bytes); let lower = text.to_lowercase(); let mut pos = 0; while let Some(rel) = lower[pos..].find("<link") { let start = pos + rel; let Some(end) = lower[start..].find('>').map(|e| start + e) else { break }; pos = end + 1; let tag = &text[start..end]; let tag_lower = &lower[start..end]; let is_alternate = tag_lower.contains("rel=\"alternate\"") || tag_lower.contains("rel='alternate'"); let is_feed_type = tag_lower.contains("rss+xml") || tag_lower.contains("atom+xml"); if is_alternate && is_feed_type && let Some(href) = tag_attr(tag, "href") { return Some(href); } } None } /// The value of one attribute in an HTML/XML start tag, however it is quoted. fn tag_attr(tag: &str, name: &str) -> Option<String> { let key = format!("{name}="); let idx = tag.to_lowercase().find(&key)?; let after = &tag[idx + key.len()..]; let quote = after.chars().next()?; if quote != '"' && quote != '\'' { return None; } let rest = &after[1..]; let close = rest.find(quote)?; Some(rest[..close].trim().to_owned()) } /// Escapes every `&` that does not already start a recognized XML entity /// (`&`, `<`, `>`, `"`, `'`, or a numeric reference like `'`). fn escape_bare_ampersands(bytes: &[u8]) -> Vec<u8> { fn is_entity_start(rest: &[u8]) -> bool { for named in [&b"amp;"[..], b"lt;", b"gt;", b"quot;", b"apos;"] { if rest.starts_with(named) { return true; } } let digits = if rest.starts_with(b"#x") || rest.starts_with(b"#X") { &rest[2..] } else if rest.starts_with(b"#") { &rest[1..] } else { return false; }; let len = digits.iter().take_while(|b| b.is_ascii_alphanumeric()).count(); len > 0 && digits.get(len) == Some(&b';') } let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'&' && !is_entity_start(&bytes[i + 1..]) { out.extend_from_slice(b"&"); } else { out.push(bytes[i]); } i += 1; } out } /// Every `<enclosure>` of every `<item>`, in document order. /// /// The `rss` crate models an item as having at most one enclosure -- which is what RSS 2.0 /// says -- and when a feed carries several it keeps only the *last*, silently losing the /// rest. Feeds do ship several, so read them from the XML directly. fn enclosures_by_item(bytes: &[u8]) -> Vec<Vec<Enclosure>> { use quick_xml::events::Event; let mut reader = quick_xml::Reader::from_reader(bytes); reader.config_mut().trim_text(true); let mut buf = Vec::new(); let mut out: Vec<Vec<Enclosure>> = Vec::new(); let mut current: Option<Vec<Enclosure>> = None; let read_enclosure = |e: &quick_xml::events::BytesStart| -> Option<Enclosure> { let (mut url, mut mime, mut length) = (String::new(), None, None); for attr in e.attributes().flatten() { // Values arrive escaped: a feed URL's "&" is "&" in the document. let val = quick_xml::escape::unescape(&attr.value) .map(|v| v.trim().to_string()) .unwrap_or_default(); match attr.key.local_name().as_ref() { "url" => url = val, "type" => mime = Some(val).filter(|v| !v.is_empty()), "length" => length = val.parse().ok(), _ => {} } } (!url.is_empty()).then_some(Enclosure { url, mime, length }) }; loop { match reader.read_event_into(&mut buf) { Ok(Event::Start(e)) => match e.name().local_name().as_ref() { "item" => current = Some(Vec::new()), "enclosure" => { if let (Some(list), Some(enc)) = (current.as_mut(), read_enclosure(&e)) { list.push(enc); } } _ => {} }, Ok(Event::Empty(e)) => match e.name().local_name().as_ref() { // <item/> with no children still counts, so the indexes stay aligned. "item" => out.push(Vec::new()), "enclosure" => { if let (Some(list), Some(enc)) = (current.as_mut(), read_enclosure(&e)) { list.push(enc); } } _ => {} }, Ok(Event::End(e)) => { if e.name().local_name().as_ref() == "item" && let Some(list) = current.take() { out.push(list); } } Ok(Event::Eof) | Err(_) => break, _ => {} } buf.clear(); } if let Some(list) = current.take() { out.push(list); } out } fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed { let per_item = enclosures_by_item(bytes); let explicit = ch .itunes_ext() .and_then(|it| it.explicit()) .is_some_and(is_yes); let entries = ch .items() .iter() .enumerate() .filter_map(|(idx, item)| { // Straight from the XML, so an item with several keeps all of them. Falls // back to the parsed one if the scan and the parser disagree on item count. let enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| { item.enclosure() .into_iter() .map(|e| Enclosure { url: e.url().trim().to_owned(), mime: non_empty(Some(e.mime_type())), length: e.length().parse().ok(), }) .filter(|e| !e.url.is_empty()) .collect() }); let guid = pick_guid( item.guid().map(|g| g.value()), item.link(), enclosures.first().map(|e| e.url.as_str()), item.title(), )?; let entry_explicit = item .itunes_ext() .and_then(|it| it.explicit()) .is_some_and(is_yes); let it = item.itunes_ext(); Some(Entry { guid, title: title_text(item.title()), link: non_empty(item.link()), published: item.pub_date().and_then(parse_date), // Content wins over description, as __getEntries preferred entry.content. description: body(item.content(), item.description()), categories: item .categories() .iter() .map(|c| c.name().to_owned()) .filter(|c| !c.is_empty() && !c.starts_with("http")) .collect(), explicit: explicit || entry_explicit, image: item_image(item, &enclosures), duration: it.and_then(|i| i.duration()).and_then(parse_duration), episode: it.and_then(|i| i.episode()).and_then(|e| e.trim().parse().ok()), season: it.and_then(|i| i.season()).and_then(|e| e.trim().parse().ok()), enclosures, }) }) .collect(); ParsedFeed { title: title_text(Some(ch.title())), ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()), // itunes:image is the square artwork; <image><url> is the older, often smaller one. image: ch .itunes_ext() .and_then(|i| i.image()) .map(str::to_owned) .or_else(|| ch.image().map(|i| i.url().to_owned())), // Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and // would fill the Directory with one-off tags. The subcategory where there is one: Apple // files every tabletop and gaming show under Leisure, which says little; Games says it. category: ch .itunes_ext() .and_then(|i| i.categories().first()) .map(|c| c.subcategory().filter(|s| !s.text().trim().is_empty()).unwrap_or(c)) .and_then(|c| non_empty(Some(c.text().trim()))), entries, } } fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed { let entries = feed .entries() .iter() .filter_map(|e| { // Atom carries enclosures as <link rel="enclosure">. let enclosures: Vec<Enclosure> = e .links() .iter() .filter(|l| l.rel() == "enclosure") .map(|l| Enclosure { url: l.href().trim().to_owned(), mime: non_empty(l.mime_type()), length: l.length().and_then(|s| s.parse().ok()), }) .filter(|e| !e.url.is_empty()) .collect(); let alt = e .links() .iter() .find(|l| l.rel() == "alternate" || l.rel().is_empty()) .map(|l| l.href()); let guid = pick_guid( Some(e.id()), alt, enclosures.first().map(|x| x.url.as_str()), Some(e.title().as_str()), )?; Some(Entry { guid, title: title_text(Some(e.title().as_str())), link: alt.map(str::to_owned), published: e.published().or(Some(e.updated())).map(|d| d.timestamp()), description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())), categories: e.categories().iter().map(|c| c.term().to_owned()).collect(), explicit: false, image: None, duration: None, episode: None, season: None, enclosures, }) }) .collect(); ParsedFeed { title: title_text(Some(feed.title().as_str())), ttl_mins: None, image: feed.logo().or_else(|| feed.icon()).map(str::to_owned), category: None, entries, } } /// The original fell back to hashing the title or description. A guid, permalink or /// enclosure URL is a stable identifier already, so no hashing is needed; an entry with /// none of them has nothing to download and is dropped. fn pick_guid( guid: Option<&str>, link: Option<&str>, enclosure: Option<&str>, title: Option<&str>, ) -> Option<String> { [guid, link, enclosure, title] .into_iter() .flatten() .map(str::trim) .find(|s| !s.is_empty()) .map(str::to_owned) } fn is_yes(s: &str) -> bool { matches!(s.trim().to_ascii_lowercase().as_str(), "yes" | "true" | "explicit") } fn non_empty(s: Option<&str>) -> Option<String> { s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned) } /// A title as plain text. An Atom title of `type="html"`, or an RSS one in CDATA, comes through /// the XML parser with its HTML entities intact: The Verge's "Meta’s" reached the page as /// typed. Decoded one entity at a time, so an `&` that starts none, as in "Q&A", stays as it is /// instead of failing the whole title. fn title_text(s: Option<&str>) -> Option<String> { let s = non_empty(s)?; let mut out = String::with_capacity(s.len()); let mut rest = s.as_str(); while let Some(at) = rest.find('&') { out.push_str(&rest[..at]); rest = &rest[at..]; let len = 1 + rest[1..] .find(|c: char| !(c.is_ascii_alphanumeric() || c == '#')) .unwrap_or(rest.len() - 1); let decoded = rest[len..] .starts_with(';') .then(|| quick_xml::escape::unescape_with(&rest[..=len], quick_xml::escape::resolve_html5_entity).ok()) .flatten(); match decoded { Some(v) => { out.push_str(&v); rest = &rest[len + 1..]; } None => { out.push('&'); rest = &rest[1..]; } } } out.push_str(rest); non_empty(Some(&out)) } /// An item's show notes: its full body when that is whole, else its description. /// /// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name /// pasted from a web app (`[&:has([data-writing-block])>*]:pointer-events-auto`), so the body /// began halfway through a tag and the page showed the rest of the tag as text. The same item's /// `description` was whole. With no description to fall back on, a damaged body beats none. fn body(content: Option<&str>, description: Option<&str>) -> Option<String> { non_empty(content) .filter(|c| !starts_mid_tag(c)) .or_else(|| non_empty(description)) .or_else(|| non_empty(content)) } /// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose /// start was cut off. fn starts_mid_tag(html: &str) -> bool { html[..html.find('<').unwrap_or(html.len())].contains("\">") } /// The picture to show beside an item, in order of how deliberate it is: /// `itunes:image`, then Media RSS `media:thumbnail`, then a `media:content` that is an /// image, and finally an image enclosure -- which is how a blog's article picture arrives /// (Substack puts it there), so those entries get artwork rather than a blank square. fn item_image(item: &rss::Item, enclosures: &[Enclosure]) -> Option<String> { if let Some(url) = item.itunes_ext().and_then(|i| i.image()) { return non_empty(Some(url)); } let media = item.extensions().get("media"); let attr = |name: &str, want_image: bool| -> Option<String> { media?.get(name)?.iter().find_map(|e| { if want_image { // media:content carries anything; only take it when it says it is a picture. let is_image = e.attrs.get("type").is_some_and(|t| t.starts_with("image/")) || e.attrs.get("medium").is_some_and(|m| m == "image"); if !is_image { return None; } } non_empty(e.attrs.get("url").map(String::as_str)) }) }; attr("thumbnail", false) .or_else(|| attr("content", true)) .or_else(|| { enclosures .iter() .find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("image/"))) .map(|e| e.url.clone()) }) } /// itunes:duration is either plain seconds ("5649") or a clock ("1:34:09", "23:45"). fn parse_duration(s: &str) -> Option<i64> { let s = s.trim(); if s.is_empty() { return None; } if !s.contains(':') { return s.parse().ok().filter(|n| *n > 0); } let mut total: i64 = 0; for part in s.split(':') { total = total * 60 + part.trim().parse::<i64>().ok()?; } Some(total).filter(|n| *n > 0) } /// RSS pubDate is RFC 2822; some feeds ship RFC 3339 instead. fn parse_date(s: &str) -> Option<i64> { let s = s.trim(); chrono::DateTime::parse_from_rfc2822(s) .or_else(|_| chrono::DateTime::parse_from_rfc3339(s)) .ok() .map(|d| d.timestamp()) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_rss_with_itunes_extensions() { let bytes = include_bytes!("../tests/data/rss2.xml"); let feed = parse(bytes).unwrap(); assert_eq!(feed.title.as_deref(), Some("Test Cast")); assert_eq!(feed.ttl_mins, Some(45)); assert_eq!(feed.category.as_deref(), Some("Podcasting"), "the first, by its subcategory"); assert_eq!(feed.entries.len(), 3); let ep = &feed.entries[0]; assert_eq!(ep.guid, "https://example.com/ep/1"); assert_eq!(ep.title.as_deref(), Some("Episode One")); assert_eq!(ep.published, Some(1_078_016_400)); assert_eq!(ep.categories, vec!["Tech"]); assert!(!ep.explicit); assert_eq!( ep.enclosures, vec![Enclosure { url: "https://example.com/ep1.mp3".into(), mime: Some("audio/mpeg".into()), length: Some(12_345_678), }] ); assert!(feed.entries[1].explicit, "entry-level itunes:explicit=yes"); assert_eq!( feed.entries[2].enclosures[0].mime.as_deref(), Some("application/x-bittorrent") ); } #[test] fn titles_are_read_as_text_not_html() { // The Verge: an Atom title of type="html", its entity inside CDATA. let xml = br#"<?xml version="1.0"?> <feed xmlns="http://www.w3.org/2005/Atom"><title type="text">Vv 2026-09-15T00:00:00Z <![CDATA[Meta’s new One]]>e1 2026-09-15T00:00:00Z"#; assert_eq!(parse(xml).unwrap().entries[0].title.as_deref(), Some("Meta\u{2019}s new One")); // HTML names as well as numbers; a bare `&` and an unknown name are left as they are. assert_eq!( title_text(Some("Peña & “Q&A” &bogus; AT&T;")).as_deref(), Some("Pe\u{f1}a & \u{201c}Q&A\u{201d} &bogus; AT&T;") ); } #[test] fn a_body_cut_off_mid_tag_gives_way_to_the_description() { // How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag. let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant">

What if

"#; let whole = r#"

What if

"#; assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole)); assert_eq!(body(Some("

Notes

"), Some("Summary")).as_deref(), Some("

Notes

"), "a whole body wins"); assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags.")); assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none"); assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary")); } #[test] fn feed_level_explicit_overrides_entries() { let xml = br#" Xhttps://xd yes ag1 "#; let feed = parse(xml).unwrap(); assert!( feed.entries[0].explicit, "the entry says nothing; the feed-level flag must still mark it explicit" ); } #[test] fn explain_failure_translates_the_errors_the_ui_should_flag() { assert_eq!( explain_failure("HTTP 404 Not Found").unwrap().reason, "The publisher took this feed down, or moved it." ); assert_eq!(explain_failure("HTTP 401 Unauthorized").unwrap().reason, "The site refuses ipx's requests."); assert_eq!(explain_failure("HTTP 403 Forbidden").unwrap().reason, "The site refuses ipx's requests."); assert_eq!(explain_failure("HTTP 402 Payment Required").unwrap().reason, "The feed now needs a paid plan."); let dns = explain_failure("connecting: dns error: failed to lookup address information").unwrap(); assert_eq!(dns.reason, "This address no longer resolves; the site is gone."); let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap(); assert_eq!(moved.new_url.as_deref(), Some("https://x/feed")); assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none()); let down = explain_failure("the site sent \"Unable to establish a DB connection\" instead of a feed").unwrap(); assert_eq!(down.reason, "The site sent a message instead of the feed; the publisher has to fix it."); for transient in [ "HTTP 500 Internal Server Error", "HTTP 429 Too Many Requests", "operation timed out", "not RSS (reached end of input without finding a complete channel) and not Atom (unexpected end of input)", ] { assert!(explain_failure(transient).is_none(), "{transient} must not be flagged"); } } #[test] fn a_web_page_says_so_and_names_the_feed_it_links() { let html = br#" not a feed"#; let err = parse(html).unwrap_err().to_string(); assert_eq!(err, "got a web page, not a feed; it links https://x.example/feed as its feed"); } #[test] fn a_web_page_with_no_feed_link_still_says_so() { let html = b"moved"; assert_eq!(parse(html).unwrap_err().to_string(), "got a web page, not a feed"); } #[test] fn malformed_xml_gets_the_original_parser_errors() { let err = parse(b"cut off").unwrap_err().to_string(); assert!(err.starts_with("not RSS ("), "{err}"); } #[test] fn a_body_with_no_markup_says_what_the_site_sent() { let err = parse(b"\xef\xbb\xbf\r\n Unable to establish a DB connection\nmore").unwrap_err().to_string(); assert_eq!(err, "the site sent \"Unable to establish a DB connection\" instead of a feed"); let long = parse("x".repeat(200).as_bytes()).unwrap_err().to_string(); assert_eq!(long, format!("the site sent \"{}…\" instead of a feed", "x".repeat(80))); assert_eq!(parse(b" \n").unwrap_err().to_string(), "the site sent an empty reply instead of a feed"); } #[test] fn parses_atom_enclosure_links() { let bytes = include_bytes!("../tests/data/atom.xml"); let feed = parse(bytes).unwrap(); assert_eq!(feed.title.as_deref(), Some("Atom Cast")); assert_eq!(feed.entries.len(), 1); let ep = &feed.entries[0]; assert_eq!(ep.guid, "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a"); assert_eq!(ep.link.as_deref(), Some("https://example.org/ep/1")); assert_eq!(ep.published, Some(1_078_016_400)); assert_eq!( ep.enclosures, vec![Enclosure { url: "https://example.org/ep1.m4a".into(), mime: Some("audio/mp4".into()), length: Some(9_876_543), }], "rel=enclosure only; the alternate link must not become an enclosure" ); } #[test] fn a_bare_ampersand_in_a_link_is_repaired_and_parsed() { // kcpw.org: <link>https://kcpw.org/?post_type=post&p=125715</link> -- a bare "&" // that strict XML rejects but browsers accept. let xml = br#"<?xml version="1.0"?> <rss version="2.0"><channel><title>Xhttps://xd ag1 https://kcpw.org/?post_type=post&p=125715 "#; let feed = parse(xml).unwrap(); assert_eq!(feed.entries[0].link.as_deref(), Some("https://kcpw.org/?post_type=post&p=125715")); assert_eq!(feed.entries[0].enclosures[0].url, "https://x/a.mp3?a=1&b=2"); } #[test] fn escape_bare_ampersands_leaves_real_entities_alone() { let out = escape_bare_ampersands(b"a&b <x> ' / c&d"); assert_eq!(out, b"a&b <x> ' / c&d"); } #[test] fn the_rss_title_always_wins_and_episode_numbers_stay_metadata() { // Some feeds set a different itunes:title. The displayed title is always the RSS // , verbatim -- separators and all -- and season/episode are stored // alongside it rather than folded into it. let xml = br#"<?xml version="1.0"?> <rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"> <channel><title>Showhttps://xd Music from a Darkened Room | Session Zero Session Zero sz 8 6720 Music from a Darkened Room Part 1 | Murphy's Drawer p1 81 "#; let feed = parse(xml).unwrap(); let sz = &feed.entries[0]; assert_eq!( sz.title.as_deref(), Some("Music from a Darkened Room | Session Zero"), "itunes:title must not override the RSS title" ); assert_eq!(sz.season, Some(8)); assert_eq!(sz.episode, None, "a missing episode number stays missing"); assert_eq!(sz.duration, Some(6720)); let p1 = &feed.entries[1]; assert_eq!(p1.title.as_deref(), Some("Music from a Darkened Room Part 1 | Murphy's Drawer")); assert_eq!((p1.season, p1.episode), (Some(8), Some(1))); } #[test] fn opml_is_recognised_and_its_feeds_listed() { let xml = br#"My Subscriptions "#; assert!(is_opml(xml)); assert_eq!(opml_title(xml).as_deref(), Some("My Subscriptions")); let feeds = parse_opml(xml).unwrap(); assert_eq!(feeds.len(), 2, "nested folders are walked, non-feed outlines skipped"); assert_eq!(feeds[0], ("Alpha".into(), "https://a.example/rss".into())); // A feed must never be mistaken for a subscription list. assert!(!is_opml(include_bytes!("../tests/data/rss2.xml"))); assert!(!is_opml(include_bytes!("../tests/data/atom.xml"))); } #[test] fn a_patreon_creator_is_a_list_of_its_shows() { let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9"; assert_eq!(expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}")); assert_eq!(expand_input("https://example.com/rss"), "https://example.com/rss"); assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}"))); assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}"))); assert!(!is_patreon_creator(&format!("https://www.patreon.com/rss/x?auth={tok}&show=1")), "one show is a feed"); assert!(!is_patreon_creator(&format!("https://example.com/rss?auth={tok}"))); // The show you already have by name is the one a bare token would add by number. assert!(same_feed( &format!("https://www.patreon.com/rss/glasscannon?auth={tok}&show=2073588"), &format!("https://www.patreon.com/rss?auth={tok}&show=2073588"), )); assert!(!same_feed( &format!("https://www.patreon.com/rss?auth={tok}&show=1"), &format!("https://www.patreon.com/rss?auth={tok}&show=2"), )); // The self link carries the campaign by number, whichever spelling was asked for. let head = br#"https://www.patreon.com/glasscannon "#; assert_eq!(patreon_campaign(head).as_deref(), Some("369921")); assert_eq!(patreon_campaign(b"T"), None); let json = br#"{"data":{"id":"369921","type":"campaign","attributes":{"name":"The Glass Cannon Network"}, "relationships":{"shows":{"data":[{"id":"2073588","type":"collection"},{"id":"2073636","type":"collection"}]}}}, "included":[{"id":"2073588","type":"collection","attributes":{"title":"Get in the Trunk "}}, {"id":"2073636","type":"collection","attributes":{"title":"Shadowdark"}}]}"#; let (name, shows) = parse_patreon_shows(json).unwrap(); assert_eq!(name.as_deref(), Some("The Glass Cannon Network")); assert_eq!(shows, [("2073588".into(), "Get in the Trunk".into()), ("2073636".into(), "Shadowdark".into())]); // An answer that stops naming the shows is an error, never "this creator has none". assert!(parse_patreon_shows(br#"{"data":{"attributes":{"name":"X"}}}"#).is_err()); } #[test] fn an_item_may_carry_several_enclosures() { // The rss crate keeps only one per item -- the last -- so these come from the XML. let xml = br#"<?xml version="1.0"?> <rss version="2.0"><channel><title>Mhttps://xd Two filesm1 One filem2 Nonem3 "#; let f = parse(xml).unwrap(); assert_eq!(f.entries.len(), 3); let two = &f.entries[0].enclosures; assert_eq!(two.len(), 2, "both enclosures survive"); assert_eq!( two[0].url, "https://x/a.mp3?v=1&t=2", "document order, and the escaped ampersand is decoded" ); assert_eq!(two[0].length, Some(111)); assert_eq!(two[1].url, "https://x/b.mp4"); assert_eq!(two[1].mime.as_deref(), Some("video/mp4")); assert_eq!(f.entries[1].enclosures.len(), 1); assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none"); } #[test] fn an_items_picture_comes_from_the_most_deliberate_source() { let xml = br#" Phttps://xd Has itunesa Has thumbnailb Has media contentc Only an image enclosured Audio onlye "#; let f = parse(xml).unwrap(); let img = |i: usize| f.entries[i].image.as_deref(); assert_eq!(img(0), Some("https://x/itunes.jpg"), "itunes:image wins"); assert_eq!(img(1), Some("https://x/thumb.jpg"), "then media:thumbnail"); assert_eq!(img(2), Some("https://x/pic.jpg"), "media:content, and only the image one"); assert_eq!(img(3), Some("https://x/d.jpg"), "a blog's article picture arrives as an enclosure"); assert_eq!(img(4), None, "audio is not a picture"); } #[test] fn durations_parse_from_seconds_or_a_clock() { assert_eq!(parse_duration("5649"), Some(5649)); assert_eq!(parse_duration("23:45"), Some(1425)); assert_eq!(parse_duration("1:34:09"), Some(5649)); assert_eq!(parse_duration("0"), None, "zero is not a duration"); assert_eq!(parse_duration(""), None); assert_eq!(parse_duration("garbage"), None); } #[test] fn rejects_html_masquerading_as_a_feed() { assert!(parse(b"nope").is_err()); } #[test] fn guid_falls_back_through_link_then_enclosure() { assert_eq!(pick_guid(Some(" "), Some("l"), Some("e"), None).as_deref(), Some("l")); assert_eq!(pick_guid(None, None, Some("e"), Some("t")).as_deref(), Some("e")); assert_eq!(pick_guid(None, None, None, None), None); } }