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:
@@ -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’s" reads "Meta’s". 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
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
57
src/feed.rs
57
src/feed.rs
@@ -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’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’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ña & “Q&A” &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.
|
||||||
|
|||||||
Reference in New Issue
Block a user