use crate::constants::REGEXPS;
use crate::dom_utils;
use scraper::{ElementRef, Html, Selector};
#[derive(Debug, Clone)]
pub struct ReaderableOptions {
pub min_content_length: usize,
pub min_score: f64,
}
impl Default for ReaderableOptions {
fn default() -> Self {
Self {
min_content_length: 140,
min_score: 20.0,
}
}
}
pub fn is_probably_readerable(html: &str, options: Option<ReaderableOptions>) -> bool {
let options = options.unwrap_or_default();
let document = Html::parse_document(html);
let Ok(candidate_selector) = Selector::parse("p, pre, article, div") else {
return false;
};
let mut score = 0.0;
for node in document.select(&candidate_selector) {
if !is_scored_candidate(node) {
continue;
}
let text_len = node.text().collect::<String>().trim().len();
if text_len < options.min_content_length {
continue;
}
score += ((text_len - options.min_content_length) as f64).sqrt();
if score > options.min_score {
return true;
}
}
false
}
fn is_scored_candidate(node: ElementRef) -> bool {
let tag = node.value().name();
if tag.eq_ignore_ascii_case("div") && !has_direct_br_child(node) {
return false;
}
if tag.eq_ignore_ascii_case("p") && parent_is_list_item(node) {
return false;
}
if !dom_utils::is_probably_visible(node) {
return false;
}
let class = node.value().attr("class").unwrap_or("");
let id = node.value().attr("id").unwrap_or("");
let match_string = format!("{class} {id}");
if REGEXPS.unlikely_candidates.is_match(&match_string)
&& !REGEXPS.ok_maybe_its_a_candidate.is_match(&match_string)
{
return false;
}
true
}
fn has_direct_br_child(node: ElementRef) -> bool {
node.children()
.filter_map(ElementRef::wrap)
.any(|child| child.value().name().eq_ignore_ascii_case("br"))
}
fn parent_is_list_item(node: ElementRef) -> bool {
node.parent()
.and_then(ElementRef::wrap)
.is_some_and(|parent| parent.value().name().eq_ignore_ascii_case("li"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_probably_readerable() {
let html = r#"
<html>
<body>
<article>
<p>This is a long enough paragraph that should make the content readerable.
It has sufficient content to pass the minimum threshold check. Adding more text here to ensure
we definitely exceed the 140 character minimum requirement for each paragraph element.</p>
<p>Another paragraph with more content to increase the score. This paragraph also needs to be
long enough to contribute to the overall readability score calculation and help us pass the test.</p>
</article>
</body>
</html>
"#;
assert!(is_probably_readerable(html, None));
}
#[test]
fn test_not_readerable() {
let html = r#"
<html>
<body>
<p>Short</p>
</body>
</html>
"#;
assert!(!is_probably_readerable(html, None));
}
fn prose_paragraphs(class_attr: &str) -> String {
(1..=3)
.map(|i| {
format!(
"<p class=\"{class_attr}\">Paragraph {i} carries well over the hundred and \
forty character minimum that a candidate needs before it contributes to the \
score at all, so three of them comfortably clear the threshold.</p>"
)
})
.collect()
}
#[test]
fn test_unlikely_candidate_is_excluded() {
let html = format!("<html><body>{}</body></html>", prose_paragraphs("comment"));
assert!(!is_probably_readerable(&html, None));
}
#[test]
fn test_ok_maybe_candidate_overrides_unlikely() {
let html = format!(
"<html><body>{}</body></html>",
prose_paragraphs("comment main-content")
);
assert!(is_probably_readerable(&html, None));
}
#[test]
fn test_unlikely_wrapper_does_not_exclude_its_children() {
let html = format!(
"<html><body><div class=\"comment\">{}</div></body></html>",
prose_paragraphs("")
);
assert!(is_probably_readerable(&html, None));
}
#[test]
fn test_hidden_content_is_excluded() {
let html = format!(
"<html><body><div style=\"display:none\">{}</div></body></html>",
prose_paragraphs("")
);
assert!(!is_probably_readerable(&html, None));
}
#[test]
fn test_div_with_br_child_is_counted() {
let line = "A line of prose long enough on its own to matter, padded out past the \
hundred and forty character minimum that a candidate needs before it \
contributes anything to the score.";
let body = [line; 6].join("<br>");
let html = format!("<html><body><div>{body}</div></body></html>");
assert!(is_probably_readerable(&html, None));
}
#[test]
fn test_bare_div_of_text_is_not_a_candidate() {
let filler = "Plenty of prose sitting directly in a div with no line breaks and no \
paragraph children whatsoever. "
.repeat(10);
let html = format!("<html><body><div>{filler}</div></body></html>");
assert!(!is_probably_readerable(&html, None));
}
#[test]
fn test_div_without_br_is_not_counted_itself() {
let html = format!(
"<html><body><div>{}</div></body></html>",
prose_paragraphs("")
);
assert!(is_probably_readerable(&html, None));
}
#[test]
fn test_paragraphs_inside_list_items_are_excluded() {
let items: String = (1..=3)
.map(|i| {
let filler = "This entry is deliberately verbose so that its length alone would \
carry the page over the score threshold. "
.repeat(6);
format!("<li><p>Entry {i}. {filler}</p></li>")
})
.collect();
let html = format!("<html><body><ul>{items}</ul></body></html>");
assert!(!is_probably_readerable(&html, None));
}
}