Files
the-daily-epub/src/dedupe.rs
T
thalladaandClaude Opus 5 db19d08257 Break up the image and EPUB god files
The image fixes left two problems of navigation. Image handling was
spread across `extract.rs` (300 lines of normalization) and
`epub/images.rs` (download, re-encode, markup rewriting, plus generic
HTML helpers that comments, world and x4 were all reaching into a
module named "images" to borrow). And `epub/build.rs` had grown to
1,148 lines of code covering cover rendering, ten askama templates,
every chapter renderer and the zip assembly.

New homes:

- `src/html.rs` — markup helpers that do not care what the markup is
  about: tag scanning, attribute parsing, entity decoding, escaping,
  XHTML fixups, reading a fragment as text. Previously scattered between
  `epub/images.rs` and `extract.rs`.
- `src/images/` — one module per stage of an article's images, in the
  order they run: `normalize` (make `<img>` usable, pre-readability),
  `refs` (what an article references), `fetch` + `encode` (download and
  re-encode per edition), `embed` (point the markup at what shipped).
- `src/epub/{cover,chapters,build}.rs` — the cover, the chapter
  renderers, and the ordering plus assembly that puts them together.
  `epub/fixtures.rs` takes the shared test issue, which was a public
  module wedged inside `build.rs`.
- `src/curate/profile/themes.rs` — a 260-line keyword table that sat in
  the middle of the profile logic.

`curate::html_to_text` is renamed `prompt_text`: it is a different
function from `html::html_to_text` (collapses whitespace, no DOM, sized
for prompt budgets) and sharing a name with it was a trap.

Largest module drops from 1,148 code lines to 828, and no file mixes
two subjects. Behaviour is unchanged: 231 lib tests plus 25 integration
tests green, and the real-world audit over issues 1–3 still reports 214
images referenced, 214 shown, 0 placeholders, 0 orphaned assets.

`image_audit` gains `--epub-out DIR`, which writes a readable EPUB of
the audited articles so images can be checked on a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 03:35:21 +00:00

