Fix the six ways article images went missing

Across the first three issues, 145 of 208 referenced images reached the
page; 38 that had been downloaded, re-encoded and written into the EPUB
were never referenced by any chapter, and 69 became `[image: …]` lines.
Six independent causes, each verified against the real articles:

- **Entity-encoded URLs never matched their asset.** `prepare_body`
  re-runs `ammonia::clean`, which writes `&` as `&`, and the tag
  scanner that looks the asset up compared raw attribute text against a
  URL a real parser had decoded. Every image with a query string lost.
  `parse_attrs` now decodes entities.
- **Readability clobbers a working `src`.** Its lazy-image heuristic
  copies any attribute containing `.jpg` over `src`, which on NPR meant
  a `{width}` template (the CDN answers those with a grey square reading
  "Image"), on Substack a JSON blob, on dfarq an entire `srcset` string.
  Pages are now normalized before readability sees them: every `<img>`
  is reduced to `src`/`alt`/`title` with the best candidate from the
  lazy attributes, `src`, `srcset` and `<picture><source>`, so there is
  nothing left for the heuristic to substitute. Candidates that cannot
  resolve — braces, whitespace, quotes, `data:` — are rejected by shape
  rather than by publisher.
- **Readability deletes `<button>` and its subtree**, taking lightbox
  images with it and leaving the captions behind. Image-only wrappers
  are unwrapped first.
- **SVG was undecodable**, so vector charts became placeholders. They
  are rasterized with resvg, which the cover already depends on.
- **Relative URLs resolved against the pre-redirect URL**, 404ing every
  image on an article reached through a shortener. `fetch_readable` now
  reports where it landed.
- **The 12-image cap** silently truncated photo essays, and the images
  past it were advertised as failures. Removed; the issue-wide byte
  budget is the real backstop.

An image that still cannot be embedded is now dropped rather than
announced, unless its alt text is a real description — decorative rules,
spacers and dead links were generating most of the placeholder noise.

