From b8d22904f14ceabbd5195250d163c50e42281fa0 Mon Sep 17 00:00:00 2001 From: rays Date: Tue, 15 Sep 2026 16:46:47 +0000 Subject: [PATCH] Read a title's HTML entities as the characters they stand for An Atom title of type="html", and an RSS title in CDATA, reach the parser with their entities intact, so The Verge's "Meta’s" showed as typed: 55 stored titles across 17 feeds. Titles are decoded one entity at a time with quick-xml's HTML5 table, leaving an & that starts none ("Q&A") alone rather than failing the whole title. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 ++++++ Cargo.toml | 2 +- src/feed.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb28db..2a8574e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ The long form, with what was wrong before and how it was found, is in - The first scan after upgrading fetches every feed in full once, on its usual schedule, so each picks up its category without waiting for the publisher to change something. +### Fixed + +- Titles that arrive as HTML, such as The Verge's, no longer show their entities as text: + "Meta’s" reads "Meta’s". Titles already stored are corrected the next time their feed + changes. + ## [0.5.5] - 2026-09-14 ### Changed diff --git a/Cargo.toml b/Cargo.toml index d6d65c0..51ad437 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ futures-util = { version = "0.3.34", default-features = false, features = ["std" librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] } opml = "1.1.6" percent-encoding = "2.3.2" -quick-xml = "0.42.0" +quick-xml = { version = "0.42.0", features = ["escape-html"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] } rss = "2.1.1" rusqlite = { version = "0.40.2", features = ["bundled"] } diff --git a/src/feed.rs b/src/feed.rs index 9ac5fde..cd8c754 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -507,7 +507,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed { Some(Entry { guid, - title: non_empty(item.title()), + 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. @@ -529,7 +529,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed { .collect(); ParsedFeed { - title: non_empty(Some(ch.title())), + title: title_text(Some(ch.title())), ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()), // itunes:image is the square artwork; is the older, often smaller one. image: ch @@ -582,7 +582,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed { Some(Entry { guid, - title: non_empty(Some(e.title().as_str())), + 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())), @@ -598,7 +598,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed { .collect(); ParsedFeed { - title: non_empty(Some(feed.title().as_str())), + title: title_text(Some(feed.title().as_str())), ttl_mins: None, image: feed.logo().or_else(|| feed.icon()).map(str::to_owned), category: None, @@ -631,6 +631,39 @@ fn non_empty(s: Option<&str>) -> Option { 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 { + 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 @@ -744,6 +777,22 @@ mod tests { ); } + #[test] + fn titles_are_read_as_text_not_html() { + // The Verge: an Atom title of type="html", its entity inside CDATA. + let xml = br#" + 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.