= Vec::new();
for p in picks {
if !seen.contains(&p.section) {
seen.push(p.section.clone());
}
}
seen
};
let issue = Issue {
meta: IssueMeta {
date: "2026-08-16".parse().unwrap(),
issue_number: 0,
generated_at: jiff::Timestamp::now(),
display_date: "Image audit rebuild".into(),
article_count: picks.len() as i64,
section_count: sections.len() as i64,
total_words: picks.iter().map(|p| p.article.word_count).sum(),
reading_minutes: picks.iter().map(|p| p.article.reading_minutes()).sum(),
},
lineup: Lineup {
date: "2026-08-16".parse().unwrap(),
picks: picks.to_vec(),
section_order: sections,
},
editorial: Editorial {
front_page_html: "Rebuilt from published issues by \
examples/image_audit.rs to check image handling. \
Articles are re-extracted live; editorial, discussions and the \
world briefing are absent by design.
"
.into(),
section_intros: Default::default(),
summaries: Default::default(),
},
world_briefing: None,
colophon: Colophon::default(),
};
let cfg = daily_epub::config::Config::default();
match daily_epub::epub::build_edition_with_images(
&issue,
daily_epub::types::Edition::Standard,
&cfg,
out_dir,
assets,
) {
Ok(artifact) => println!(
"\nwrote {} ({:.1} MB, {} images)",
artifact.path.display(),
artifact.bytes as f64 / 1_048_576.0,
assets.len()
),
Err(e) => eprintln!("epub build failed: {e}"),
}
}
/// Pull `(entry id, title, url)` out of every article chapter in an EPUB.
fn targets_from_epub(path: &Path) -> Vec {
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 = (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::().ok())
else {
continue;
};
let Some(url) = between(&xhtml, r#"class="read-online">", '<').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 {
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("&", "&")
.replace("'", "'")
.replace(""", "\"")
.replace("<", "<")
.replace(">", ">")
}
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::() + "…"
}
/// 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
}