`examples/image_audit.rs` replays the pipeline over the articles of
published issues and reports what reaches the page. On issues 1–3:
208→214 images referenced, 145→214 shown, 69→0 placeholders, 38→0
orphaned assets. The three images still not embedded are a 14×14 favicon
and a 650×2 divider — correctly declined, and no longer announced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 02:51:28 +00:00
co-authored by Claude Opus 5
parent 6ba3c3d525
commit 254eaeb713
5 changed files with 1351 additions and 42 deletions
+446
View File
@@ -0,0 +1,446 @@
//! Real-world image audit: replay the extraction + image pipeline over the
//! articles of already-published issues and report what reaches the page.
//!
//! Unit tests can only prove the code does what we think on markup we wrote.
//! This runs the same code over the pages the issues were actually built from,
//! so a regression in lazy-image handling, URL matching or re-encoding shows up
//! as a number rather than a hunch.
//!
//! ```text
//! cargo run --release --example image_audit -- \
//! --cache /tmp/pagecache ~/bookorbit/books/daily-epub/*.epub
//! ```
//!
//! Pages are cached on disk after the first run, so before/after comparisons
//! see byte-identical input. Articles are re-fetched and re-extracted through
//! readability even when the original issue took its body from Miniflux, so the
//! counts describe the fetch path, not that specific issue's history.
use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use daily_epub::epub::{build, images};
use daily_epub::extract::{self, Extractor};
use daily_epub::types::{Article, EntryId, ExtractMethod, ImageAsset, Pick, SourceKind, SourceRef};
/// One article we are auditing, pulled back out of a published EPUB.
#[derive(Debug, Clone)]
struct Target {
issue: String,
entry_id: EntryId,
title: String,
url: String,
}
/// What the pipeline did with one article's images.
#[derive(Debug, Default, Clone)]
struct Outcome {
/// `<img>` elements in the extracted body.
refs: usize,
/// Images that downloaded and re-encoded successfully.
embedded: usize,
/// `<img src="images/…">` in the rendered chapter.
rendered: usize,
/// `[image: …]` paragraphs in the rendered chapter.
placeholders: usize,
/// Reasons individual images did not make it, most specific first.
losses: Vec<String>,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "error".into()),
)
.init();
let mut cache = PathBuf::from("/tmp/daily-epub-audit-cache");
let mut dump: Option<String> = None;
let mut epubs: Vec<PathBuf> = Vec::new();
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--cache" => cache = PathBuf::from(args.next().expect("--cache needs a path")),
"--dump" => dump = Some(args.next().expect("--dump needs a title substring")),
other => epubs.push(PathBuf::from(other)),
}
}
if epubs.is_empty() {
eprintln!("usage: image_audit [--cache DIR] <issue.epub>...");
std::process::exit(2);
}
std::fs::create_dir_all(&cache).expect("cache dir");
// One entry per article: passing `*.epub` picks up both editions of every
// issue, and the same article twice would collide on its asset ids.
let mut seen: std::collections::HashSet<EntryId> = std::collections::HashSet::new();
let targets: Vec<Target> = epubs
.iter()
.flat_map(|p| targets_from_epub(p))
.filter(|t| seen.insert(t.entry_id))
.collect();
eprintln!(
"auditing {} articles from {} files",
targets.len(),
epubs.len()
);
let http = daily_epub::http::build_client(std::time::Duration::from_secs(20)).unwrap();
let extractor = Extractor::new(http.clone(), vec![]);
// Extract every article first, then run one issue-wide image pass, exactly
// as `epub::build_edition` does.
let mut picks: Vec<Pick> = Vec::new();
let mut skipped: Vec<(Target, String)> = Vec::new();
for target in &targets {
match body_for(&cache, &http, &extractor, target).await {
Ok(html) => picks.push(pick_for(target, html)),
Err(e) => skipped.push((target.clone(), e)),
}
}
let assets =
images::collect_for_issue(&http, &picks, daily_epub::types::Edition::Standard).await;
eprintln!("embedded {} images", assets.len());
let by_entry: BTreeMap<EntryId, Vec<&ImageAsset>> = assets.iter().fold(
BTreeMap::new(),
|mut acc: BTreeMap<EntryId, Vec<&ImageAsset>>, a| {
if let Some(id) = entry_of(&a.id) {
acc.entry(id).or_default().push(a);
}
acc
},
);
let mut totals = Outcome::default();
let mut rows: Vec<(Target, Outcome)> = Vec::new();
for (target, pick) in targets_of(&targets, &picks) {
let refs = images::extract_img_refs(&pick.article.content_html);
let embedded = by_entry.get(&target.entry_id).map_or(0, |v| v.len());
let body = build::prepare_body(&pick.article.content_html, &assets);
let rendered = body.matches("<img src=\"images/").count();
let placeholders = body.matches("image-placeholder").count();
let embedded_urls: Vec<&str> = by_entry
.get(&target.entry_id)
.map(|v| v.iter().map(|a| a.source_url.as_str()).collect())
.unwrap_or_default();
let losses = refs
.iter()
.filter(|r| !embedded_urls.contains(&r.src.as_str()))
.map(|r| classify(&r.src))
.collect::<Vec<_>>();
if dump.as_ref().is_some_and(|d| target.title.contains(d)) {
println!("\n== {} [{}]", target.title, target.url);
for r in &refs {
let state = if embedded_urls.contains(&r.src.as_str()) {
"ok "
} else {
"MISS"
};
println!(" {state} {}", r.src);
}
println!(" --- rendered body images:");
for cap in body.split("<img src=\"").skip(1) {
println!(" {}", cap.split('"').next().unwrap_or_default());
}
}
let outcome = Outcome {
refs: refs.len(),
embedded,
rendered,
placeholders,
losses,
};
totals.refs += outcome.refs;
totals.embedded += outcome.embedded;
totals.rendered += outcome.rendered;
totals.placeholders += outcome.placeholders;
totals.losses.extend(outcome.losses.iter().cloned());
rows.push((target.clone(), outcome));
}
println!(
"\n{:<12} {:>5} {:>5} {:>5} {:>5} article",
"issue", "refs", "emb", "shown", "ph"
);
println!("{}", "-".repeat(100));
for (t, o) in &rows {
let flag = if o.placeholders > 0 || o.rendered < o.refs {
"!"
} else {
" "
};
println!(
"{:<12} {:>5} {:>5} {:>5} {:>5} {} {} [{}]",
t.issue,
o.refs,
o.embedded,
o.rendered,
o.placeholders,
flag,
truncate(&t.title, 44),
host(&t.url),
);
for loss in &o.losses {
println!("{:>36} lost: {loss}", "");
}
}
println!("\n== totals");
println!(" articles {}", rows.len());
println!(" <img> in bodies {}", totals.refs);
println!(" embedded {}", totals.embedded);
println!(" shown in chapters {}", totals.rendered);
println!(" placeholders {}", totals.placeholders);
println!(
" orphaned assets {}",
totals.embedded.saturating_sub(totals.rendered)
);
let mut kinds: BTreeMap<String, usize> = BTreeMap::new();
for loss in &totals.losses {
*kinds.entry(loss.clone()).or_default() += 1;
}
println!("\n== loss reasons");
for (kind, n) in &kinds {
println!(" {n:>4} {kind}");
}
if !skipped.is_empty() {
println!("\n== unfetchable ({})", skipped.len());
for (t, e) in &skipped {
println!(" {}{e}", truncate(&t.title, 50));
}
}
}
/// Pair each target with the pick built from it (targets that failed to fetch
/// have no pick and are skipped).
fn targets_of<'a>(targets: &'a [Target], picks: &'a [Pick]) -> Vec<(&'a Target, &'a Pick)> {
picks
.iter()
.filter_map(|p| {
targets
.iter()
.find(|t| t.entry_id == p.article.best_entry_id)
.map(|t| (t, p))
})
.collect()
}
fn entry_of(asset_id: &str) -> Option<EntryId> {
asset_id
.strip_prefix("img-")?
.split('-')
.next()?
.parse()
.ok()
}
/// Why one `<img>` never became an embedded asset — a guess from its URL, good
/// enough to group the failures.
fn classify(src: &str) -> String {
let lower = src.to_ascii_lowercase();
if src.contains('{') || src.contains('}') {
format!("unresolved URL template — {}", truncate(src, 90))
} else if src.contains(' ') || src.contains("%20") {
format!("srcset blob in src — {}", truncate(src, 90))
} else if lower.contains(".svg") {
format!("svg — {}", truncate(src, 90))
} else if src.starts_with("data:") {
"data: URI (lazy placeholder)".to_string()
} else {
format!("download/decode failed — {}", truncate(src, 90))
}
}
/// Fetch + extract one article, using the on-disk page cache.
async fn body_for(
cache: &Path,
_http: &reqwest::Client,
extractor: &Extractor,
target: &Target,
) -> Result<String, String> {
let key = cache.join(format!("{:x}.html", seahash(&target.url)));
let url_key = key.with_extension("url");
if !key.exists() {
// `fetch_readable` does the fetching we want but returns readability's
// output; cache the raw page instead so extraction changes are visible.
let (raw, final_url) = raw_fetch(&target.url).await?;
std::fs::write(&key, raw).map_err(|e| e.to_string())?;
std::fs::write(&url_key, final_url).map_err(|e| e.to_string())?;
}
let bytes = std::fs::read(&key).map_err(|e| e.to_string())?;
if bytes.is_empty() {
return Err("empty page".into());
}
// Relative URLs belong to the page we landed on, not the one we asked for.
let base = std::fs::read_to_string(&url_key).unwrap_or_else(|_| target.url.clone());
let html = String::from_utf8_lossy(&bytes).into_owned();
let _ = extractor;
let readable = extract::readability(&html, &base).map_err(|e| e.to_string())?;
Ok(extract::sanitize_with_base(
&extract::normalize_img_tags(&readable),
&base,
))
}
/// A plain page fetch with the extractor's desktop UA and gzip handling,
/// returning the body and the URL the fetch landed on.
async fn raw_fetch(url: &str) -> Result<(Vec<u8>, String), String> {
let client = reqwest::Client::builder()
.user_agent(extract::DESKTOP_UA)
.gzip(true)
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(url)
.header(
reqwest::header::ACCEPT,
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let final_url = resp.url().to_string();
resp.bytes()
.await
.map(|b| (b.to_vec(), final_url))
.map_err(|e| e.to_string())
}
fn pick_for(target: &Target, content_html: String) -> Pick {
let word_count = extract::word_count(&content_html);
let image_urls = extract::collect_image_urls(&content_html, &target.url);
Pick {
article: Article {
id: target.entry_id,
canonical_url: target.url.clone(),
title: target.title.clone(),
best_entry_id: target.entry_id,
content_html,
word_count,
excerpt_only: false,
image_count: image_urls.len() as i64,
image_urls,
sources: vec![SourceRef {
entry_id: target.entry_id,
feed_id: 1,
feed_title: "Feed".into(),
category: None,
kind: SourceKind::Feed,
}],
first_seen: "2026-08-15T05:30:00Z".parse().unwrap(),
url: target.url.clone(),
author: None,
feed_id: 1,
feed_title: "Feed".into(),
category: None,
published_at: None,
comments_url: None,
social: vec![],
extract_method: ExtractMethod::Readability,
},
section: "Audit".into(),
position: 0,
is_lead: false,
summary: None,
llm: None,
discussion: None,
}
}
/// Pull `(entry id, title, url)` out of every article chapter in an EPUB.
fn targets_from_epub(path: &Path) -> Vec<Target> {
let issue = path
.file_stem()
.map(|s| s.to_string_lossy().replace("The Daily EPUB - ", ""))
.unwrap_or_default();
let file = std::fs::File::open(path).expect("open epub");
let mut zip = zip::ZipArchive::new(file).expect("read epub");
let names: Vec<String> = (0..zip.len())
.filter_map(|i| zip.by_index(i).ok().map(|f| f.name().to_string()))
.filter(|n| n.contains("art-") && n.ends_with(".xhtml"))
.collect();
let mut out = Vec::new();
for name in names {
let mut xhtml = String::new();
if zip
.by_name(&name)
.and_then(|mut f| f.read_to_string(&mut xhtml).map_err(Into::into))
.is_err()
{
continue;
}
let Some(entry_id) = name
.rsplit('/')
.next()
.and_then(|f| f.strip_prefix("art-"))
.and_then(|f| f.strip_suffix(".xhtml"))
.and_then(|f| f.parse::<EntryId>().ok())
else {
continue;
};
let Some(url) = between(&xhtml, r#"class="read-online"><a href=""#, '"') else {
continue;
};
let title = between(&xhtml, "<title>", '<').unwrap_or_else(|| "?".into());
out.push(Target {
issue: issue.clone(),
entry_id,
title: unescape(&title),
url: unescape(&url),
});
}
out
}
fn between(haystack: &str, prefix: &str, end: char) -> Option<String> {
let start = haystack.find(prefix)? + prefix.len();
let rest = &haystack[start..];
let stop = rest.find(end)?;
Some(rest[..stop].to_string())
}
fn unescape(s: &str) -> String {
s.replace("&amp;", "&")
.replace("&#39;", "'")
.replace("&quot;", "\"")
.replace("&lt;", "<")
.replace("&gt;", ">")
}
fn host(url: &str) -> String {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.unwrap_or_default()
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
s.chars().take(n.saturating_sub(1)).collect::<String>() + ""
}
/// Tiny stable hash for cache filenames.
fn seahash(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= u64::from(*b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}