Skip to main content

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}