use alloc::vec;
use alloc::vec::Vec;
use super::{document_order, has_token};
use crate::node::NodeKind;
use crate::serialize::is_space;
use crate::tags::{self, Display};
use crate::tree::{Dom, NodeRef};
const MIN_PARAGRAPH: usize = 25;
const MIN_SEMANTIC: usize = 200;
const DIVIDERS: [f64; 5] = [1.0, 2.0, 6.0, 9.0, 12.0];
const NEGATIVE: &[&str] = &[
"nav",
"menu",
"header",
"masthead",
"footer",
"sidebar",
"aside",
"widget",
"comment",
"disqus",
"respond",
"share",
"sharing",
"social",
"related",
"advert",
"sponsor",
"promo",
"cookie",
"consent",
"gdpr",
"banner",
"breadcrumb",
"newsletter",
"subscribe",
"popup",
"modal",
"pagination",
"pager",
"skip",
];
const NEGATIVE_EXACT: &[&str] = &["ad", "ads", "toc"];
const POSITIVE: &[&str] = &[
"article", "content", "post", "entry", "main", "story", "body", "blog", "prose",
];
const POSITIVE_EXACT: &[&str] = &["text"];
const KEEP: &[&str] = &["article", "content", "main", "body"];
const UNLIKELY_ROLES: &[&str] = &[
"navigation",
"complementary",
"contentinfo",
"banner",
"menu",
"menubar",
"search",
"dialog",
"alertdialog",
];
const HIDDEN: u8 = 1;
const UNLIKELY: u8 = 2;
const SELF_UNLIKELY: u8 = 4;
const IN_SECTION: u8 = 8;
const IN_GOOD_ARTICLE: u8 = 1;
const IN_GOOD_MAIN: u8 = 2;
const IN_GOOD_BODY: u8 = 4;
#[derive(Clone, Copy, Default)]
struct Stats {
text: usize,
links: usize,
commas: usize,
own: usize,
unlikely: usize,
para_below: bool,
block_below: bool,
para_text: usize,
}
impl Dom {
pub fn main_content(&self) -> Option<NodeRef<'_>> {
let order: Vec<NodeRef<'_>> = document_order(self).collect();
let size = self.len();
let mut flags = vec![0u8; size];
let mut stats = vec![Stats::default(); size];
for node in &order {
let inherited = node
.parent()
.and_then(|p| flags.get(p.id().index()))
.map_or(0, |f| f & (HIDDEN | UNLIKELY | IN_SECTION));
let mut f = inherited;
if let Some(tag) = node.tag_name() {
if is_hidden(*node, tag) {
f |= HIDDEN;
}
if is_unlikely(*node, tag, inherited & IN_SECTION != 0) {
f |= UNLIKELY | SELF_UNLIKELY;
}
if is_main(*node, tag) || tag == "article" {
f |= IN_SECTION;
}
}
if let Some(slot) = flags.get_mut(node.id().index()) {
*slot = f;
}
}
let mut total = 0usize;
for node in order.iter().rev() {
let index = node.id().index();
let f = flags.get(index).copied().unwrap_or(HIDDEN);
if f & HIDDEN != 0 {
continue;
}
let (s, paragraph) = match node.kind() {
NodeKind::Text(t) => {
let (chars, commas) = measure(t);
let s = Stats {
text: chars,
commas,
..Stats::default()
};
(s, false)
}
NodeKind::Element { tag } => {
let Some(s) = stats.get_mut(index) else {
continue;
};
if tag == "a" {
s.links = s.text;
}
(*s, paragraph_like(tag, *s))
}
NodeKind::Comment(_) | NodeKind::Doctype(_) => continue,
};
let Some(parent) = node.parent() else {
total = total.saturating_add(s.text);
continue;
};
let Some(p) = stats.get_mut(parent.id().index()) else {
continue;
};
p.text = p.text.saturating_add(s.text);
p.links = p.links.saturating_add(s.links);
p.commas = p.commas.saturating_add(s.commas);
if node.is_text() {
p.own = p.own.saturating_add(s.text);
}
let unlikely = if f & SELF_UNLIKELY != 0 {
s.text
} else {
s.unlikely
};
p.unlikely = p.unlikely.saturating_add(unlikely);
let scored = paragraph
&& !s.para_below
&& s.text >= MIN_PARAGRAPH
&& s.links.saturating_mul(2) <= s.text;
p.para_text = p
.para_text
.saturating_add(if scored { s.text } else { s.para_text });
p.para_below |= s.para_below || paragraph;
p.block_below |= s.block_below
|| node.tag_name().is_some_and(|tag| {
matches!(tags::display(tag), Display::Block | Display::Paragraph)
});
}
let stat = |n: NodeRef<'_>| stats.get(n.id().index()).copied().unwrap_or_default();
let flag = |n: NodeRef<'_>| flags.get(n.id().index()).copied().unwrap_or(HIDDEN);
let good = |n: NodeRef<'_>| {
let s = stat(n);
let enough = (s.text >= MIN_SEMANTIC && s.text.saturating_mul(5) >= total)
|| s.text.saturating_mul(2) >= total;
s.text >= MIN_PARAGRAPH
&& enough
&& s.links.saturating_mul(2) <= s.text
&& s.unlikely.saturating_mul(4) <= s.text
&& flag(n) & (HIDDEN | UNLIKELY) == 0
};
let mut bodies = Vec::new();
let mut articles = Vec::new();
let mut mains = Vec::new();
let mut enclosing = vec![0u8; size];
for node in &order {
let inherited = node
.parent()
.and_then(|p| enclosing.get(p.id().index()))
.copied()
.unwrap_or(0);
let mut f = inherited;
if let Some(tag) = node.tag_name() {
if is_article_body(*node) && good(*node) {
if inherited & IN_GOOD_BODY == 0 {
bodies.push(*node);
}
f |= IN_GOOD_BODY;
}
if tag == "article" && good(*node) {
if inherited & IN_GOOD_ARTICLE == 0 {
articles.push(*node);
}
f |= IN_GOOD_ARTICLE;
}
if is_main(*node, tag) && good(*node) {
if inherited & IN_GOOD_MAIN == 0 {
mains.push(*node);
}
f |= IN_GOOD_MAIN;
}
}
if let Some(slot) = enclosing.get_mut(node.id().index()) {
*slot = f;
}
}
if let [only] = bodies.as_slice() {
return Some(*only);
}
if let [only] = articles.as_slice() {
return Some(*only);
}
if let [only] = mains.as_slice() {
return Some(*only);
}
for skip_unlikely in [true, false] {
let mut scores: Vec<Option<f64>> = vec![None; size + 1];
let mut any = false;
for node in &order {
let Some(tag) = node.tag_name() else {
continue;
};
let s = stat(*node);
let f = flag(*node);
let blocked = f & HIDDEN != 0 || (skip_unlikely && f & UNLIKELY != 0);
if blocked
|| s.para_below
|| s.text < MIN_PARAGRAPH
|| s.links.saturating_mul(2) > s.text
|| !paragraph_like(tag, s)
{
continue;
}
any = true;
let score = 1.0 + s.commas as f64 + (s.text / 100).min(3) as f64;
let mut ancestor = node.parent();
for divider in DIVIDERS {
let slot = ancestor.map_or(size, |a| a.id().index());
if let Some(entry) = scores.get_mut(slot) {
let start = match (*entry, ancestor) {
(Some(sum), _) => sum,
(None, Some(a)) => base_score(a, a.tag_name().unwrap_or("")),
(None, None) => 0.0,
};
*entry = Some(start + score / divider);
}
match ancestor {
Some(a) => ancestor = a.parent(),
None => break,
}
}
}
if !any {
continue;
}
let density = |s: Stats| {
if s.text == 0 {
0.0
} else {
s.links as f64 / s.text as f64
}
};
let document = Stats {
text: total,
links: order
.iter()
.filter(|n| n.parent().is_none())
.map(|n| stat(*n).links)
.sum(),
..Stats::default()
};
let mut best: Option<(Option<NodeRef<'_>>, f64)> = scores
.get(size)
.copied()
.flatten()
.map(|score| (None, score * (1.0 - density(document))));
for node in &order {
if let Some(Some(score)) = scores.get(node.id().index()) {
let fin = score * (1.0 - density(stat(*node)));
if best.is_none_or(|(_, b)| fin > b) {
best = Some((Some(*node), fin));
}
}
}
let mut winner = best?.0?;
while let Some(parent) = winner.parent() {
if matches!(parent.tag_name(), Some("html" | "body") | None)
|| flag(parent) & (HIDDEN | SELF_UNLIKELY) != 0
{
break;
}
let (c, p) = (stat(winner), stat(parent));
let own_unlikely = if flag(winner) & SELF_UNLIKELY != 0 {
c.text
} else {
c.unlikely
};
let extra = p.text.saturating_sub(c.text);
let extra_links = p.links.saturating_sub(c.links);
let extra_para = p.para_text.saturating_sub(c.para_text);
let more_article = extra_para.saturating_mul(3) >= extra.saturating_mul(2);
let extra_unlikely = p.unlikely.saturating_sub(own_unlikely);
if extra_unlikely.saturating_mul(4) > extra
|| (extra > c.text && !more_article)
|| extra_links.saturating_mul(2) > extra
{
break;
}
winner = parent;
}
return Some(winner);
}
None
}
}
fn measure(text: &str) -> (usize, usize) {
let mut chars = 0;
let mut commas = 0;
for c in text.chars() {
if !is_space(c) && !c.is_whitespace() {
chars += 1;
if matches!(c, ',' | ',' | '、') {
commas += 1;
}
}
}
(chars, commas)
}
fn paragraph_like(tag: &str, s: Stats) -> bool {
match tag {
"p" | "pre" | "td" | "li" | "dd" => true,
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "th" | "caption" | "summary" | "legend" => false,
_ => {
matches!(tags::display(tag), Display::Block | Display::Paragraph)
&& (s.own >= MIN_PARAGRAPH || (!s.block_below && s.text >= MIN_PARAGRAPH))
}
}
}
fn is_article_body(node: NodeRef<'_>) -> bool {
node.attr("itemprop")
.is_some_and(|p| has_token(p, "articleBody"))
}
fn is_main(node: NodeRef<'_>, tag: &str) -> bool {
tag == "main" || node.attr("role").is_some_and(|r| has_token(r, "main"))
}
fn is_hidden(node: NodeRef<'_>, tag: &str) -> bool {
tags::display(tag) == Display::Hidden
|| node.hides_itself()
|| node
.attr("aria-hidden")
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
}
fn is_unlikely(node: NodeRef<'_>, tag: &str, in_section: bool) -> bool {
match tag {
"html" | "body" | "article" | "main" => return false,
"nav" | "aside" => return true,
"header" | "footer" if !in_section => return true,
_ => {}
}
if let Some(role) = node.attr("role") {
if has_token(role, "main") {
return false;
}
if UNLIKELY_ROLES.iter().any(|r| has_token(role, r)) {
return true;
}
}
let words = || {
node.attr("class")
.into_iter()
.chain(node.attr("id"))
.flat_map(words)
};
words().any(is_negative) && !words().any(|w| matches_any(w, KEEP, &[]))
}
fn base_score(node: NodeRef<'_>, tag: &str) -> f64 {
let mut score = match tag {
"div" | "article" | "main" => 5.0,
"pre" | "td" | "blockquote" => 3.0,
"address" | "ol" | "ul" | "dl" | "dd" | "dt" | "li" | "form" => -3.0,
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "th" => -5.0,
_ => 0.0,
};
for value in [node.attr("class"), node.attr("id")].into_iter().flatten() {
if words(value).any(is_negative) {
score -= 25.0;
}
if words(value).any(|w| matches_any(w, POSITIVE, POSITIVE_EXACT)) {
score += 25.0;
}
}
score
}
fn words(value: &str) -> impl Iterator<Item = &str> {
value
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|w| !w.is_empty())
}
fn is_negative(word: &str) -> bool {
matches_any(word, NEGATIVE, NEGATIVE_EXACT)
}
fn matches_any(word: &str, prefixes: &[&str], exact: &[&str]) -> bool {
prefixes.iter().any(|p| {
word.get(..p.len())
.is_some_and(|head| head.eq_ignore_ascii_case(p))
}) || exact.iter().any(|e| word.eq_ignore_ascii_case(e))
}