Show notes cut off mid-tag give way to the item's description

libsyn served Daily Meditation Podcast's content:encoded cut at the '>'
inside a Tailwind class pasted from a web app, so 57 items began halfway
through a tag and the page showed the rest of it as text. Their
description was whole. A body that closes an attribute list before any
tag opens now falls back to the description, for RSS and Atom alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
This commit is contained in:
2026-09-12 13:29:00 +00:00
parent a958f7cb37
commit 1352f0d54d
2 changed files with 38 additions and 6 deletions

View File

@@ -17,6 +17,11 @@ The long form, with what was wrong before and how it was found, is in
- The database no longer records when accounts, subscriptions and sign-ins were created. Nothing - The database no longer records when accounts, subscriptions and sign-ins were created. Nothing
ever read it, and an existing database drops the columns on its next start. ever read it, and an existing database drops the columns on its next start.
### Fixed
- Show notes that the podcast's host cut off in the middle of a tag no longer open with a scrap of
HTML: the item's other copy of its notes is shown instead. Daily Meditation Podcast had 57.
## [0.5.1] - 2026-09-12 ## [0.5.1] - 2026-09-12
### Fixed ### Fixed

View File

@@ -340,7 +340,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
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.
description: non_empty(item.content()).or_else(|| non_empty(item.description())), description: body(item.content(), item.description()),
categories: item categories: item
.categories() .categories()
.iter() .iter()
@@ -406,11 +406,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
title: non_empty(Some(e.title().as_str())), title: non_empty(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: e description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
.content()
.and_then(|c| c.value())
.or_else(|| e.summary().map(|s| s.as_str()))
.map(str::to_owned),
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(), categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
explicit: false, explicit: false,
image: None, image: None,
@@ -455,6 +451,25 @@ 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)
} }
/// 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: /// 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 /// `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 /// image, and finally an image enclosure -- which is how a blog's article picture arrives
@@ -548,6 +563,18 @@ mod tests {
); );
} }
#[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"> <p>What if</p>"#;
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "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] #[test]
fn feed_level_explicit_overrides_entries() { fn feed_level_explicit_overrides_entries() {
let xml = br#"<?xml version="1.0"?> let xml = br#"<?xml version="1.0"?>