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 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 16:46:47 +00:00
parent cbd16ce3f6
commit b8d22904f1
3 changed files with 60 additions and 5 deletions

View File

@@ -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 - 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. 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&#8217;s" reads "Metas". Titles already stored are corrected the next time their feed
changes.
## [0.5.5] - 2026-09-14 ## [0.5.5] - 2026-09-14
### Changed ### Changed

View File

@@ -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"] } librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
opml = "1.1.6" opml = "1.1.6"
percent-encoding = "2.3.2" 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"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
rss = "2.1.1" rss = "2.1.1"
rusqlite = { version = "0.40.2", features = ["bundled"] } rusqlite = { version = "0.40.2", features = ["bundled"] }

View File

@@ -507,7 +507,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
Some(Entry { Some(Entry {
guid, guid,
title: non_empty(item.title()), title: title_text(item.title()),
link: non_empty(item.link()), link: non_empty(item.link()),
published: item.pub_date().and_then(parse_date), published: item.pub_date().and_then(parse_date),
// Content wins over description, as __getEntries preferred entry.content. // Content wins over description, as __getEntries preferred entry.content.
@@ -529,7 +529,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
.collect(); .collect();
ParsedFeed { ParsedFeed {
title: non_empty(Some(ch.title())), title: title_text(Some(ch.title())),
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()), 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. // itunes:image is the square artwork; <image><url> is the older, often smaller one.
image: ch image: ch
@@ -582,7 +582,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
Some(Entry { Some(Entry {
guid, guid,
title: non_empty(Some(e.title().as_str())), title: title_text(Some(e.title().as_str())),
link: alt.map(str::to_owned), link: alt.map(str::to_owned),
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()), 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())), 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(); .collect();
ParsedFeed { ParsedFeed {
title: non_empty(Some(feed.title().as_str())), title: title_text(Some(feed.title().as_str())),
ttl_mins: None, ttl_mins: None,
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned), image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
category: None, category: None,
@@ -631,6 +631,39 @@ fn non_empty(s: Option<&str>) -> Option<String> {
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned) 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&#8217;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. /// 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 /// 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#"<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom"><title type="text">V</title><id>v</id>
<updated>2026-09-15T00:00:00Z</updated>
<entry><title type="html"><![CDATA[Meta&#8217;s new One]]></title><id>e1</id>
<updated>2026-09-15T00:00:00Z</updated></entry></feed>"#;
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&ntilde;a &amp; &#x201C;Q&A&#8221; &bogus; AT&T;")).as_deref(),
Some("Pe\u{f1}a & \u{201c}Q&A\u{201d} &bogus; AT&T;")
);
}
#[test] #[test]
fn a_body_cut_off_mid_tag_gives_way_to_the_description() { 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. // How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag.