use crate::constants::{flags::*, is_div_to_p_elem, is_phrasing_elem, regexps};
use crate::dom::{NodeDataStore, NodeStats, get_tag_name, has_tag_name};
use crate::selectors::Selectors;
use dom_query::{Node, NodeData};
#[inline]
fn is_hash_url(s: &str) -> bool {
s.starts_with('#') && s.len() > 1
}
pub fn get_or_compute_stats(node: &Node<'_>, store: &mut NodeDataStore) -> NodeStats {
if let Some(stats) = store.get_stats(&node.id) {
return *stats;
}
if let Some(stats) = stats_for_small_subtree(node, 64) {
store.set_stats(node.id, stats);
return stats;
}
let mut stack = vec![(*node, false)];
while let Some((current, expanded)) = stack.pop() {
if store.get_stats(¤t.id).is_some() {
continue;
}
if !expanded {
stack.push((current, true));
stack.extend(
current
.children_it(true)
.filter(|child| store.get_stats(&child.id).is_none())
.map(|child| (child, false)),
);
continue;
}
let mut stats = current
.query(|tree_node| match &tree_node.data {
NodeData::Text { contents } => stats_for_text(contents),
_ => NodeStats::default(),
})
.unwrap_or_default();
for child in current.children_it(false) {
if let Some(child_stats) = store.get_stats(&child.id) {
append_stats(&mut stats, child_stats);
}
}
stats.has_sentence_end = stats.has_sentence_break || stats.ends_with_dot;
store.set_stats(current.id, stats);
}
store.get_stats(&node.id).copied().unwrap_or_default()
}
fn stats_for_small_subtree(node: &Node<'_>, max_nodes: usize) -> Option<NodeStats> {
let mut stats = NodeStats::default();
for (index, descendant) in std::iter::once(*node)
.chain(node.descendants_it())
.enumerate()
{
if index == max_nodes {
return None;
}
descendant.query(|tree_node| {
if let NodeData::Text { contents } = &tree_node.data {
append_stats(&mut stats, &stats_for_text(contents));
}
});
}
Some(stats)
}
fn stats_for_text(text: &str) -> NodeStats {
let mut stats = NodeStats {
has_text: !text.is_empty(),
starts_with_whitespace: text.starts_with(char::is_whitespace),
ends_with_whitespace: text.ends_with(char::is_whitespace),
..NodeStats::default()
};
let mut previous_was_whitespace = true;
let mut last_was_dot = false;
for c in text.chars() {
if c.is_whitespace() {
stats.has_sentence_break |= last_was_dot;
last_was_dot = false;
if !previous_was_whitespace {
stats.text_length += 1;
previous_was_whitespace = true;
}
} else {
stats.has_non_whitespace = true;
last_was_dot = c == '.';
stats.comma_count += usize::from(
c == ','
|| (c as u32 >= 0x0600
&& matches!(
c,
'\u{060C}'
| '\u{FE50}'
| '\u{FE10}'
| '\u{FE11}'
| '\u{2E41}'
| '\u{2E34}'
| '\u{2E32}'
| '\u{FF0C}'
)),
);
stats.text_length += 1;
previous_was_whitespace = false;
}
}
if previous_was_whitespace && stats.text_length > 0 {
stats.text_length -= 1;
}
stats.ends_with_dot = last_was_dot;
stats.has_sentence_end = stats.has_sentence_break || stats.ends_with_dot;
stats
}
fn append_stats(stats: &mut NodeStats, child: &NodeStats) {
if !child.has_text {
return;
}
if !stats.has_text {
*stats = *child;
return;
}
stats.has_sentence_break |=
child.has_sentence_break || (stats.ends_with_dot && child.starts_with_whitespace);
if stats.has_non_whitespace
&& child.has_non_whitespace
&& (stats.ends_with_whitespace || child.starts_with_whitespace)
{
stats.text_length += 1;
}
stats.text_length += child.text_length;
stats.comma_count += child.comma_count;
stats.has_non_whitespace |= child.has_non_whitespace;
stats.ends_with_whitespace = child.ends_with_whitespace;
stats.ends_with_dot = child.ends_with_dot;
stats.has_sentence_end = stats.has_sentence_break || stats.ends_with_dot;
}
pub fn compute_initial_readability_data(
node: &Node<'_>,
flags: u32,
) -> crate::dom::ReadabilityData {
let initial_score = match get_tag_name(node).as_deref() {
Some("DIV") => 5.0,
Some("PRE") | Some("TD") | Some("BLOCKQUOTE") => 3.0,
Some("ADDRESS") | Some("OL") | Some("UL") | Some("DL") | Some("DD") | Some("DT")
| Some("LI") | Some("FORM") => -3.0,
Some("H1") | Some("H2") | Some("H3") | Some("H4") | Some("H5") | Some("H6")
| Some("TH") => -5.0,
_ => 0.0,
};
let class_weight = get_class_weight(node, flags);
crate::dom::ReadabilityData::with_score(initial_score + class_weight as f64)
}
pub fn initialize_node(node: &Node<'_>, store: &mut NodeDataStore, flags: u32) {
store.set(node.id, compute_initial_readability_data(node, flags));
}
pub fn get_class_weight(node: &Node<'_>, flags: u32) -> i32 {
if (flags & FLAG_WEIGHT_CLASSES) == 0 {
return 0;
}
let mut weight: i32 = 0;
if let Some(class_name) = node.attr("class") {
let class_str = class_name.as_ref();
if !class_str.is_empty() {
let matches = regexps::CLASS_WEIGHT_SET.matches(class_str);
if matches.matched(0) {
weight -= 25; }
if matches.matched(1) {
weight += 25; }
}
}
if let Some(id) = node.attr("id") {
let id_str = id.as_ref();
if !id_str.is_empty() {
let matches = regexps::CLASS_WEIGHT_SET.matches(id_str);
if matches.matched(0) {
weight -= 25; }
if matches.matched(1) {
weight += 25; }
}
}
weight
}
pub fn has_non_empty_inner_text(node: &Node<'_>) -> bool {
has_non_whitespace_text(node)
}
pub fn get_inner_text(node: &Node<'_>, normalize_spaces: bool) -> String {
let text = node.text();
let trimmed = text.trim();
if trimmed.is_empty() {
return String::new();
}
if normalize_spaces {
normalize_whitespace(trimmed)
} else if trimmed.as_ptr() == text.as_ptr() && trimmed.len() == text.len() {
text.to_string()
} else {
trimmed.to_string()
}
}
fn normalize_whitespace(s: &str) -> String {
let needs_normalize = s
.as_bytes()
.windows(2)
.any(|w| w[0].is_ascii_whitespace() && w[1].is_ascii_whitespace())
|| s.bytes().any(|b| b == b'\t' || b == b'\n' || b == b'\r');
if !needs_normalize {
return s.to_string();
}
let mut result = String::with_capacity(s.len());
let mut prev_ws = false;
for c in s.chars() {
if c.is_whitespace() {
if !prev_ws {
result.push(' ');
}
prev_ws = true;
} else {
result.push(c);
prev_ws = false;
}
}
result
}
pub fn get_link_density_with_text(
node: &Node<'_>,
node_text: Option<&str>,
_selectors: &Selectors,
) -> f64 {
let text_length = match node_text {
Some(t) => t.chars().count(),
None => node.normalized_char_count(),
};
if text_length == 0 {
return 0.0;
}
let mut link_length = 0.0;
for link in node
.descendants_it()
.filter(|descendant| has_tag_name(descendant, "a"))
{
let coefficient = match link.attr("href") {
Some(href) if is_hash_url(href.as_ref()) => 0.3,
_ => 1.0,
};
link_length += link.normalized_char_count() as f64 * coefficient;
}
link_length / text_length as f64
}
pub fn get_link_density(node: &Node<'_>, selectors: &Selectors) -> f64 {
get_link_density_with_text(node, None, selectors)
}
pub fn get_link_density_cached(
node: &Node<'_>,
parent_text_length: usize,
store: &mut NodeDataStore,
_selectors: &Selectors,
) -> f64 {
if parent_text_length == 0 {
return 0.0;
}
let mut link_length = 0.0;
for link in node
.descendants_it()
.filter(|descendant| has_tag_name(descendant, "a"))
{
let link_stats = get_or_compute_stats(&link, store);
let coefficient = match link.attr("href") {
Some(href) if is_hash_url(href.as_ref()) => 0.3,
_ => 1.0,
};
link_length += link_stats.text_length as f64 * coefficient;
}
link_length / parent_text_length as f64
}
pub fn is_whitespace(node: &Node<'_>) -> bool {
if node.is_text() {
let text = node.text();
return text.trim().is_empty();
}
if node.is_element()
&& let Some(tag) = get_tag_name(node)
{
return tag == "BR";
}
false
}
pub fn is_phrasing_content(node: &Node<'_>) -> bool {
is_phrasing_content_depth(node, 0)
}
fn is_phrasing_content_depth(node: &Node<'_>, depth: u32) -> bool {
if node.is_text() {
return true;
}
if let Some(tag) = get_tag_name(node) {
if is_phrasing_elem(&tag) {
return true;
}
if (tag == "A" || tag == "DEL" || tag == "INS") && depth < 10 {
return node
.children()
.iter()
.all(|child| is_phrasing_content_depth(child, depth + 1));
}
}
false
}
pub fn wrap_phrasing_content_in_p(div: &Node<'_>) {
let children: Vec<_> = div.children();
let mut i = 0;
while i < children.len() {
let child = &children[i];
if is_phrasing_content(child) {
let mut j = i;
let mut has_content = false;
while j < children.len() && is_phrasing_content(&children[j]) {
let node = &children[j];
has_content |= !node.is_text() || !node.text().trim().is_empty();
j += 1;
}
if has_content {
let mut start = i;
let mut end = j;
while start < end && is_whitespace(&children[start]) {
start += 1;
}
while start < end && is_whitespace(&children[end - 1]) {
end -= 1;
}
if start < end
&& let Some(first_node) = children.get(start)
{
let p = div.tree.new_element("p");
first_node.insert_before(&p);
for node in &children[start..end] {
p.append_child(node);
}
for node in children[i..start].iter().chain(children[end..j].iter()) {
node.remove_from_parent();
}
}
}
i = j;
} else {
i += 1;
}
}
}
pub fn is_element_without_content(node: &Node<'_>) -> bool {
if !node.is_element() {
return false;
}
if has_non_whitespace_text(node) {
return false;
}
node.children_it(false)
.filter(|child| child.is_element())
.all(|child| has_tag_name(&child, "BR") || has_tag_name(&child, "HR"))
}
pub fn has_single_tag_inside_element(node: &Node<'_>, tag: &str) -> bool {
let mut found_element = false;
for child in node.children_it(false) {
if child.is_element() {
if found_element || !has_tag_name(&child, tag) {
return false;
}
found_element = true;
} else if child.is_text()
&& child
.text()
.as_ref()
.ends_with(|c: char| !c.is_whitespace())
{
return false;
}
}
found_element
}
pub fn has_child_block_element(node: &Node<'_>) -> bool {
node.descendants_it()
.filter(|child| child.is_element())
.any(|child| get_tag_name(&child).is_some_and(|tag| is_div_to_p_elem(&tag)))
}
pub fn is_probably_visible(node: &Node<'_>) -> bool {
if let Some(style) = node.attr("style") {
let style_str = style.as_ref();
if has_hidden_style(style_str) {
return false;
}
}
if node.has_attr("hidden") {
return false;
}
if let Some(aria_hidden) = node.attr("aria-hidden")
&& aria_hidden.as_ref() == "true"
{
if let Some(class) = node.attr("class") {
if !class.as_ref().contains("fallback-image") {
return false;
}
} else {
return false;
}
}
true
}
pub fn is_valid_byline(node: &Node<'_>, match_string: &str) -> bool {
let is_byline_attr = node.attr("rel").is_some_and(|rel| rel.as_ref() == "author")
|| node
.attr("itemprop")
.is_some_and(|ip| ip.as_ref().contains("author"))
|| regexps::BYLINE.is_match(match_string);
if !is_byline_attr {
return false;
}
let text = node.text();
let trimmed = text.trim();
!trimmed.is_empty() && trimmed.len() < 400 && trimmed.chars().count() < 100
}
pub fn is_single_image(node: &Node<'_>) -> bool {
let mut current = *node;
let mut checked_text = false;
loop {
let n = current;
if let Some(tag) = get_tag_name(&n)
&& tag == "IMG"
{
return true;
}
if !checked_text {
if has_non_whitespace_text(&n) {
return false;
}
checked_text = true;
}
let mut children = n.children_it(false).filter(|child| child.is_element());
let Some(child) = children.next() else {
return false;
};
if children.next().is_some() {
return false;
}
current = child;
}
}
fn has_non_whitespace_text(node: &Node<'_>) -> bool {
if node.is_text() {
return node.text().chars().any(|c| !c.is_whitespace());
}
node.descendants_it().any(|descendant| {
descendant.is_text() && descendant.text().chars().any(|c| !c.is_whitespace())
})
}
fn has_hidden_style(haystack: &str) -> bool {
let hbytes = haystack.as_bytes();
let hlen = hbytes.len();
if hlen == 0 {
return false;
}
let display_pat: &[u8] = b"display:none";
let vis_pat: &[u8] = b"visibility:hidden";
let mut i = 0;
while i < hlen {
let b = hbytes[i].to_ascii_lowercase();
let needle = if b == b'd' {
display_pat
} else if b == b'v' {
vis_pat
} else {
i += 1;
continue;
};
let needle_len = needle.len();
if i + needle_len > hlen {
i += 1;
continue;
}
let mut hi = i;
let mut ni = 0;
let mut matches = true;
while ni < needle_len && hi < hlen {
if hbytes[hi].is_ascii_whitespace() {
hi += 1;
continue;
}
if hbytes[hi].to_ascii_lowercase() != needle[ni] {
matches = false;
break;
}
hi += 1;
ni += 1;
}
if matches && ni == needle_len {
return true;
}
i += 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use dom_query::Document;
fn concatenated_stats(text: &str) -> NodeStats {
let mut expected = NodeStats::default();
let mut previous_was_whitespace = true;
let mut last_was_dot = false;
for c in text.chars() {
if c.is_whitespace() {
expected.has_sentence_end |= last_was_dot;
last_was_dot = false;
if !previous_was_whitespace {
expected.text_length += 1;
previous_was_whitespace = true;
}
} else {
last_was_dot = c == '.';
expected.comma_count += usize::from(
c == ','
|| matches!(
c,
'\u{060C}'
| '\u{FE50}'
| '\u{FE10}'
| '\u{FE11}'
| '\u{2E41}'
| '\u{2E34}'
| '\u{2E32}'
| '\u{FF0C}'
),
);
expected.text_length += 1;
previous_was_whitespace = false;
}
}
if previous_was_whitespace && expected.text_length > 0 {
expected.text_length -= 1;
}
expected.has_sentence_end |= last_was_dot;
expected
}
fn assert_stats_match(node: &Node<'_>, store: &mut NodeDataStore) {
let expected = concatenated_stats(&node.text());
let actual = get_or_compute_stats(node, store);
assert_eq!(actual.text_length, expected.text_length);
assert_eq!(actual.comma_count, expected.comma_count);
assert_eq!(actual.has_sentence_end, expected.has_sentence_end);
}
#[test]
fn cached_node_stats_match_concatenated_text_semantics() {
let cases = [
"<div> alpha,<span> beta.</span>\n<strong>gamma\u{060c}</strong> </div>",
"<div><span>not.</span><span>ended</span></div>",
"<div><span>end.</span><i> </i><span>next</span></div>",
"<div> \n <span>one</span><i></i> <b>two</b> </div>",
];
for html in cases {
let doc = Document::from(html);
let node = doc.select("div").nodes().first().copied().unwrap();
assert_stats_match(&node, &mut NodeDataStore::new());
}
}
#[test]
fn bottom_up_stats_match_large_mixed_subtree() {
let mut html = String::from("<div> leading.");
for index in 0..70 {
match index % 4 {
0 => html.push_str("<span> word,</span>"),
1 => html.push_str("<i> </i>"),
2 => html.push_str("<b>sentence.</b>\n"),
_ => html.push_str("<em>joined</em><strong>text</strong>"),
}
}
html.push_str(" trailing\u{060c}</div>");
let doc = Document::from(html);
let node = doc.select("div").nodes().first().copied().unwrap();
assert!(node.descendants_it().count() > 64);
assert_stats_match(&node, &mut NodeDataStore::new());
}
#[test]
fn clearing_stats_recomputes_a_mutated_large_subtree() {
let mut html = String::from("<div>");
for _ in 0..70 {
html.push_str("<span>cached text, </span>");
}
html.push_str("</div>");
let doc = Document::from(html);
let node = doc.select("div").nodes().first().copied().unwrap();
let removed = doc.select("span").nodes().last().copied().unwrap();
let mut store = NodeDataStore::new();
let before = get_or_compute_stats(&node, &mut store);
removed.remove_from_parent();
let expected = concatenated_stats(&node.text());
assert_ne!(before.text_length, expected.text_length);
assert_eq!(
get_or_compute_stats(&node, &mut store).text_length,
before.text_length
);
store.clear_stats();
assert_stats_match(&node, &mut store);
}
}