use std::collections::{HashMap, HashSet};
use std::ops::Deref;
use select::document::Document;
use select::node::Node;
use select::predicate::{Attr, Class, Name, Predicate};
use crate::clean::{DefaultDocumentCleaner, DocumentCleaner};
use crate::video::VideoNode;
use crate::Language;
use url::Url;
pub const ARTICLE_BODY_ATTR: &[(&str, &str)] = &[
("itemprop", "articleBody"),
("data-testid", "article-body"),
("name", "articleBody"),
("class", "content"),
("class", "article-content"),
("class", "post-content"),
("class", "entry-content"),
("class", "main-content"),
("id", "content"),
("id", "article-content"),
("id", "main-content"),
("role", "article"),
("data-role", "content"),
];
pub const NON_CONTENT_ATTR: &[(&str, &str)] = &[
("class", "sidebar"),
("class", "navigation"),
("class", "nav"),
("class", "menu"),
("class", "footer"),
("class", "header"),
("class", "advertisement"),
("class", "ad"),
("class", "comments"),
("class", "widget"),
("data-image-caption", ""), ("role", "navigation"),
("role", "complementary"),
("data-role", "sidebar"),
];
pub const PUNCTUATION: &str = r###",."'!?&-/:;()#$%*+<=>@[\]^_`{|}~"###;
pub trait TextContainer<'a> {
fn first_children_text(&self) -> Option<&'a str>;
fn text_content_length(&self) -> usize;
fn link_density(&self) -> f64;
fn is_noise_node(&self) -> bool;
}
impl<'a> TextContainer<'a> for Node<'a> {
fn first_children_text(&self) -> Option<&'a str> {
self.children().find_map(|n| n.as_text())
}
fn text_content_length(&self) -> usize {
self.text().chars().count()
}
fn link_density(&self) -> f64 {
let link_text_length: usize = self.find(Name("a"))
.map(|n| n.text().chars().count())
.sum();
let total_text_length = self.text_content_length();
if total_text_length == 0 {
return 1.0;
}
link_text_length as f64 / total_text_length as f64
}
fn is_noise_node(&self) -> bool {
if Name("script").or(Name("style")).or(Name("link")).or(Name("meta")).or(Name("noscript")).or(Name("figcaption")).or(Name("figure")).matches(self) {
return true;
}
if self.attr("data-image-caption").is_some() {
return true;
}
if self.attr("data-creative").is_some() {
return true;
}
if let Some(class) = self.attr("class") {
let class_lower = class.to_lowercase();
if class_lower.contains("articlebottom") ||
class_lower.contains("article-bottom") ||
class_lower.contains("footer") ||
class_lower.contains("sidebar") ||
class_lower.contains("widget") ||
class_lower.contains("related-") ||
class_lower.contains("recommendation") {
return true;
}
}
if Name("img").matches(self) {
if let Some(style) = self.attr("style") {
if style.contains("display: none") ||
style.contains("visibility: hidden") ||
(style.contains("position: absolute") && style.contains("left: -9999px")) {
return true;
}
}
}
let mut current = self.parent();
while let Some(parent) = current {
if Name("script").or(Name("style")).or(Name("noscript")).or(Name("figcaption")).or(Name("figure")).matches(&parent) {
return true;
}
if parent.attr("data-image-caption").is_some() {
return true;
}
if parent.attr("data-creative").is_some() {
return true;
}
if let Some(class) = parent.attr("class") {
let class_lower = class.to_lowercase();
if class_lower.contains("articlebottom") ||
class_lower.contains("article-bottom") ||
class_lower.contains("footer") ||
class_lower.contains("sidebar") ||
class_lower.contains("widget") ||
class_lower.contains("related-") ||
class_lower.contains("recommendation") {
return true;
}
}
current = parent.parent();
}
false
}
}
pub struct TextNodeFind<'a> {
document: &'a Document,
next: usize,
}
impl<'a> TextNodeFind<'a> {
fn is_text_node(node: &Node<'a>) -> bool {
if Name("p").or(Name("pre")).or(Name("td")).or(Name("article")).matches(node) {
return true;
}
if Name("div").matches(node) {
if let Some(class) = node.attr("class") {
if class.contains("article") ||
class.contains("story") ||
class.contains("paragraph") ||
class.contains("content") ||
class.contains("post") ||
class.contains("entry") {
return true;
}
}
if let Some(id) = node.attr("id") {
if id.contains("article") ||
id.contains("story") ||
id.contains("content") {
return true;
}
}
return false;
}
false
}
fn is_bad(node: &Node<'a>) -> bool {
Name("figure")
.or(Name("figcaption")) .or(Name("media"))
.or(Name("aside"))
.or(Name("nav"))
.or(Name("footer"))
.or(Name("header"))
.or(Name("script"))
.or(Name("style"))
.or(Name("link"))
.or(Name("meta"))
.or(Name("noscript"))
.or(Class("advertisement").or(Class("ad")))
.or(Class("sidebar"))
.or(Class("navigation"))
.or(Class("comments"))
.or(Class("caption")) .matches(node)
}
fn is_non_content_by_attr(node: &Node<'a>) -> bool {
NON_CONTENT_ATTR.iter().any(|&(k, v)| Attr(k, v).matches(node))
}
fn new(document: &'a Document) -> Self {
Self { document, next: 0 }
}
}
impl<'a> Iterator for TextNodeFind<'a> {
type Item = Node<'a>;
fn next(&mut self) -> Option<Node<'a>> {
while self.next < self.document.nodes.len() {
let node = self.document.nth(self.next).unwrap();
self.next += 1;
if Self::is_bad(&node) || Self::is_non_content_by_attr(&node) || node.is_noise_node() {
self.next += node.descendants().count();
continue;
}
if Self::is_text_node(&node) {
return Some(node);
}
}
None
}
}
#[derive(Debug, Clone)]
pub struct ArticleTextNode<'a> {
inner: Node<'a>,
confidence_score: f64,
}
impl<'a> ArticleTextNode<'a> {
pub fn new(inner: Node<'a>) -> Self {
Self {
inner,
confidence_score: 1.0,
}
}
pub fn with_confidence(inner: Node<'a>, confidence_score: f64) -> Self {
Self {
inner,
confidence_score,
}
}
pub fn confidence_score(&self) -> f64 {
self.confidence_score
}
pub fn clean_text(&self) -> String {
let raw_text = self.extract_clean_text();
Self::post_process_text(&raw_text)
}
fn extract_clean_text(&self) -> String {
let mut text_parts = Vec::new();
for para in self.inner.find(Name("p")) {
if para.is_noise_node() {
continue;
}
if Self::is_promotional_footer(¶) {
continue;
}
let text = para.text();
let trimmed = text.trim();
if !trimmed.is_empty() && !Self::is_noise_text(trimmed) {
text_parts.push(trimmed.to_string());
}
}
text_parts.join(" ")
}
fn is_promotional_footer(para: &Node) -> bool {
let links: Vec<_> = para.find(Name("a")).collect();
if links.is_empty() {
return false;
}
let all_nofollow = links.iter().all(|link| {
if let Some(rel) = link.attr("rel") {
rel.contains("nofollow")
} else {
false
}
});
all_nofollow
}
fn extract_content_text(&self) -> String {
let mut text_parts = Vec::new();
let paragraphs: Vec<_> = self.inner.find(Name("p"))
.filter(|n| !n.is_noise_node())
.filter(|n| n.text_content_length() >= 20) .collect();
if !paragraphs.is_empty() {
for para in paragraphs {
if let Some(text) = para.as_text() {
let trimmed = text.trim();
if !trimmed.is_empty() && Self::is_likely_content_text(trimmed) {
text_parts.push(trimmed.to_string());
}
}
}
}
if text_parts.is_empty() {
for node in self.inner.descendants() {
if node.is_noise_node() {
continue;
}
if Self::is_likely_code_node(&node) {
continue;
}
if let Some(text) = node.as_text() {
let trimmed = text.trim();
if !trimmed.is_empty() &&
trimmed.len() >= 10 &&
Self::is_likely_content_text(trimmed) {
text_parts.push(trimmed.to_string());
}
}
}
}
text_parts.join("\n\n")
}
fn is_likely_content_text(text: &str) -> bool {
let text = text.trim();
if text.len() < 10 {
return false;
}
if text.contains("function(") ||
text.contains("=>") ||
text.contains("const ") ||
text.contains("let ") ||
text.contains("var ") ||
text.contains("getCookie") ||
text.contains("navigator.") ||
text.contains("XMLHttpRequest") {
return false;
}
if text.starts_with("http://") || text.starts_with("https://") ||
text.contains("/dist/") || text.contains("/assets/") ||
text.contains(".css") || text.contains(".js") {
return false;
}
if (text.contains("class=") && text.contains("\"")) ||
(text.contains("src=") && text.contains("\"")) ||
(text.contains("alt=") && text.contains("\"")) ||
(text.contains("height=") && text.contains("\"")) {
return false;
}
if text.contains("webvisor:") ||
text.contains("clickmap:") ||
text.contains("trackLinks:") ||
text.contains("analytics.") {
return false;
}
let alpha_count = text.chars().filter(|c| c.is_alphabetic()).count();
let space_count = text.chars().filter(|c| c.is_whitespace()).count();
let total_chars = text.chars().count();
if total_chars == 0 {
return false;
}
let alpha_ratio = alpha_count as f64 / total_chars as f64;
let space_ratio = space_count as f64 / total_chars as f64;
alpha_ratio > 0.4 && space_ratio > 0.05 && alpha_ratio < 0.95
}
fn is_likely_code_node(node: &Node) -> bool {
if let Some(attr) = node.attr("onclick")
.or_else(|| node.attr("onload"))
.or_else(|| node.attr("onsubmit")) {
if attr.contains("function") || attr.contains("(") {
return true;
}
}
if let Some(parent) = node.parent() {
if parent.is(Name("script")) || parent.is(Name("style")) {
return true;
}
}
false
}
fn post_process_text(text: &str) -> String {
let text = text.replace('\u{a0}', " ");
let lines: Vec<&str> = text.lines().collect();
let mut cleaned_lines = Vec::new();
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if Self::is_code_like_line(trimmed) {
continue;
}
cleaned_lines.push(trimmed);
}
cleaned_lines.join("\n")
}
fn is_code_like_line(line: &str) -> bool {
let line = line.trim();
let word_count = line.split_whitespace().count();
let special_char_count = line.chars().filter(|c| !c.is_alphanumeric() && !c.is_whitespace()).count();
if word_count > 0 {
let special_ratio = special_char_count as f64 / line.len() as f64;
if special_ratio > 0.3 && word_count < 5 {
return true;
}
}
line.contains(";") && line.contains("=") && !line.contains(" ") ||
line.contains("()") && line.contains(".") ||
line.starts_with("src=") || line.starts_with("alt=") ||
line.starts_with("class=") || line.starts_with("height=")
}
fn is_noise_text(text: &str) -> bool {
let text = text.trim();
if text.len() < 10 {
return false; }
if text.contains("stylesheet") && text.contains(".css") ||
text.contains("rel=\"") && text.contains("href=\"") ||
text.contains("<script") || text.contains("<style") ||
text.contains("<link") || text.contains("<meta") {
return true;
}
if text.contains("position: absolute") && text.contains("left: -9999px") {
return true;
}
if text.starts_with("http://") || text.starts_with("https://") ||
text.contains("/dist/") || text.contains("/assets/") {
return true;
}
if text.contains("-") && text.contains(".") && text.chars().any(|c| c.is_uppercase()) {
let word_count = text.split_whitespace().count();
if word_count == 1 && text.len() > 20 {
return true;
}
}
false
}
fn is_noise_line(line: &str) -> bool {
let line = line.trim();
if line.contains('<') && line.contains('>') {
return true;
}
if line.contains("{") && line.contains("}") ||
line.contains(";") && line.contains(":") && !line.contains("://") ||
line.starts_with("//") || line.starts_with("/*") {
return true;
}
false
}
fn is_low_content_line(line: &str) -> bool {
let alpha_count = line.chars().filter(|c| c.is_alphabetic()).count();
let total_chars = line.chars().count();
if total_chars == 0 {
return true;
}
(alpha_count as f64 / total_chars as f64) < 0.3
}
pub fn images(&self, base_url: Option<&Url>) -> Vec<Url> {
let options = Url::options().base_url(base_url);
self.inner
.find(Name("img"))
.filter(|n| !n.is_noise_node())
.filter_map(|n| n.attr("src").or_else(|| n.attr("data-src")).map(str::trim))
.filter(|url| !url.is_empty())
.filter_map(|url| options.parse(url).ok())
.collect()
}
pub fn references(&self) -> Vec<Url> {
let mut uniques = HashSet::new();
DefaultDocumentCleaner
.iter_clean_nodes(self.inner)
.filter(|n| Name("a").matches(n))
.filter(|n| !n.is_noise_node())
.filter_map(|n| n.attr("href").map(str::trim))
.filter(|href| !href.is_empty())
.filter(|href| uniques.insert(*href))
.filter_map(|url| Url::parse(url).ok())
.collect()
}
pub fn videos(&self) -> Vec<VideoNode<'a>> {
let mut videos: Vec<_> = self
.inner
.find(VideoNode::node_predicate())
.filter(|n| !n.is_noise_node())
.map(VideoNode::new)
.collect();
videos.extend(
self.inner
.find(Name("embed"))
.filter(|n| !n.is_noise_node())
.filter(|n| {
if let Some(parent) = n.parent() {
parent.name() != Some("object")
} else {
false
}
})
.map(VideoNode::new),
);
videos
}
}
impl<'a> Deref for ArticleTextNode<'a> {
type Target = Node<'a>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
pub struct ArticleTextNodeExtractor;
impl ArticleTextNodeExtractor {
pub const MINIMUM_STOPWORD_COUNT: usize = 5;
pub const MAX_STEPSAWAY_FROM_NODE: usize = 3;
pub const MIN_TEXT_LENGTH: usize = 50;
pub const MAX_LINK_DENSITY: f64 = 0.5;
pub fn article_body_predicate() -> for<'r, 's> fn(&'r Node<'s>) -> bool {
|node| {
ARTICLE_BODY_ATTR.iter().any(|&(k, v)| Attr(k, v).matches(node))
}
}
pub fn calculate_best_node(doc: &Document, lang: Language) -> Option<ArticleTextNode> {
if let Some(article_node) = doc.find(Attr("itemprop", "articleBody")).next() {
return Some(ArticleTextNode::with_confidence(article_node, 0.95));
}
let mut starting_boost = 1.0;
let txt_nodes: Vec<_> = ArticleTextNodeExtractor::nodes_to_check(doc)
.filter(|n| !n.is_noise_node())
.filter_map(|node| {
let text = node.text();
let text_len = text.len();
if text_len < Self::MIN_TEXT_LENGTH {
return None;
}
if text.trim().is_empty() || ArticleTextNode::is_noise_text(&text) {
return None;
}
let link_density = node.link_density();
if link_density > Self::MAX_LINK_DENSITY {
return None;
}
if let Some(stats) = lang.stopword_count(&text) {
if stats.stopword_count >= Self::MINIMUM_STOPWORD_COUNT {
let score = Self::calculate_node_score(&node, stats.stopword_count);
return Some((node, stats, score));
}
}
None
})
.collect();
let mut nodes_scores = HashMap::with_capacity(txt_nodes.len());
let nodes_number = txt_nodes.len();
let negative_scoring = 0.0;
let bottom_negativescore_nodes = (nodes_number as f64 * 0.25).max(1.0);
for (i, (node, stats, base_score)) in txt_nodes.iter().enumerate() {
let mut boost_score = 0.0;
if ArticleTextNodeExtractor::is_boostable(node, lang.clone()) {
boost_score = (1.0 / starting_boost) * 50.0;
starting_boost += 1.0;
}
if nodes_number > 15 {
let score = (nodes_number - i) as f64;
if score <= bottom_negativescore_nodes {
let booster = bottom_negativescore_nodes - score;
boost_score = booster.powf(2.0) * -1.0;
let negscore = boost_score.abs() + negative_scoring;
if negscore > 40.0 {
boost_score = 5.0;
}
}
}
let formatting_bonus = Self::calculate_formatting_bonus(node);
let length_bonus = (stats.word_count as f64 / 100.0).min(5.0);
let upscore = (*base_score as f64 + boost_score + formatting_bonus + length_bonus) as usize;
Self::propagate_score_to_parents(node, upscore, &mut nodes_scores);
}
let (best_index, best_score) = nodes_scores
.iter()
.max_by_key(|(_, (score, _))| score)
.map(|(&idx, &(score, _))| (idx, score))
.unwrap_or((0, 0));
let confidence = Self::calculate_confidence(best_score, nodes_number);
Some(ArticleTextNode::with_confidence(
Node::new(doc, best_index).unwrap(),
confidence,
))
}
fn calculate_node_score(node: &Node, stopword_count: usize) -> usize {
let base_score = stopword_count;
let mut semantic_bonus = 0;
if let Some(itemprop) = node.attr("itemprop") {
semantic_bonus = match itemprop {
"articleBody" => 100, "articleText" => 40,
_ => 0,
};
}
if semantic_bonus == 0 {
if let Some(itemtype) = node.attr("itemtype") {
if itemtype.contains("schema.org/Article") || itemtype.contains("schema.org/NewsArticle") {
semantic_bonus = 30;
} else if itemtype.contains("schema.org/BlogPosting") {
semantic_bonus = 20;
}
}
}
if semantic_bonus == 0 {
if let Some(role) = node.attr("role") {
if role == "article" {
if node.name() == Some("article") {
semantic_bonus = 25; } else {
semantic_bonus = 15;
}
}
}
}
if semantic_bonus == 0 {
semantic_bonus = match node.name() {
Some("article") => 10,
Some("main") => 8,
Some("section") => 5,
Some("div") => 3,
_ => 0,
};
}
base_score + semantic_bonus
}
fn calculate_formatting_bonus(node: &Node) -> f64 {
let mut bonus = 0.0;
if node.find(Name("strong")).next().is_some() {
bonus += 2.0;
}
if node.find(Name("em")).next().is_some() {
bonus += 1.5;
}
if node.find(Name("b")).next().is_some() {
bonus += 1.0;
}
if node.find(Name("i")).next().is_some() {
bonus += 0.5;
}
let link_count = node.find(Name("a")).count();
if link_count > 5 {
bonus -= (link_count - 5) as f64 * 0.5;
}
bonus
}
fn propagate_score_to_parents(node: &Node, score: usize, nodes_scores: &mut HashMap<usize, (usize, usize)>) {
if let Some(parent) = node.parent() {
let entry = nodes_scores.entry(parent.index()).or_insert((0, 0));
entry.0 += score; entry.1 += 1;
if let Some(grandparent) = parent.parent() {
let grandparent_score = (score as f64 * 0.4) as usize;
let entry = nodes_scores.entry(grandparent.index()).or_insert((0, 0));
entry.0 += grandparent_score;
entry.1 += 1;
}
}
}
fn calculate_confidence(score: usize, total_nodes: usize) -> f64 {
if total_nodes == 0 {
return 0.0;
}
let normalized_score = score as f64 / (total_nodes * 10) as f64;
normalized_score.min(1.0).max(0.0)
}
fn nodes_to_check(doc: &Document) -> impl Iterator<Item = Node> {
TextNodeFind::new(doc)
}
fn is_boostable(node: &Node, lang: Language) -> bool {
let mut steps_away = 0;
let mut prev_sibling = node.prev();
while let Some(sibling) = prev_sibling.filter(|n| n.is(Name("p"))) {
if steps_away >= Self::MAX_STEPSAWAY_FROM_NODE {
break;
}
if Self::is_quality_paragraph(&sibling, lang.clone()) {
return true;
}
steps_away += 1;
prev_sibling = sibling.prev();
}
steps_away = 0;
let mut next_sibling = node.next();
while let Some(sibling) = next_sibling.filter(|n| n.is(Name("p"))) {
if steps_away >= Self::MAX_STEPSAWAY_FROM_NODE {
break;
}
if Self::is_quality_paragraph(&sibling, lang.clone()) {
return true;
}
steps_away += 1;
next_sibling = sibling.next();
}
false
}
fn is_quality_paragraph(node: &Node, lang: Language) -> bool {
if node.link_density() > Self::MAX_LINK_DENSITY {
return false;
}
if let Some(stats) = node
.first_children_text()
.and_then(|txt| lang.stopword_count(txt))
{
stats.stopword_count > Self::MINIMUM_STOPWORD_COUNT &&
stats.word_count >= Self::MIN_TEXT_LENGTH / 5
} else {
false
}
}
pub fn words(txt: &str) -> impl Iterator<Item = &str> {
txt.split(|c: char| c.is_whitespace() || is_punctuation(c))
.filter(|s| !s.is_empty())
}
}
pub fn is_punctuation(c: char) -> bool {
PUNCTUATION.contains(c)
}
pub fn author_text(node: Node) -> String {
if Name("meta").matches(&node) {
return node
.attr("content")
.map(str::to_string)
.unwrap_or_else(String::new);
}
let mut string = String::new();
let mut descendants = node.descendants();
let hidden = Class("hidden").or(Class("sr-only")).or(Class("visually-hidden"));
'outer: while let Some(child) = descendants.next() {
if hidden.matches(&child) || child.is_noise_node() {
for ignore in child.descendants() {
if let Some(next) = descendants.next() {
if ignore.index() != next.index() {
continue 'outer;
}
}
}
}
if let Some(txt) = child.as_text() {
if !ArticleTextNode::is_noise_text(txt) {
string.push_str(txt);
string.push(' '); }
}
}
string.trim().to_string()
}
#[derive(Debug, Clone)]
pub struct WordsStats {
pub word_count: usize,
pub stopword_count: usize,
pub avg_word_length: f64,
}