From c313f234772bd75a3b98c758ccb7616fb7d30932 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 6 Jul 2026 22:20:20 -0500 Subject: [PATCH] fix: bound readable content extraction --- crates/fetchkit/src/convert.rs | 99 ++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 27 deletions(-) diff --git a/crates/fetchkit/src/convert.rs b/crates/fetchkit/src/convert.rs index 97ed37a..d18166c 100644 --- a/crates/fetchkit/src/convert.rs +++ b/crates/fetchkit/src/convert.rs @@ -948,19 +948,15 @@ pub fn strip_boilerplate(html: &str) -> String { /// penalizes link-heavy or boilerplate-looking blocks, and returns `None` when /// confidence is too low so callers can fall back to the existing main/full modes. pub fn extract_readable_content(html: &str) -> Option { - let candidates = collect_readable_candidates(html); - let best = candidates - .into_iter() + let best = collect_best_readable_candidate(html) .filter(|candidate| candidate.word_count >= 20) - .max_by_key(|candidate| candidate.score)?; - - if best.score < 100 { - return None; - } + .filter(|candidate| candidate.score >= 100)?; Some(strip_boilerplate_elements(&best.html)) } +const MAX_READABLE_CANDIDATES: usize = 64; + #[derive(Debug)] struct ReadableCandidate { html: String, @@ -968,20 +964,41 @@ struct ReadableCandidate { word_count: usize, } -fn collect_readable_candidates(html: &str) -> Vec { - let mut candidates = Vec::new(); +#[derive(Debug)] +struct ReadableCandidateScore { + score: i64, + word_count: usize, +} + +fn collect_best_readable_candidate(html: &str) -> Option { + let lower = html.to_ascii_lowercase(); + let mut best = None; + let mut scored_count = 0usize; + for tag_name in ["article", "main", "section", "div"] { - collect_tag_candidates(html, tag_name, &mut candidates); + collect_best_tag_candidate(html, &lower, tag_name, &mut best, &mut scored_count); + if scored_count >= MAX_READABLE_CANDIDATES { + break; + } } - candidates + + best } -fn collect_tag_candidates(html: &str, tag_name: &str, candidates: &mut Vec) { - let lower = html.to_ascii_lowercase(); +fn collect_best_tag_candidate( + html: &str, + lower: &str, + tag_name: &str, + best: &mut Option, + scored_count: &mut usize, +) { let open_prefix = format!("<{tag_name}"); let mut search_start = 0usize; - while let Some(relative_start) = lower[search_start..].find(&open_prefix) { + while *scored_count < MAX_READABLE_CANDIDATES { + let Some(relative_start) = lower[search_start..].find(&open_prefix) else { + break; + }; let tag_start = search_start + relative_start; let after_name = tag_start + open_prefix.len(); let Some(next) = lower[after_name..].chars().next() else { @@ -1003,13 +1020,24 @@ fn collect_tag_candidates(html: &str, tag_name: &str, candidates: &mut Vec candidate.score) + .unwrap_or(true); + if replace_best { + *best = Some(ReadableCandidate { + html: inner.to_string(), + score: score.score, + word_count: score.word_count, + }); + } } search_start = open_end + 1; @@ -1093,14 +1121,14 @@ fn looks_like_content_container(open_tag: &str) -> bool { && !negative.iter().any(|needle| lower.contains(needle)) } -fn score_readable_candidate(open_tag: &str, html: String) -> Option { - let text = html_to_text(&html); +fn score_readable_candidate(open_tag: &str, html: &str) -> Option { + let text = html_to_text(html); let word_count = text.split_whitespace().count(); if word_count == 0 { return None; } - let link_word_count = link_text_word_count(&html); + let link_word_count = link_text_word_count(html); let paragraph_count = html.matches(" Option bool { @@ -1896,6 +1920,27 @@ mod tests { assert!(extract_readable_content(html).is_none()); } + #[test] + fn test_extract_readable_content_handles_deep_nested_content() { + let mut html = String::new(); + for _ in 0..128 { + html.push_str(r#"
"#); + } + html.push_str("

Nested Article

"); + for _ in 0..24 { + html.push_str( + "

This readable paragraph gives agents useful bounded extraction text.

", + ); + } + for _ in 0..128 { + html.push_str("
"); + } + + let result = extract_readable_content(&html).unwrap(); + assert!(result.contains("Nested Article")); + assert!(result.contains("bounded extraction text")); + } + #[test] fn test_html_to_markdown_links() { let html = r#"

Visit Example Site today.

"#;