656 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Normalization and duplicate clustering (spec §3.2).
//!
//! Canonicalizes URLs, clusters entries that tell the same story (HN frontpage feed
//! + Scour feed + the blog's own feed), and drops obvious non-articles.
use std::collections::HashMap;
use jiff::Timestamp;
use url::Url;
use crate::types::{Article, Entry, ExtractMethod, SourceKind, SourceRef};
/// Query parameters stripped during canonicalization (§3.2).
pub const TRACKING_PARAMS: &[&str] = &["ref", "fbclid", "gclid", "s", "si", "mc_cid", "mc_eid"];
/// URL hosts that are never articles (§3.2).
pub const NON_ARTICLE_HOSTS: &[&str] = &[
"youtube.com",
"www.youtube.com",
"youtu.be",
"vimeo.com",
"open.spotify.com",
"podcasts.apple.com",
];
/// Path suffixes that mark an audio/video enclosure rather than an article (§3.2).
const MEDIA_EXTENSIONS: &[&str] = &[
".mp3", ".m4a", ".m4v", ".mp4", ".ogg", ".oga", ".opus", ".wav", ".flac", ".aac", ".mov",
".webm", ".mkv",
];
/// How many redirector hops [`canonical_url`] will follow before giving up (§3.2).
const MAX_REDIRECT_DEPTH: u8 = 3;
/// Minimum length of a [`normalized_title`] before it may merge two clusters.
/// Short titles ("News", "Weekly") collide far too easily (§3.2 secondary pass).
const MIN_TITLE_KEY_LEN: usize = 12;
/// Canonicalize a URL: lowercase host, drop the fragment, strip tracking params
/// (`utm_*` and [`TRACKING_PARAMS`]), trim the trailing slash, and resolve known
/// redirectors such as Google News links to their target (§3.2).
///
/// Returns `None` when the input is not a parseable absolute http(s) URL.
pub fn canonical_url(raw: &str) -> Option<String> {
canonicalize(raw, 0)
}
fn canonicalize(raw: &str, depth: u8) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut url = Url::parse(trimmed).ok()?;
if !matches!(url.scheme(), "http" | "https") {
return None;
}
url.host_str()?;
// Known redirectors (Google News et al) carry the real article in a param.
if depth < MAX_REDIRECT_DEPTH
&& let Some(target) = redirect_target(&url)
&& let Some(resolved) = canonicalize(&target, depth + 1)
{
return Some(resolved);
}
url.set_fragment(None);
if let Some(host) = url.host_str() {
let lower = host.to_ascii_lowercase();
if lower != host {
url.set_host(Some(&lower)).ok()?;
}
}
let kept: Vec<(String, String)> = url
.query_pairs()
.filter(|(k, _)| !is_tracking_param(k))
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
if kept.is_empty() {
url.set_query(None);
} else {
let mut pairs = url.query_pairs_mut();
pairs.clear();
for (k, v) in &kept {
pairs.append_pair(k, v);
}
drop(pairs);
}
let path = url.path().to_string();
if path.len() > 1 && path.ends_with('/') {
url.set_path(path.trim_end_matches('/'));
}
let mut out = url.to_string();
if url.query().is_none() && out.ends_with('/') {
out.pop();
}
Some(out)
}
fn is_tracking_param(key: &str) -> bool {
let key = key.to_ascii_lowercase();
key.starts_with("utm_") || TRACKING_PARAMS.contains(&key.as_str())
}
/// The real destination behind a known redirector, if any (§3.2).
fn redirect_target(url: &Url) -> Option<String> {
let host = url.host_str()?.to_ascii_lowercase();
let is_google_news = host == "news.google.com" || host.ends_with(".news.google.com");
let is_google_redirect = host == "news.url.google.com"
|| ((host == "www.google.com" || host == "google.com") && url.path() == "/url");
if !(is_google_news || is_google_redirect) {
return None;
}
url.query_pairs()
.find(|(k, _)| k == "url" || k == "q")
.map(|(_, v)| v.into_owned())
.filter(|v| v.starts_with("http"))
}
/// Title normalized for the fuzzy second dedupe pass: lowercased, alphanumeric only (§3.2).
pub fn normalized_title(title: &str) -> String {
title
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect()
}
/// True for entries that are not articles at all: media-enclosure-only items,
/// [`NON_ARTICLE_HOSTS`], empty titles (§3.2).
pub fn is_non_article(entry: &Entry) -> bool {
if entry.title.trim().is_empty() {
return true;
}
let Some(url) = Url::parse(entry.url.trim()).ok().filter(|u| {
matches!(u.scheme(), "http" | "https") && u.host_str().is_some_and(|h| !h.is_empty())
}) else {
return true;
};
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
if NON_ARTICLE_HOSTS
.iter()
.any(|blocked| host == *blocked || host.ends_with(&format!(".{blocked}")))
{
return true;
}
let path = url.path().to_ascii_lowercase();
if MEDIA_EXTENSIONS.iter().any(|ext| path.ends_with(ext)) {
return true;
}
is_enclosure_only(&entry.raw_content)
}
/// Content that is nothing but an embedded player has no text worth reading (§3.2).
fn is_enclosure_only(raw_content: &str) -> bool {
let lower = raw_content.to_ascii_lowercase();
let embeds = lower.contains("<audio")
|| lower.contains("<video")
|| lower.contains("<embed")
|| lower.contains("<iframe");
embeds && crate::html::word_count(raw_content) < 25
}
/// Classify which kind of feed an entry arrived through, for the sources list (§3.2, §3.5).
pub fn classify_source(entry: &Entry) -> SourceKind {
classify_source_with_feed(entry, None)
}
/// [`classify_source`] with the feed's own URL/site URL when the caller has it.
///
/// The `entries` table does not store the feed URL, so the entry-only form falls
/// back to the feed title plus the entry/comments URLs (§3.2).
pub fn classify_source_with_feed(entry: &Entry, feed_url_or_site: Option<&str>) -> SourceKind {
let mut haystack = String::new();
if let Some(feed) = feed_url_or_site {
haystack.push_str(&feed.to_ascii_lowercase());
haystack.push(' ');
}
if let Some(title) = &entry.feed_title {
haystack.push_str(&title.to_ascii_lowercase());
haystack.push(' ');
}
if let Some(category) = &entry.category {
haystack.push_str(&category.to_ascii_lowercase());
haystack.push(' ');
}
let comments = entry
.comments_url
.as_deref()
.unwrap_or("")
.to_ascii_lowercase();
let url = entry.url.to_ascii_lowercase();
if haystack.contains("scour.ing") || haystack.contains("scour") {
return SourceKind::Scour;
}
if haystack.contains("hnrss")
|| haystack.contains("news.ycombinator")
|| haystack.contains("hacker news")
|| comments.contains("news.ycombinator.com")
{
return SourceKind::HnFrontpage;
}
if haystack.contains("lobste.rs")
|| haystack.contains("lobsters")
|| comments.contains("lobste.rs")
|| url.contains("lobste.rs/s/")
{
return SourceKind::Lobsters;
}
if haystack.contains("reddit.com")
|| haystack.contains("reddit")
|| comments.contains("reddit.com")
|| url.contains("reddit.com/r/")
{
return SourceKind::Reddit;
}
SourceKind::Feed
}
/// `feed_id → feed URL (or site URL)`, as built by [`crate::miniflux::feed_urls`].
///
/// Passing it into [`cluster_with_feeds`] is what makes "came via Scour" exact:
/// a Scour interest feed is only recognizable from its `feed_url`, and the
/// `entries` table does not store one (§3.2).
pub type FeedUrls = HashMap<crate::types::FeedId, String>;
/// Build a [`SourceRef`] describing how `entry` reached us.
pub fn source_ref(entry: &Entry) -> SourceRef {
source_ref_with_feeds(entry, &FeedUrls::new())
}
/// [`source_ref`] with the run's `feed_id → feed url` map for exact classification.
pub fn source_ref_with_feeds(entry: &Entry, feed_urls: &FeedUrls) -> SourceRef {
SourceRef {
entry_id: entry.id,
feed_id: entry.feed_id,
feed_title: entry
.feed_title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| format!("feed {}", entry.feed_id)),
category: entry.category.clone(),
kind: classify_source_with_feed(entry, feed_urls.get(&entry.feed_id).map(String::as_str)),
}
}
/// Outcome of the dedupe stage.
#[derive(Debug, Clone, Default)]
pub struct DedupeStats {
pub entries_in: usize,
pub dropped_non_article: usize,
pub clusters: usize,
/// Clusters that merged more than one entry.
pub merged: usize,
}
/// Cluster `entries` into [`Article`]s: primary key is the canonical URL, secondary
/// pass matches [`normalized_title`] within the window. Each cluster keeps the
/// richest content, the union of source refs and the earliest `first_seen` (§3.2).
///
/// The returned articles have `id == 0` (not yet persisted) and carry the richest
/// *raw* Miniflux content in `content_html`; [`crate::extract`] replaces it with the
/// sanitized body and the real `word_count`.
pub fn cluster(entries: Vec<Entry>) -> (Vec<Article>, DedupeStats) {
cluster_with_feeds(entries, &FeedUrls::new())
}
/// [`cluster`] with the run's `feed_id → feed url` map, so `SourceKind::Scour`
/// (and the other feed-shaped kinds) are detected from the feed URL rather than
/// guessed from the feed title (§3.2).
pub fn cluster_with_feeds(
entries: Vec<Entry>,
feed_urls: &FeedUrls,
) -> (Vec<Article>, DedupeStats) {
let span = tracing::info_span!("dedupe", entries = entries.len());
let _guard = span.enter();
let mut stats = DedupeStats {
entries_in: entries.len(),
..DedupeStats::default()
};
let mut clusters: Vec<Vec<(Entry, String)>> = Vec::new();
let mut by_url: HashMap<String, usize> = HashMap::new();
let mut by_title: HashMap<String, usize> = HashMap::new();
for entry in entries {
if is_non_article(&entry) {
stats.dropped_non_article += 1;
continue;
}
let Some(canon) = canonical_url(&entry.url) else {
stats.dropped_non_article += 1;
continue;
};
let title_key = normalized_title(&entry.title);
let title_key = (title_key.len() >= MIN_TITLE_KEY_LEN).then_some(title_key);
let index = match by_url.get(&canon) {
Some(&i) => i,
None => match title_key.as_ref().and_then(|k| by_title.get(k)) {
Some(&i) => i,
None => {
clusters.push(Vec::new());
clusters.len() - 1
}
},
};
by_url.entry(canon.clone()).or_insert(index);
if let Some(key) = title_key {
by_title.entry(key).or_insert(index);
}
clusters[index].push((entry, canon));
}
let mut articles: Vec<Article> = Vec::with_capacity(clusters.len());
for members in clusters {
if members.is_empty() {
continue;
}
if members.len() > 1 {
stats.merged += 1;
}
articles.push(build_article(members, feed_urls));
}
stats.clusters = articles.len();
tracing::info!(
clusters = stats.clusters,
merged = stats.merged,
dropped = stats.dropped_non_article,
"clustered entries into articles"
);
(articles, stats)
}
/// Merge one cluster's entries into a single [`Article`], keeping the richest body.
fn build_article(members: Vec<(Entry, String)>, feed_urls: &FeedUrls) -> Article {
// Richest content wins; ties break on the lowest entry id so runs are stable.
let best = members
.iter()
.enumerate()
.max_by_key(|(_, (entry, _))| (crate::html::word_count(&entry.raw_content), -(entry.id)))
.map(|(i, _)| i)
.unwrap_or(0);
let (best_entry, canonical) = &members[best];
let first_seen = members
.iter()
.map(|(e, _)| e.published_at.unwrap_or(e.fetched_at))
.min()
.unwrap_or_else(Timestamp::now);
let published_at = members.iter().filter_map(|(e, _)| e.published_at).min();
let mut sources: Vec<SourceRef> = members
.iter()
.map(|(e, _)| source_ref_with_feeds(e, feed_urls))
.collect();
sources.sort_by_key(|s| s.entry_id);
sources.dedup_by_key(|s| s.entry_id);
// Prefer a comments URL that actually points at a discussion we can look up.
let comments_url = members
.iter()
.filter_map(|(e, _)| e.comments_url.clone())
.filter(|c| !c.trim().is_empty())
.max_by_key(|c| {
let lower = c.to_ascii_lowercase();
if lower.contains("news.ycombinator.com") {
2
} else if lower.contains("lobste.rs") {
1
} else {
0
}
});
let author = members
.iter()
.filter_map(|(e, _)| e.author.clone())
.find(|a| !a.trim().is_empty());
let word_count = crate::html::word_count(&best_entry.raw_content);
Article {
id: 0,
canonical_url: canonical.clone(),
title: best_entry.title.trim().to_string(),
best_entry_id: best_entry.id,
content_html: best_entry.raw_content.clone(),
word_count,
excerpt_only: false,
image_count: 0,
sources,
first_seen,
url: best_entry.url.clone(),
author,
feed_id: best_entry.feed_id,
feed_title: best_entry
.feed_title
.clone()
.unwrap_or_else(|| format!("feed {}", best_entry.feed_id)),
category: best_entry.category.clone(),
published_at,
comments_url,
image_urls: Vec::new(),
social: Vec::new(),
extract_method: ExtractMethod::Miniflux,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(s: &str) -> Timestamp {
s.parse().unwrap()
}
fn entry(id: i64, url: &str, title: &str) -> Entry {
Entry {
id,
feed_id: id * 10,
feed_title: Some(format!("Feed {id}")),
category: Some("Tech".into()),
title: title.into(),
url: url.into(),
canonical_url: None,
author: None,
published_at: Some(ts("2026-08-15T04:00:00Z")),
comments_url: None,
raw_content: "<p>hello world</p>".into(),
fetched_at: ts("2026-08-15T05:30:00Z"),
}
}
#[test]
fn canonicalization_table() {
let cases: &[(&str, Option<&str>)] = &[
// host case + fragment
(
"https://Example.COM/Posts/One#section",
Some("https://example.com/Posts/One"),
),
// trailing slash
("https://example.com/a/b/", Some("https://example.com/a/b")),
// bare root loses its slash
("https://example.com/", Some("https://example.com")),
("http://example.com", Some("http://example.com")),
// utm_* and friends
(
"https://example.com/p?utm_source=rss&utm_medium=feed&utm_campaign=x",
Some("https://example.com/p"),
),
(
"https://example.com/p?ref=hn&fbclid=abc&gclid=def&s=1&si=2",
Some("https://example.com/p"),
),
// meaningful params survive
(
"https://example.com/p?id=7&utm_source=rss",
Some("https://example.com/p?id=7"),
),
// mixed: everything at once
(
"HTTPS://WWW.Example.com/Path/?utm_source=a&page=2#frag",
Some("https://www.example.com/Path?page=2"),
),
// google news redirector resolves to the target
(
"https://news.google.com/rss/articles/CBMi?oc=5&url=https%3A%2F%2Fexample.com%2Freal%2F",
Some("https://example.com/real"),
),
(
"https://www.google.com/url?q=https://example.com/real&sa=D",
Some("https://example.com/real"),
),
// non-http schemes and junk
("mailto:tyler@hallada.net", None),
("ftp://example.com/file", None),
("not a url", None),
("", None),
(" ", None),
];
for (input, expected) in cases {
assert_eq!(
canonical_url(input).as_deref(),
*expected,
"canonicalizing {input:?}"
);
}
}
#[test]
fn canonicalization_is_idempotent() {
let once = canonical_url("https://Example.com/A/?utm_source=x#y").unwrap();
assert_eq!(canonical_url(&once).as_deref(), Some(once.as_str()));
}
#[test]
fn title_normalization() {
assert_eq!(
normalized_title("Rust 1.90: What's *New*?"),
"rust190whatsnew"
);
assert_eq!(
normalized_title(" The Quick — Brown Fox "),
"thequickbrownfox"
);
// Same story, different feed punctuation, same key.
assert_eq!(
normalized_title("Show HN: My Tiny Database"),
normalized_title("Show HN – My Tiny Database!")
);
assert_eq!(normalized_title("!!!"), "");
}
#[test]
fn non_articles_are_rejected() {
let mut youtube = entry(1, "https://www.youtube.com/watch?v=abc", "A video");
assert!(is_non_article(&youtube));
youtube.url = "https://m.youtube.com/watch?v=abc".into();
assert!(is_non_article(&youtube));
assert!(is_non_article(&entry(
2,
"https://open.spotify.com/episode/x",
"An episode"
)));
assert!(is_non_article(&entry(3, "https://example.com/p", " ")));
assert!(is_non_article(&entry(4, "javascript:void(0)", "Bad url")));
assert!(is_non_article(&entry(
5,
"https://cdn.example.com/ep/12.mp3",
"Episode 12"
)));
let mut enclosure = entry(6, "https://example.com/pod/12", "Episode 12");
enclosure.raw_content = "<audio src=\"https://x/1.mp3\"></audio>".into();
assert!(is_non_article(&enclosure));
// An article that merely embeds a video is still an article.
let mut with_video = entry(7, "https://example.com/post", "A real post");
with_video.raw_content =
format!("<iframe src=\"x\"></iframe><p>{}</p>", "word ".repeat(60));
assert!(!is_non_article(&with_video));
assert!(!is_non_article(&entry(8, "https://example.com/p", "Fine")));
}
#[test]
fn source_kinds_come_from_feed_metadata() {
let mut e = entry(1, "https://example.com/p", "T");
e.feed_title = Some("Scour: Rust".into());
assert_eq!(classify_source(&e), SourceKind::Scour);
e.feed_title = Some("Hacker News: Front Page".into());
assert_eq!(classify_source(&e), SourceKind::HnFrontpage);
e.feed_title = Some("Some Blog".into());
e.comments_url = Some("https://news.ycombinator.com/item?id=1".into());
assert_eq!(classify_source(&e), SourceKind::HnFrontpage);
e.comments_url = Some("https://lobste.rs/s/abcdef/thing".into());
assert_eq!(classify_source(&e), SourceKind::Lobsters);
e.comments_url = None;
e.feed_title = Some("r/rust".into());
e.url = "https://www.reddit.com/r/rust/comments/x/y/".into();
assert_eq!(classify_source(&e), SourceKind::Reddit);
e.feed_title = Some("Tyler's Blog".into());
e.category = Some("Blogroll".into());
e.url = "https://hallada.net/post".into();
assert_eq!(classify_source(&e), SourceKind::Feed);
// The feed URL wins when the caller has it.
assert_eq!(
classify_source_with_feed(&e, Some("https://scour.ing/feed/rust")),
SourceKind::Scour
);
}
#[test]
fn clustering_merges_by_url_then_title() {
let long_body = format!("<p>{}</p>", "word ".repeat(400));
// Same story from three feeds: two share a URL (modulo tracking params),
// the third differs only in punctuation of the title.
let mut hn = entry(
1,
"https://blog.dev/post?utm_source=hn",
"A Deep Dive Into B-Trees",
);
hn.feed_title = Some("Hacker News".into());
hn.comments_url = Some("https://news.ycombinator.com/item?id=42".into());
let mut scour = entry(2, "https://blog.dev/post/", "A Deep Dive Into B-Trees");
scour.feed_title = Some("Scour: Databases".into());
scour.raw_content = long_body.clone();
let mut own = entry(3, "https://blog.dev/post-alt", "A Deep Dive into B-Trees!");
own.feed_title = Some("Blog.dev".into());
own.published_at = Some(ts("2026-08-15T02:00:00Z"));
let other = entry(4, "https://other.dev/x", "Something Else Entirely Here");
let (articles, stats) = cluster(vec![hn, scour, own, other]);
assert_eq!(stats.entries_in, 4);
assert_eq!(stats.clusters, 2);
assert_eq!(stats.merged, 1);
assert_eq!(stats.dropped_non_article, 0);
let merged = &articles[0];
assert_eq!(merged.canonical_url, "https://blog.dev/post");
assert_eq!(merged.sources.len(), 3);
// Richest content won.
assert_eq!(merged.best_entry_id, 2);
assert!(merged.word_count > 300);
// Union of source kinds, used as a curation signal.
assert!(merged.came_via(SourceKind::Scour));
assert!(merged.came_via(SourceKind::HnFrontpage));
assert!(merged.came_via(SourceKind::Feed));
// Earliest publication time and the HN comments link survive the merge.
assert_eq!(merged.first_seen, ts("2026-08-15T02:00:00Z"));
assert_eq!(
merged.comments_url.as_deref(),
Some("https://news.ycombinator.com/item?id=42")
);
assert_eq!(merged.id, 0);
assert_eq!(articles[1].canonical_url, "https://other.dev/x");
assert_eq!(articles[1].sources.len(), 1);
}
#[test]
fn clustering_drops_non_articles_and_keeps_short_titles_apart() {
let mut a = entry(1, "https://a.dev/1", "News");
a.raw_content = "<p>one</p>".into();
let mut b = entry(2, "https://b.dev/2", "News");
b.raw_content = "<p>two</p>".into();
let video = entry(3, "https://youtu.be/xyz", "A video");
let (articles, stats) = cluster(vec![a, b, video]);
assert_eq!(stats.dropped_non_article, 1);
// "news" is below MIN_TITLE_KEY_LEN, so the two stay separate.
assert_eq!(articles.len(), 2);
assert_eq!(stats.merged, 0);
}
}