domtree/extract/readable.rs
1//! Main-content extraction ("reader mode"): [`Dom::main_content`].
2//!
3//! A small, deterministic cousin of Mozilla's Readability. Four linear passes
4//! over the document (pre-order flags, post-order text statistics, paragraph
5//! scoring, the semantic check) plus one walk up from the winner; no
6//! recursion, no allocation beyond a few per-node arrays.
7
8use alloc::vec;
9use alloc::vec::Vec;
10
11use super::{document_order, has_token};
12use crate::node::NodeKind;
13use crate::serialize::is_space;
14use crate::tags::{self, Display};
15use crate::tree::{Dom, NodeRef};
16
17/// Paragraphs shorter than this (non-whitespace characters) are not scored.
18const MIN_PARAGRAPH: usize = 25;
19
20/// An `<article>`/`<main>` needs this much text (or half the page's).
21const MIN_SEMANTIC: usize = 200;
22
23/// A paragraph's score goes to its parent in full, its grandparent halved, and
24/// three more ancestors with these divisors (as in Readability).
25const DIVIDERS: [f64; 5] = [1.0, 2.0, 6.0, 9.0, 12.0];
26
27/// Class/id words marking boilerplate. Matched against the words of `class`
28/// and `id` (split at anything not ASCII alphanumeric, case-insensitive) as a
29/// prefix, so `comment` also catches `comments` and `commentlist`.
30const NEGATIVE: &[&str] = &[
31 "nav",
32 "menu",
33 "header",
34 "masthead",
35 "footer",
36 "sidebar",
37 "aside",
38 "widget",
39 "comment",
40 "disqus",
41 "respond",
42 "share",
43 "sharing",
44 "social",
45 "related",
46 "advert",
47 "sponsor",
48 "promo",
49 "cookie",
50 "consent",
51 "gdpr",
52 "banner",
53 "breadcrumb",
54 "newsletter",
55 "subscribe",
56 "popup",
57 "modal",
58 "pagination",
59 "pager",
60 "skip",
61];
62
63/// Boilerplate words matched only as whole words (`ad` must not catch
64/// `address` or `admin`).
65const NEGATIVE_EXACT: &[&str] = &["ad", "ads", "toc"];
66
67/// Class/id words marking content (prefix match, like [`NEGATIVE`]).
68const POSITIVE: &[&str] = &[
69 "article", "content", "post", "entry", "main", "story", "body", "blog", "prose",
70];
71
72/// Content words matched only as whole words.
73const POSITIVE_EXACT: &[&str] = &["text"];
74
75/// Content words strong enough to keep an element with a boilerplate word
76/// (`comment-body`, `sidebar-content`) from being dropped before scoring.
77const KEEP: &[&str] = &["article", "content", "main", "body"];
78
79/// ARIA roles of boilerplate landmarks.
80const UNLIKELY_ROLES: &[&str] = &[
81 "navigation",
82 "complementary",
83 "contentinfo",
84 "banner",
85 "menu",
86 "menubar",
87 "search",
88 "dialog",
89 "alertdialog",
90];
91
92// Per-node flags.
93/// The node or an ancestor is not rendered.
94const HIDDEN: u8 = 1;
95/// The node or an ancestor is boilerplate.
96const UNLIKELY: u8 = 2;
97/// The node itself is boilerplate.
98const SELF_UNLIKELY: u8 = 4;
99/// Inside an `<article>`/`<main>` (where `<header>`/`<footer>` belong to it).
100const IN_SECTION: u8 = 8;
101/// A good `<article>` / `<main>` encloses (or is) this node.
102const IN_GOOD_ARTICLE: u8 = 1;
103const IN_GOOD_MAIN: u8 = 2;
104
105/// Text statistics of one subtree, in non-whitespace characters of visible
106/// text.
107#[derive(Clone, Copy, Default)]
108struct Stats {
109 text: usize,
110 /// Text inside links.
111 links: usize,
112 commas: usize,
113 /// Text in the element's own (direct) text children.
114 own: usize,
115 /// Text inside boilerplate elements.
116 unlikely: usize,
117 /// Some descendant is paragraph-like.
118 para_below: bool,
119}
120
121impl Dom {
122 /// The element holding the page's main content — the article, post or
123 /// documentation body without the navigation, sidebars, comments and
124 /// footer around it. The "reader mode" step before
125 /// `NodeRef::to_markdown` in an LLM or RAG pipeline.
126 ///
127 /// A small, deterministic heuristic in the spirit of Mozilla's
128 /// Readability. Text is measured in non-whitespace characters of visible
129 /// text (no `<script>`, `<style>`, `hidden`, `aria-hidden="true"` or
130 /// `display:none`). *Boilerplate* means `<nav>`, `<aside>`, a
131 /// `<header>`/`<footer>` that is not inside an `<article>`/`<main>`, an
132 /// element with a landmark `role` such as `navigation`, `complementary`
133 /// or `contentinfo`, or one whose `class`/`id` has a boilerplate word
134 /// (`nav`, `menu`, `header`, `footer`, `sidebar`, `comment`, `share`,
135 /// `social`, `related`, `ad`/`ads`/`advert`, `promo`, `cookie`,
136 /// `banner`, `breadcrumb`, …) and no strong content word (`article`,
137 /// `content`, `main`, `body`).
138 ///
139 /// 1. **Semantic markup first.** If exactly one outermost `<article>` has
140 /// enough text (200+ characters and at least a fifth of the page, or
141 /// half the page), at most half of it in links and at most a quarter in
142 /// boilerplate, and it is not itself inside boilerplate, it is the
143 /// answer. Otherwise the same test runs on `<main>` / `[role=main]`.
144 /// 2. **Score paragraphs.** Each innermost paragraph-like element
145 /// (`<p>`, `<pre>`, `<td>`, `<li>`, `<dd>`, or a block with 25+
146 /// characters of its own text) with 25+ characters, at most half of
147 /// them links, and not inside boilerplate scores `1 + commas +
148 /// min(chars / 100, 3)`. The score goes to its parent in full, to its
149 /// grandparent halved, and to three more ancestors divided by 6, 9 and
150 /// 12.
151 /// 3. **Pick the best container.** Each scored ancestor starts from a
152 /// base by tag (`div`/`article`/`main` +5, `pre`/`td`/`blockquote` +3,
153 /// lists and forms −3, headings −5) plus ±25 each for content /
154 /// boilerplate words in its `class` and in its `id`, adds its paragraph
155 /// scores, and is scaled by `1 − link density`. The highest final score
156 /// wins; ties go to the earlier element. If no paragraph qualified,
157 /// step 2 is retried with boilerplate allowed.
158 /// 4. **Widen to the whole article.** While the winner's parent adds no
159 /// boilerplate, at most as much text as the winner has, and mostly
160 /// non-link text (a title, a byline, the rest of a split body), the
161 /// parent becomes the winner — stopping below `<body>`.
162 ///
163 /// Returns `None` when no paragraph qualifies even with boilerplate allowed
164 /// (an empty, links-only or garbage page), or when the best "container" is the
165 /// document itself (paragraphs at the top level with no common parent
166 /// element). Use the whole document then. The chosen element is returned
167 /// as is: boilerplate *inside* it (a share bar in an article) is not
168 /// removed.
169 ///
170 /// ```
171 /// let dom = domtree::parse(r#"<body>
172 /// <nav><a href="/">Home</a> <a href="/blog">Blog</a></nav>
173 /// <div class="post">
174 /// <h1>Arena trees</h1>
175 /// <div class="entry">
176 /// <p>An arena stores every node in one vector, and links them by index.</p>
177 /// <p>Traversal is cheap, removal is a flag, and nothing is reference-counted.</p>
178 /// </div>
179 /// </div>
180 /// <div id="comments"><p>Great post, thanks for writing it up in so much detail!</p></div>
181 /// </body>"#);
182 ///
183 /// let main = dom.main_content().unwrap();
184 /// assert_eq!(main.attr("class"), Some("post"));
185 /// # #[cfg(feature = "markdown")]
186 /// assert!(main.to_markdown().starts_with("# Arena trees"));
187 /// assert!(!main.to_text().contains("Great post"));
188 /// assert!(domtree::parse("<nav><a href=/>Home</a></nav>").main_content().is_none());
189 /// ```
190 pub fn main_content(&self) -> Option<NodeRef<'_>> {
191 let order: Vec<NodeRef<'_>> = document_order(self).collect();
192 let size = self.len();
193 let mut flags = vec![0u8; size];
194 let mut stats = vec![Stats::default(); size];
195
196 // Pass 1 (pre-order): inherited flags.
197 for node in &order {
198 let inherited = node
199 .parent()
200 .and_then(|p| flags.get(p.id().index()))
201 .map_or(0, |f| f & (HIDDEN | UNLIKELY | IN_SECTION));
202 let mut f = inherited;
203 if let Some(tag) = node.tag_name() {
204 if is_hidden(*node, tag) {
205 f |= HIDDEN;
206 }
207 if is_unlikely(*node, tag, inherited & IN_SECTION != 0) {
208 f |= UNLIKELY | SELF_UNLIKELY;
209 }
210 if is_main(*node, tag) || tag == "article" {
211 f |= IN_SECTION;
212 }
213 }
214 if let Some(slot) = flags.get_mut(node.id().index()) {
215 *slot = f;
216 }
217 }
218
219 // Pass 2 (reverse pre-order, so children come before parents): text
220 // statistics.
221 let mut total = 0usize;
222 for node in order.iter().rev() {
223 let index = node.id().index();
224 let f = flags.get(index).copied().unwrap_or(HIDDEN);
225 if f & HIDDEN != 0 {
226 continue;
227 }
228 let (s, paragraph) = match node.kind() {
229 NodeKind::Text(t) => {
230 let (chars, commas) = measure(t);
231 let s = Stats {
232 text: chars,
233 commas,
234 ..Stats::default()
235 };
236 (s, false)
237 }
238 NodeKind::Element { tag } => {
239 let Some(s) = stats.get_mut(index) else {
240 continue;
241 };
242 if tag == "a" {
243 s.links = s.text;
244 }
245 (*s, paragraph_like(tag, s.own))
246 }
247 NodeKind::Comment(_) | NodeKind::Doctype(_) => continue,
248 };
249 let Some(parent) = node.parent() else {
250 total = total.saturating_add(s.text);
251 continue;
252 };
253 let Some(p) = stats.get_mut(parent.id().index()) else {
254 continue;
255 };
256 p.text = p.text.saturating_add(s.text);
257 p.links = p.links.saturating_add(s.links);
258 p.commas = p.commas.saturating_add(s.commas);
259 if node.is_text() {
260 p.own = p.own.saturating_add(s.text);
261 }
262 let unlikely = if f & SELF_UNLIKELY != 0 {
263 s.text
264 } else {
265 s.unlikely
266 };
267 p.unlikely = p.unlikely.saturating_add(unlikely);
268 p.para_below |= s.para_below || paragraph;
269 }
270
271 let stat = |n: NodeRef<'_>| stats.get(n.id().index()).copied().unwrap_or_default();
272 let flag = |n: NodeRef<'_>| flags.get(n.id().index()).copied().unwrap_or(HIDDEN);
273
274 // Step 1: a single good <article>, else a single good <main>.
275 let good = |n: NodeRef<'_>| {
276 let s = stat(n);
277 let enough = (s.text >= MIN_SEMANTIC && s.text.saturating_mul(5) >= total)
278 || s.text.saturating_mul(2) >= total;
279 s.text >= MIN_PARAGRAPH
280 && enough
281 && s.links.saturating_mul(2) <= s.text
282 && s.unlikely.saturating_mul(4) <= s.text
283 && flag(n) & (HIDDEN | UNLIKELY) == 0
284 };
285 let mut articles = Vec::new();
286 let mut mains = Vec::new();
287 let mut enclosing = vec![0u8; size];
288 for node in &order {
289 let inherited = node
290 .parent()
291 .and_then(|p| enclosing.get(p.id().index()))
292 .copied()
293 .unwrap_or(0);
294 let mut f = inherited;
295 if let Some(tag) = node.tag_name() {
296 if tag == "article" && good(*node) {
297 if inherited & IN_GOOD_ARTICLE == 0 {
298 articles.push(*node);
299 }
300 f |= IN_GOOD_ARTICLE;
301 }
302 if is_main(*node, tag) && good(*node) {
303 if inherited & IN_GOOD_MAIN == 0 {
304 mains.push(*node);
305 }
306 f |= IN_GOOD_MAIN;
307 }
308 }
309 if let Some(slot) = enclosing.get_mut(node.id().index()) {
310 *slot = f;
311 }
312 }
313 if let [only] = articles.as_slice() {
314 return Some(*only);
315 }
316 if let [only] = mains.as_slice() {
317 return Some(*only);
318 }
319
320 // Steps 2 and 3: score paragraphs into their ancestors; the document
321 // itself (paragraphs without a parent element) is the extra slot.
322 for skip_unlikely in [true, false] {
323 let mut scores: Vec<Option<f64>> = vec![None; size + 1];
324 let mut any = false;
325 for node in &order {
326 let Some(tag) = node.tag_name() else {
327 continue;
328 };
329 let s = stat(*node);
330 let f = flag(*node);
331 let blocked = f & HIDDEN != 0 || (skip_unlikely && f & UNLIKELY != 0);
332 if blocked
333 || s.para_below
334 || s.text < MIN_PARAGRAPH
335 || s.links.saturating_mul(2) > s.text
336 || !paragraph_like(tag, s.own)
337 {
338 continue;
339 }
340 any = true;
341 let score = 1.0 + s.commas as f64 + (s.text / 100).min(3) as f64;
342 let mut ancestor = node.parent();
343 for divider in DIVIDERS {
344 let slot = ancestor.map_or(size, |a| a.id().index());
345 if let Some(entry) = scores.get_mut(slot) {
346 let start = match (*entry, ancestor) {
347 (Some(sum), _) => sum,
348 (None, Some(a)) => base_score(a, a.tag_name().unwrap_or("")),
349 (None, None) => 0.0,
350 };
351 *entry = Some(start + score / divider);
352 }
353 match ancestor {
354 Some(a) => ancestor = a.parent(),
355 None => break,
356 }
357 }
358 }
359 if !any {
360 continue;
361 }
362
363 let density = |s: Stats| {
364 if s.text == 0 {
365 0.0
366 } else {
367 s.links as f64 / s.text as f64
368 }
369 };
370 // The document slot first: a real element must beat it.
371 let document = Stats {
372 text: total,
373 links: order
374 .iter()
375 .filter(|n| n.parent().is_none())
376 .map(|n| stat(*n).links)
377 .sum(),
378 ..Stats::default()
379 };
380 let mut best: Option<(Option<NodeRef<'_>>, f64)> = scores
381 .get(size)
382 .copied()
383 .flatten()
384 .map(|score| (None, score * (1.0 - density(document))));
385 for node in &order {
386 if let Some(Some(score)) = scores.get(node.id().index()) {
387 let fin = score * (1.0 - density(stat(*node)));
388 if best.is_none_or(|(_, b)| fin > b) {
389 best = Some((Some(*node), fin));
390 }
391 }
392 }
393 let mut winner = best?.0?;
394
395 // Step 4: widen to the whole article.
396 while let Some(parent) = winner.parent() {
397 if matches!(parent.tag_name(), Some("html" | "body") | None)
398 || flag(parent) & (HIDDEN | SELF_UNLIKELY) != 0
399 {
400 break;
401 }
402 let (c, p) = (stat(winner), stat(parent));
403 let own_unlikely = if flag(winner) & SELF_UNLIKELY != 0 {
404 c.text
405 } else {
406 c.unlikely
407 };
408 let extra = p.text.saturating_sub(c.text);
409 let extra_links = p.links.saturating_sub(c.links);
410 if p.unlikely > own_unlikely
411 || extra > c.text
412 || extra_links.saturating_mul(2) > extra
413 {
414 break;
415 }
416 winner = parent;
417 }
418 return Some(winner);
419 }
420 None
421 }
422}
423
424/// Non-whitespace characters and commas in a text node.
425fn measure(text: &str) -> (usize, usize) {
426 let mut chars = 0;
427 let mut commas = 0;
428 for c in text.chars() {
429 if !is_space(c) && !c.is_whitespace() {
430 chars += 1;
431 if matches!(c, ',' | ',' | '、') {
432 commas += 1;
433 }
434 }
435 }
436 (chars, commas)
437}
438
439/// Elements that hold a paragraph of text.
440fn paragraph_like(tag: &str, own: usize) -> bool {
441 match tag {
442 "p" | "pre" | "td" | "li" | "dd" => true,
443 "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "th" | "caption" | "summary" | "legend" => false,
444 _ => {
445 matches!(tags::display(tag), Display::Block | Display::Paragraph)
446 && own >= MIN_PARAGRAPH
447 }
448 }
449}
450
451fn is_main(node: NodeRef<'_>, tag: &str) -> bool {
452 tag == "main" || node.attr("role").is_some_and(|r| has_token(r, "main"))
453}
454
455fn is_hidden(node: NodeRef<'_>, tag: &str) -> bool {
456 if tags::display(tag) == Display::Hidden
457 || node.has_attr("hidden")
458 || node
459 .attr("aria-hidden")
460 .is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
461 {
462 return true;
463 }
464 node.attr("style").is_some_and(|style| {
465 let style: alloc::string::String = style
466 .chars()
467 .filter(|c| !c.is_whitespace())
468 .map(|c| c.to_ascii_lowercase())
469 .collect();
470 style.contains("display:none") || style.contains("visibility:hidden")
471 })
472}
473
474fn is_unlikely(node: NodeRef<'_>, tag: &str, in_section: bool) -> bool {
475 match tag {
476 "html" | "body" | "article" | "main" => return false,
477 "nav" | "aside" => return true,
478 "header" | "footer" if !in_section => return true,
479 _ => {}
480 }
481 if let Some(role) = node.attr("role") {
482 if has_token(role, "main") {
483 return false;
484 }
485 if UNLIKELY_ROLES.iter().any(|r| has_token(role, r)) {
486 return true;
487 }
488 }
489 let words = || {
490 node.attr("class")
491 .into_iter()
492 .chain(node.attr("id"))
493 .flat_map(words)
494 };
495 words().any(is_negative) && !words().any(|w| matches_any(w, KEEP, &[]))
496}
497
498/// The starting score of a candidate: by tag, plus ±25 for content /
499/// boilerplate words in its `class` and in its `id`.
500fn base_score(node: NodeRef<'_>, tag: &str) -> f64 {
501 let mut score = match tag {
502 "div" | "article" | "main" => 5.0,
503 "pre" | "td" | "blockquote" => 3.0,
504 "address" | "ol" | "ul" | "dl" | "dd" | "dt" | "li" | "form" => -3.0,
505 "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "th" => -5.0,
506 _ => 0.0,
507 };
508 for value in [node.attr("class"), node.attr("id")].into_iter().flatten() {
509 if words(value).any(is_negative) {
510 score -= 25.0;
511 }
512 if words(value).any(|w| matches_any(w, POSITIVE, POSITIVE_EXACT)) {
513 score += 25.0;
514 }
515 }
516 score
517}
518
519/// The ASCII-alphanumeric words of a `class` or `id` value.
520fn words(value: &str) -> impl Iterator<Item = &str> {
521 value
522 .split(|c: char| !c.is_ascii_alphanumeric())
523 .filter(|w| !w.is_empty())
524}
525
526fn is_negative(word: &str) -> bool {
527 matches_any(word, NEGATIVE, NEGATIVE_EXACT)
528}
529
530/// Whether `word` starts with one of `prefixes` or equals one of `exact`
531/// (ASCII case-insensitive).
532fn matches_any(word: &str, prefixes: &[&str], exact: &[&str]) -> bool {
533 prefixes.iter().any(|p| {
534 word.get(..p.len())
535 .is_some_and(|head| head.eq_ignore_ascii_case(p))
536 }) || exact.iter().any(|e| word.eq_ignore_ascii_case(e))
537}