Skip to main content

lean_ctx/core/
html_crush.rs

1#![allow(clippy::too_many_lines, clippy::collapsible_if)]
2//! Deterministic HTML content extractor — extracts article/main content from
3//! web pages, converts to clean markdown, discards boilerplate (#1124).
4//!
5//! Web pages fetched by agents (documentation, issue trackers, Stack Overflow)
6//! contain ~90% non-informational tokens (navigation, ads, scripts, footers).
7//! This module extracts only the meaningful article content and converts it to
8//! markdown — the format agents work best with.
9//!
10//! Determinism (#498): output is a pure function of the input HTML — no
11//! timestamps, counters, or randomness. Same HTML always produces same markdown.
12
13use std::collections::VecDeque;
14
15pub const KEEP_DATA_DIVISOR: usize = 2;
16const MIN_HTML_BYTES: usize = 5000;
17const MAX_EXTRACTED_TOKENS: usize = 8000;
18const CHARS_PER_TOKEN_ESTIMATE: usize = 4;
19const TRACKING_QUERY_KEYS: &[&str] = &["fbclid", "gclid", "mc_cid", "mc_eid", "_ga", "ref_src"];
20
21/// A code block extracted from the selected article body.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct CodeBlock {
24    /// Language hint from `class="language-*"`/`class="lang-*"`, or empty.
25    pub language: String,
26    /// Verbatim text inside the `<pre>`/`<code>` element.
27    pub content: String,
28}
29
30/// Article metadata found in document `<meta>`, `<link>`, and `<time>` nodes.
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct ArticleMeta {
33    pub author: Option<String>,
34    pub date: Option<String>,
35    pub url: Option<String>,
36}
37
38/// Deterministic article extraction result.
39#[derive(Debug, Clone, PartialEq)]
40pub struct ExtractionResult {
41    pub title: Option<String>,
42    pub content: String,
43    pub code_blocks: Vec<CodeBlock>,
44    pub metadata: ArticleMeta,
45    /// Fraction of input bytes removed from the rendered article, in `[0, 1]`.
46    pub token_reduction: f64,
47}
48
49impl ExtractionResult {
50    /// Render the compact one-line source marker used by shell/proxy callers.
51    #[must_use]
52    pub fn metadata_line(&self) -> Option<String> {
53        let mut fields = Vec::new();
54        if let Some(url) = self.metadata.url.as_deref() {
55            fields.push(url);
56        }
57        if let Some(title) = self.title.as_deref() {
58            fields.push(title);
59        }
60        if let Some(date) = self.metadata.date.as_deref() {
61            fields.push(date);
62        }
63        (!fields.is_empty()).then(|| format!("Source: {}", fields.join(" | ")))
64    }
65}
66
67#[derive(Debug, Clone)]
68pub struct CrushResult {
69    pub text: String,
70    pub lossless: bool,
71    pub original_bytes: usize,
72    pub extracted_tokens: usize,
73}
74
75pub fn is_html_content(content: &str) -> bool {
76    let trimmed = content.trim_start().to_ascii_lowercase();
77    trimmed.starts_with("<!doctype")
78        || trimmed.starts_with("<html")
79        || trimmed.starts_with("<?xml")
80        || (trimmed.contains("<head") && trimmed.contains("<body"))
81}
82
83pub fn crush_if_beneficial(html: &str) -> Option<CrushResult> {
84    if html.len() < MIN_HTML_BYTES {
85        return None;
86    }
87    if !is_html_content(html) {
88        return None;
89    }
90
91    let extraction = extract_article_content(html);
92    if extraction.content.is_empty() {
93        return None;
94    }
95    let extracted = match extraction.metadata_line() {
96        Some(line) => format!("{line}\n\n{}", extraction.content),
97        None => extraction.content,
98    };
99
100    let original_tokens = html.len() / CHARS_PER_TOKEN_ESTIMATE;
101    let extracted_tokens = extracted.len() / CHARS_PER_TOKEN_ESTIMATE;
102
103    if extracted_tokens * KEEP_DATA_DIVISOR >= original_tokens {
104        return None;
105    }
106    if extracted_tokens > MAX_EXTRACTED_TOKENS {
107        let char_budget = MAX_EXTRACTED_TOKENS * CHARS_PER_TOKEN_ESTIMATE;
108        let mut end = char_budget.min(extracted.len());
109        while !extracted.is_char_boundary(end) && end > 0 {
110            end -= 1;
111        }
112        let truncated = format!(
113            "{}\n\n[… truncated, {} more tokens in original]",
114            &extracted[..end],
115            original_tokens - MAX_EXTRACTED_TOKENS
116        );
117        return Some(CrushResult {
118            text: truncated,
119            lossless: false,
120            original_bytes: html.len(),
121            extracted_tokens: MAX_EXTRACTED_TOKENS,
122        });
123    }
124
125    Some(CrushResult {
126        text: extracted,
127        lossless: false,
128        original_bytes: html.len(),
129        extracted_tokens,
130    })
131}
132
133pub fn extract_article(html: &str) -> String {
134    extract_article_content(html).content
135}
136
137/// Extract selected article content, metadata, and sacred code blocks.
138pub fn extract_article_content(html: &str) -> ExtractionResult {
139    let tokens = tokenize(html);
140    let nodes = build_tree(&tokens);
141    let article = select_main_content(&nodes);
142    let content = nodes_to_markdown(&article);
143    let (title, metadata) = extract_metadata(&nodes, &article);
144    let code_blocks = collect_code_blocks(&article);
145    let token_reduction = if html.is_empty() {
146        0.0
147    } else {
148        (1.0 - content.len() as f64 / html.len() as f64).clamp(0.0, 1.0)
149    };
150    ExtractionResult {
151        title,
152        content,
153        code_blocks,
154        metadata,
155        token_reduction,
156    }
157}
158
159// --- HTML Tokenizer ---
160
161#[derive(Debug, Clone, PartialEq)]
162enum HtmlToken {
163    OpenTag {
164        name: String,
165        attrs: Vec<(String, String)>,
166        self_closing: bool,
167    },
168    CloseTag {
169        name: String,
170    },
171    Text(String),
172}
173
174fn tokenize(html: &str) -> Vec<HtmlToken> {
175    let mut tokens = Vec::new();
176    let mut chars = html.chars().peekable();
177    let mut text_buf = String::new();
178
179    while let Some(&ch) = chars.peek() {
180        if ch == '<' {
181            if !text_buf.is_empty() {
182                let t = std::mem::take(&mut text_buf);
183                tokens.push(HtmlToken::Text(decode_entities(&t)));
184            }
185            chars.next();
186            if chars.peek() == Some(&'!') {
187                skip_comment_or_doctype(&mut chars);
188                continue;
189            }
190            let is_close = chars.peek() == Some(&'/');
191            if is_close {
192                chars.next();
193            }
194            let tag_name = consume_tag_name(&mut chars);
195            if tag_name.is_empty() {
196                text_buf.push('<');
197                if is_close {
198                    text_buf.push('/');
199                }
200                continue;
201            }
202            if is_close {
203                skip_until_gt(&mut chars);
204                tokens.push(HtmlToken::CloseTag {
205                    name: tag_name.to_ascii_lowercase(),
206                });
207            } else {
208                let (attrs, self_closing) = parse_attrs(&mut chars);
209                let name = tag_name.to_ascii_lowercase();
210                if is_raw_text_element(&name) {
211                    skip_raw_content(&mut chars, &name);
212                }
213                tokens.push(HtmlToken::OpenTag {
214                    name,
215                    attrs,
216                    self_closing,
217                });
218            }
219        } else {
220            text_buf.push(ch);
221            chars.next();
222        }
223    }
224    if !text_buf.is_empty() {
225        tokens.push(HtmlToken::Text(decode_entities(&text_buf)));
226    }
227    tokens
228}
229
230fn is_raw_text_element(name: &str) -> bool {
231    matches!(name, "script" | "style" | "noscript" | "template")
232}
233
234fn skip_raw_content(chars: &mut std::iter::Peekable<std::str::Chars>, tag: &str) {
235    let close_tag = format!("</{tag}");
236    let mut buf = String::new();
237    for ch in chars.by_ref() {
238        buf.push(ch);
239        if buf.ends_with(&close_tag) {
240            skip_until_gt(chars);
241            return;
242        }
243        if buf.len() > close_tag.len() + 100 {
244            buf.drain(..buf.len() - close_tag.len());
245        }
246    }
247}
248
249fn skip_comment_or_doctype(chars: &mut std::iter::Peekable<std::str::Chars>) {
250    for ch in chars.by_ref() {
251        if ch == '>' {
252            return;
253        }
254    }
255}
256
257fn consume_tag_name(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
258    let mut name = String::new();
259    while let Some(&ch) = chars.peek() {
260        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
261            name.push(ch);
262            chars.next();
263        } else {
264            break;
265        }
266    }
267    name
268}
269
270fn parse_attrs(chars: &mut std::iter::Peekable<std::str::Chars>) -> (Vec<(String, String)>, bool) {
271    let mut attrs = Vec::new();
272    let mut self_closing = false;
273
274    loop {
275        skip_whitespace(chars);
276        match chars.peek() {
277            None => break,
278            Some(&'>') => {
279                chars.next();
280                break;
281            }
282            Some(&'/') => {
283                chars.next();
284                if chars.peek() == Some(&'>') {
285                    chars.next();
286                    self_closing = true;
287                }
288                break;
289            }
290            _ => {}
291        }
292        let key = consume_attr_name(chars);
293        if key.is_empty() {
294            chars.next();
295            continue;
296        }
297        skip_whitespace(chars);
298        let value = if chars.peek() == Some(&'=') {
299            chars.next();
300            skip_whitespace(chars);
301            consume_attr_value(chars)
302        } else {
303            String::new()
304        };
305        attrs.push((key.to_ascii_lowercase(), value));
306    }
307    (attrs, self_closing)
308}
309
310fn consume_attr_name(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
311    let mut name = String::new();
312    while let Some(&ch) = chars.peek() {
313        if ch == '=' || ch == '>' || ch == '/' || ch.is_ascii_whitespace() {
314            break;
315        }
316        name.push(ch);
317        chars.next();
318    }
319    name
320}
321
322fn consume_attr_value(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
323    let mut value = String::new();
324    match chars.peek() {
325        Some(&'"') => {
326            chars.next();
327            for ch in chars.by_ref() {
328                if ch == '"' {
329                    break;
330                }
331                value.push(ch);
332            }
333        }
334        Some(&'\'') => {
335            chars.next();
336            for ch in chars.by_ref() {
337                if ch == '\'' {
338                    break;
339                }
340                value.push(ch);
341            }
342        }
343        _ => {
344            while let Some(&ch) = chars.peek() {
345                if ch.is_ascii_whitespace() || ch == '>' {
346                    break;
347                }
348                value.push(ch);
349                chars.next();
350            }
351        }
352    }
353    value
354}
355
356fn skip_whitespace(chars: &mut std::iter::Peekable<std::str::Chars>) {
357    while chars.peek().is_some_and(char::is_ascii_whitespace) {
358        chars.next();
359    }
360}
361
362fn skip_until_gt(chars: &mut std::iter::Peekable<std::str::Chars>) {
363    for ch in chars.by_ref() {
364        if ch == '>' {
365            return;
366        }
367    }
368}
369
370fn decode_entities(text: &str) -> String {
371    text.replace("&amp;", "&")
372        .replace("&lt;", "<")
373        .replace("&gt;", ">")
374        .replace("&quot;", "\"")
375        .replace("&#39;", "'")
376        .replace("&apos;", "'")
377        .replace("&nbsp;", " ")
378        .replace("&#x27;", "'")
379        .replace("&#x2F;", "/")
380}
381
382// --- Tree Builder ---
383
384#[derive(Debug, Clone)]
385struct HtmlNode {
386    tag: String,
387    attrs: Vec<(String, String)>,
388    children: Vec<HtmlNodeChild>,
389}
390
391#[derive(Debug, Clone)]
392enum HtmlNodeChild {
393    Element(HtmlNode),
394    Text(String),
395}
396
397const VOID_ELEMENTS: &[&str] = &[
398    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
399    "track", "wbr",
400];
401
402fn build_tree(tokens: &[HtmlToken]) -> Vec<HtmlNodeChild> {
403    let mut stack: VecDeque<HtmlNode> = VecDeque::new();
404    stack.push_back(HtmlNode {
405        tag: "root".into(),
406        attrs: vec![],
407        children: vec![],
408    });
409
410    for token in tokens {
411        match token {
412            HtmlToken::OpenTag {
413                name,
414                attrs,
415                self_closing,
416            } => {
417                if *self_closing || VOID_ELEMENTS.contains(&name.as_str()) {
418                    let node = HtmlNode {
419                        tag: name.clone(),
420                        attrs: attrs.clone(),
421                        children: vec![],
422                    };
423                    if let Some(parent) = stack.back_mut() {
424                        parent.children.push(HtmlNodeChild::Element(node));
425                    }
426                } else {
427                    stack.push_back(HtmlNode {
428                        tag: name.clone(),
429                        attrs: attrs.clone(),
430                        children: vec![],
431                    });
432                }
433            }
434            HtmlToken::CloseTag { name } => {
435                if let Some(pos) = stack.iter().rposition(|n| n.tag == *name) {
436                    while stack.len() > pos + 1 {
437                        let child = stack.pop_back().unwrap();
438                        if let Some(parent) = stack.back_mut() {
439                            parent.children.push(HtmlNodeChild::Element(child));
440                        }
441                    }
442                    let child = stack.pop_back().unwrap();
443                    if let Some(parent) = stack.back_mut() {
444                        parent.children.push(HtmlNodeChild::Element(child));
445                    }
446                }
447            }
448            HtmlToken::Text(text) => {
449                let trimmed = text.trim();
450                if !trimmed.is_empty()
451                    && let Some(parent) = stack.back_mut()
452                {
453                    parent.children.push(HtmlNodeChild::Text(text.clone()));
454                }
455            }
456        }
457    }
458
459    while stack.len() > 1 {
460        let child = stack.pop_back().unwrap();
461        if let Some(parent) = stack.back_mut() {
462            parent.children.push(HtmlNodeChild::Element(child));
463        }
464    }
465
466    stack.pop_back().map(|r| r.children).unwrap_or_default()
467}
468
469// --- Content Selection ---
470
471const BOILERPLATE_TAGS: &[&str] = &["nav", "footer", "header", "aside", "menu", "menuitem"];
472
473const BOILERPLATE_ROLES: &[&str] = &[
474    "navigation",
475    "banner",
476    "contentinfo",
477    "complementary",
478    "menu",
479];
480
481const BOILERPLATE_CLASSES: &[&str] = &[
482    "nav",
483    "navbar",
484    "footer",
485    "sidebar",
486    "menu",
487    "cookie",
488    "banner",
489    "advertisement",
490    "ad",
491    "social",
492    "share",
493    "comment",
494    "related",
495];
496
497fn select_main_content(nodes: &[HtmlNodeChild]) -> Vec<HtmlNodeChild> {
498    if let Some(main) = find_element_by_tag_or_role(nodes, "main", "main") {
499        return main.children.clone();
500    }
501    if let Some(article) = find_element_by_tag_or_role(nodes, "article", "") {
502        return article.children.clone();
503    }
504    if let Some(content) = find_element_by_id_class(
505        nodes,
506        &[
507            "content",
508            "main-content",
509            "post-content",
510            "entry-content",
511            "article-body",
512        ],
513    ) {
514        return content.children.clone();
515    }
516    if let Some(body) = find_element_by_tag(nodes, "body") {
517        return filter_boilerplate(&body.children);
518    }
519    filter_boilerplate(nodes)
520}
521
522fn find_element_by_tag_or_role<'a>(
523    nodes: &'a [HtmlNodeChild],
524    tag: &str,
525    role: &str,
526) -> Option<&'a HtmlNode> {
527    for child in nodes {
528        if let HtmlNodeChild::Element(el) = child {
529            if el.tag == tag {
530                return Some(el);
531            }
532            if !role.is_empty() && el.attrs.iter().any(|(k, v)| k == "role" && v == role) {
533                return Some(el);
534            }
535            if let Some(found) = find_element_by_tag_or_role(&el.children, tag, role) {
536                return Some(found);
537            }
538        }
539    }
540    None
541}
542
543fn find_element_by_tag<'a>(nodes: &'a [HtmlNodeChild], tag: &str) -> Option<&'a HtmlNode> {
544    for child in nodes {
545        if let HtmlNodeChild::Element(el) = child {
546            if el.tag == tag {
547                return Some(el);
548            }
549            if let Some(found) = find_element_by_tag(&el.children, tag) {
550                return Some(found);
551            }
552        }
553    }
554    None
555}
556
557fn find_element_by_id_class<'a>(
558    nodes: &'a [HtmlNodeChild],
559    candidates: &[&str],
560) -> Option<&'a HtmlNode> {
561    for child in nodes {
562        if let HtmlNodeChild::Element(el) = child {
563            let id = el
564                .attrs
565                .iter()
566                .find(|(k, _)| k == "id")
567                .map_or("", |(_, v)| v.as_str());
568            let class = el
569                .attrs
570                .iter()
571                .find(|(k, _)| k == "class")
572                .map_or("", |(_, v)| v.as_str());
573            if candidates
574                .iter()
575                .any(|c| id == *c || class.split_whitespace().any(|cls| cls == *c))
576            {
577                return Some(el);
578            }
579            if let Some(found) = find_element_by_id_class(&el.children, candidates) {
580                return Some(found);
581            }
582        }
583    }
584    None
585}
586
587fn attribute<'a>(el: &'a HtmlNode, name: &str) -> Option<&'a str> {
588    el.attrs
589        .iter()
590        .find(|(key, _)| key.eq_ignore_ascii_case(name))
591        .map(|(_, value)| value.as_str())
592}
593
594fn node_text(nodes: &[HtmlNodeChild]) -> String {
595    let mut text = String::new();
596    raw_text(nodes, &mut text);
597    normalize_whitespace(&text).trim().to_string()
598}
599
600fn raw_text(nodes: &[HtmlNodeChild], out: &mut String) {
601    for child in nodes {
602        match child {
603            HtmlNodeChild::Text(text) => out.push_str(text),
604            HtmlNodeChild::Element(el) => raw_text(&el.children, out),
605        }
606    }
607}
608
609fn find_meta_value(nodes: &[HtmlNodeChild], names: &[&str]) -> Option<String> {
610    for child in nodes {
611        let HtmlNodeChild::Element(el) = child else {
612            continue;
613        };
614        if el.tag == "meta"
615            && let Some(value) = attribute(el, "content")
616            && !value.trim().is_empty()
617            && names.iter().any(|name| {
618                attribute(el, "name")
619                    .or_else(|| attribute(el, "property"))
620                    .is_some_and(|actual| actual.eq_ignore_ascii_case(name))
621            })
622        {
623            return Some(value.trim().to_string());
624        }
625        if let Some(value) = find_meta_value(&el.children, names) {
626            return Some(value);
627        }
628    }
629    None
630}
631
632fn find_canonical_url(nodes: &[HtmlNodeChild]) -> Option<String> {
633    for child in nodes {
634        let HtmlNodeChild::Element(el) = child else {
635            continue;
636        };
637        if el.tag == "link"
638            && attribute(el, "rel").is_some_and(|rel| {
639                rel.split_whitespace()
640                    .any(|v| v.eq_ignore_ascii_case("canonical"))
641            })
642            && let Some(href) = attribute(el, "href")
643            && !href.trim().is_empty()
644        {
645            return Some(href.trim().to_string());
646        }
647        if let Some(value) = find_canonical_url(&el.children) {
648            return Some(value);
649        }
650    }
651    None
652}
653
654fn extract_metadata(
655    nodes: &[HtmlNodeChild],
656    article: &[HtmlNodeChild],
657) -> (Option<String>, ArticleMeta) {
658    let title = find_element_by_tag(nodes, "title")
659        .map(|el| node_text(&el.children))
660        .filter(|value| !value.is_empty())
661        .or_else(|| {
662            find_element_by_tag(article, "h1")
663                .map(|el| node_text(&el.children))
664                .filter(|value| !value.is_empty())
665        });
666    let author = find_meta_value(nodes, &["author", "article:author"]);
667    let date = find_meta_value(
668        nodes,
669        &[
670            "date",
671            "article:published_time",
672            "article:modified_time",
673            "pubdate",
674        ],
675    )
676    .or_else(|| {
677        find_element_by_tag(nodes, "time").and_then(|el| {
678            attribute(el, "datetime").map(str::to_string).or_else(|| {
679                let text = node_text(&el.children);
680                (!text.is_empty()).then_some(text)
681            })
682        })
683    });
684    let url = find_meta_value(nodes, &["og:url", "twitter:url", "url"])
685        .or_else(|| find_canonical_url(nodes));
686    (title, ArticleMeta { author, date, url })
687}
688
689fn collect_code_blocks(nodes: &[HtmlNodeChild]) -> Vec<CodeBlock> {
690    let mut blocks = Vec::new();
691    collect_code_blocks_inner(nodes, &mut blocks);
692    blocks
693}
694
695fn collect_code_blocks_inner(nodes: &[HtmlNodeChild], blocks: &mut Vec<CodeBlock>) {
696    for child in nodes {
697        let HtmlNodeChild::Element(el) = child else {
698            continue;
699        };
700        if el.tag == "pre" || el.tag == "code" {
701            let mut content = String::new();
702            raw_text(&el.children, &mut content);
703            blocks.push(CodeBlock {
704                language: if el.tag == "pre" {
705                    detect_code_language(el)
706                } else {
707                    code_language(el)
708                },
709                content,
710            });
711            if el.tag == "pre" {
712                continue;
713            }
714        }
715        collect_code_blocks_inner(&el.children, blocks);
716    }
717}
718
719fn filter_boilerplate(nodes: &[HtmlNodeChild]) -> Vec<HtmlNodeChild> {
720    nodes
721        .iter()
722        .filter(|child| {
723            if let HtmlNodeChild::Element(el) = child {
724                !is_boilerplate(el)
725            } else {
726                true
727            }
728        })
729        .cloned()
730        .collect()
731}
732
733fn is_boilerplate(el: &HtmlNode) -> bool {
734    if BOILERPLATE_TAGS.contains(&el.tag.as_str()) {
735        return true;
736    }
737    let role = el
738        .attrs
739        .iter()
740        .find(|(k, _)| k == "role")
741        .map_or("", |(_, v)| v.as_str());
742    if BOILERPLATE_ROLES.contains(&role) {
743        return true;
744    }
745    let class = el
746        .attrs
747        .iter()
748        .find(|(k, _)| k == "class")
749        .map_or("", |(_, v)| v.as_str());
750    BOILERPLATE_CLASSES
751        .iter()
752        .any(|bc| class.split_whitespace().any(|cls| cls.contains(bc)))
753}
754
755// --- Markdown Converter ---
756
757fn nodes_to_markdown(nodes: &[HtmlNodeChild]) -> String {
758    let mut output = String::new();
759    render_nodes(nodes, &mut output, &mut RenderState::default());
760    collapse_whitespace(&output)
761}
762
763#[derive(Default)]
764struct RenderState {
765    list_depth: usize,
766    ordered_counter: Vec<usize>,
767    in_pre: bool,
768}
769
770fn render_nodes(nodes: &[HtmlNodeChild], out: &mut String, state: &mut RenderState) {
771    for child in nodes {
772        match child {
773            HtmlNodeChild::Text(text) => {
774                if state.in_pre {
775                    out.push_str(text);
776                } else {
777                    let normalized = normalize_whitespace(text);
778                    if !normalized.is_empty() {
779                        out.push_str(&normalized);
780                    }
781                }
782            }
783            HtmlNodeChild::Element(el) => render_element(el, out, state),
784        }
785    }
786}
787
788fn collapse_tracking_parameters(href: &str) -> String {
789    let Some((base, query_and_fragment)) = href.split_once('?') else {
790        return href.to_string();
791    };
792    let (query, fragment) = query_and_fragment
793        .split_once('#')
794        .map_or((query_and_fragment, ""), |parts| parts);
795    let kept: Vec<&str> = query
796        .split('&')
797        .filter(|part| {
798            let key = part.split_once('=').map_or(*part, |(key, _)| key);
799            let lower = key.to_ascii_lowercase();
800            !lower.starts_with("utm_") && !TRACKING_QUERY_KEYS.contains(&lower.as_str())
801        })
802        .collect();
803    let mut result = base.to_string();
804    if !kept.is_empty() {
805        result.push('?');
806        result.push_str(&kept.join("&"));
807    }
808    if !fragment.is_empty() {
809        result.push('#');
810        result.push_str(fragment);
811    }
812    result
813}
814
815fn render_element(el: &HtmlNode, out: &mut String, state: &mut RenderState) {
816    match el.tag.as_str() {
817        "h1" => {
818            ensure_newlines(out, 2);
819            out.push_str("# ");
820            render_nodes(&el.children, out, state);
821            ensure_newlines(out, 2);
822        }
823        "h2" => {
824            ensure_newlines(out, 2);
825            out.push_str("## ");
826            render_nodes(&el.children, out, state);
827            ensure_newlines(out, 2);
828        }
829        "h3" => {
830            ensure_newlines(out, 2);
831            out.push_str("### ");
832            render_nodes(&el.children, out, state);
833            ensure_newlines(out, 2);
834        }
835        "h4" => {
836            ensure_newlines(out, 2);
837            out.push_str("#### ");
838            render_nodes(&el.children, out, state);
839            ensure_newlines(out, 2);
840        }
841        "h5" => {
842            ensure_newlines(out, 2);
843            out.push_str("##### ");
844            render_nodes(&el.children, out, state);
845            ensure_newlines(out, 2);
846        }
847        "h6" => {
848            ensure_newlines(out, 2);
849            out.push_str("###### ");
850            render_nodes(&el.children, out, state);
851            ensure_newlines(out, 2);
852        }
853        "p" | "div" | "section" | "article" => {
854            ensure_newlines(out, 2);
855            render_nodes(&el.children, out, state);
856            ensure_newlines(out, 2);
857        }
858        "br" => {
859            out.push('\n');
860        }
861        "hr" => {
862            ensure_newlines(out, 2);
863            out.push_str("---");
864            ensure_newlines(out, 2);
865        }
866        "strong" | "b" => {
867            out.push_str("**");
868            render_nodes(&el.children, out, state);
869            out.push_str("**");
870        }
871        "em" | "i" => {
872            out.push('*');
873            render_nodes(&el.children, out, state);
874            out.push('*');
875        }
876        "code" if !state.in_pre => {
877            out.push('`');
878            render_nodes(&el.children, out, state);
879            out.push('`');
880        }
881        "pre" => {
882            ensure_newlines(out, 2);
883            let lang = detect_code_language(el);
884            out.push_str("```");
885            out.push_str(&lang);
886            out.push('\n');
887            state.in_pre = true;
888            render_nodes(&el.children, out, state);
889            state.in_pre = false;
890            if !out.ends_with('\n') {
891                out.push('\n');
892            }
893            out.push_str("```");
894            ensure_newlines(out, 2);
895        }
896        "a" => {
897            let href = el
898                .attrs
899                .iter()
900                .find(|(k, _)| k == "href")
901                .map_or("", |(_, v)| v.as_str());
902            let mut link_text = String::new();
903            render_nodes(&el.children, &mut link_text, state);
904            let link_text = link_text.trim().to_string();
905            if !link_text.is_empty()
906                && !href.is_empty()
907                && !href.starts_with('#')
908                && !href.starts_with("javascript:")
909            {
910                out.push('[');
911                out.push_str(&link_text);
912                out.push_str("](");
913                out.push_str(&collapse_tracking_parameters(href));
914                out.push(')');
915            } else if !link_text.is_empty() {
916                out.push_str(&link_text);
917            }
918        }
919        "ul" => {
920            ensure_newlines(out, 2);
921            state.list_depth += 1;
922            render_nodes(&el.children, out, state);
923            state.list_depth -= 1;
924            ensure_newlines(out, 2);
925        }
926        "ol" => {
927            ensure_newlines(out, 2);
928            state.list_depth += 1;
929            state.ordered_counter.push(0);
930            render_nodes(&el.children, out, state);
931            state.ordered_counter.pop();
932            state.list_depth -= 1;
933            ensure_newlines(out, 2);
934        }
935        "li" => {
936            ensure_newlines(out, 1);
937            let indent = "  ".repeat(state.list_depth.saturating_sub(1));
938            out.push_str(&indent);
939            if let Some(counter) = state.ordered_counter.last_mut() {
940                *counter += 1;
941                out.push_str(&format!("{counter}. "));
942            } else {
943                out.push_str("- ");
944            }
945            render_nodes(&el.children, out, state);
946        }
947        "blockquote" => {
948            ensure_newlines(out, 2);
949            let mut inner = String::new();
950            render_nodes(&el.children, &mut inner, state);
951            for line in inner.trim().lines() {
952                out.push_str("> ");
953                out.push_str(line);
954                out.push('\n');
955            }
956            ensure_newlines(out, 2);
957        }
958        "table" => {
959            ensure_newlines(out, 2);
960            render_table(el, out, state);
961            ensure_newlines(out, 2);
962        }
963        "img" => {
964            let alt = el
965                .attrs
966                .iter()
967                .find(|(k, _)| k == "alt")
968                .map_or("", |(_, v)| v.as_str());
969            let src = el
970                .attrs
971                .iter()
972                .find(|(k, _)| k == "src")
973                .map_or("", |(_, v)| v.as_str());
974            if !alt.is_empty() && !src.is_empty() {
975                out.push_str(&format!("![{alt}]({src})"));
976            }
977        }
978        _ => {
979            render_nodes(&el.children, out, state);
980        }
981    }
982}
983
984fn render_table(el: &HtmlNode, out: &mut String, state: &mut RenderState) {
985    let rows = collect_table_rows(el);
986    if rows.is_empty() {
987        return;
988    }
989
990    let col_count = rows.iter().map(Vec::len).max().unwrap_or(0);
991    if col_count == 0 {
992        return;
993    }
994
995    for (i, row) in rows.iter().enumerate() {
996        out.push('|');
997        for col in 0..col_count {
998            let cell = row.get(col).map_or("", String::as_str);
999            out.push(' ');
1000            out.push_str(cell.trim());
1001            out.push_str(" |");
1002        }
1003        out.push('\n');
1004        if i == 0 {
1005            out.push('|');
1006            for _ in 0..col_count {
1007                out.push_str(" --- |");
1008            }
1009            out.push('\n');
1010        }
1011    }
1012    let _ = state;
1013}
1014
1015fn collect_table_rows(el: &HtmlNode) -> Vec<Vec<String>> {
1016    let mut rows = Vec::new();
1017    collect_rows_recursive(el, &mut rows);
1018    rows
1019}
1020
1021fn collect_rows_recursive(el: &HtmlNode, rows: &mut Vec<Vec<String>>) {
1022    if el.tag == "tr" {
1023        let cells: Vec<String> = el
1024            .children
1025            .iter()
1026            .filter_map(|child| {
1027                if let HtmlNodeChild::Element(cell) = child {
1028                    if cell.tag == "td" || cell.tag == "th" {
1029                        let mut text = String::new();
1030                        render_nodes(&cell.children, &mut text, &mut RenderState::default());
1031                        return Some(text.trim().to_string());
1032                    }
1033                }
1034                None
1035            })
1036            .collect();
1037        if !cells.is_empty() {
1038            rows.push(cells);
1039        }
1040    }
1041    for child in &el.children {
1042        if let HtmlNodeChild::Element(child_el) = child {
1043            collect_rows_recursive(child_el, rows);
1044        }
1045    }
1046}
1047
1048fn code_language(el: &HtmlNode) -> String {
1049    let class = attribute(el, "class").unwrap_or("");
1050    for cls in class.split_whitespace() {
1051        if let Some(lang) = cls.strip_prefix("language-") {
1052            return lang.to_string();
1053        }
1054        if let Some(lang) = cls.strip_prefix("lang-") {
1055            return lang.to_string();
1056        }
1057        if matches!(
1058            cls,
1059            "rust"
1060                | "python"
1061                | "javascript"
1062                | "typescript"
1063                | "go"
1064                | "java"
1065                | "c"
1066                | "cpp"
1067                | "ruby"
1068                | "bash"
1069                | "sh"
1070                | "json"
1071                | "yaml"
1072                | "toml"
1073                | "sql"
1074                | "html"
1075                | "css"
1076        ) {
1077            return cls.to_string();
1078        }
1079    }
1080    String::new()
1081}
1082
1083fn detect_code_language(pre: &HtmlNode) -> String {
1084    for child in &pre.children {
1085        if let HtmlNodeChild::Element(code) = child {
1086            if code.tag == "code" {
1087                let language = code_language(code);
1088                if !language.is_empty() {
1089                    return language;
1090                }
1091            }
1092        }
1093    }
1094    code_language(pre)
1095}
1096
1097fn normalize_whitespace(text: &str) -> String {
1098    let mut result = String::with_capacity(text.len());
1099    let mut last_was_space = false;
1100    for ch in text.chars() {
1101        if ch.is_ascii_whitespace() {
1102            if !last_was_space {
1103                result.push(' ');
1104                last_was_space = true;
1105            }
1106        } else {
1107            result.push(ch);
1108            last_was_space = false;
1109        }
1110    }
1111    result
1112}
1113
1114fn ensure_newlines(out: &mut String, count: usize) {
1115    let trailing_newlines = out.chars().rev().take_while(|&c| c == '\n').count();
1116    for _ in trailing_newlines..count {
1117        out.push('\n');
1118    }
1119}
1120
1121fn collapse_whitespace(text: &str) -> String {
1122    let mut result = String::with_capacity(text.len());
1123    let mut consecutive_newlines = 0u32;
1124
1125    for ch in text.chars() {
1126        if ch == '\n' {
1127            consecutive_newlines += 1;
1128            if consecutive_newlines <= 2 {
1129                result.push('\n');
1130            }
1131        } else {
1132            consecutive_newlines = 0;
1133            result.push(ch);
1134        }
1135    }
1136    result.trim().to_string()
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142
1143    #[test]
1144    fn detects_html_content() {
1145        assert!(is_html_content(
1146            "<!DOCTYPE html><html><body>hi</body></html>"
1147        ));
1148        assert!(is_html_content(
1149            "  <html lang='en'><head></head><body></body></html>"
1150        ));
1151        assert!(!is_html_content("just plain text"));
1152        assert!(!is_html_content("{\"json\": true}"));
1153    }
1154
1155    #[test]
1156    fn extracts_article_element() {
1157        let html = r"
1158            <html><body>
1159                <nav>Navigation stuff</nav>
1160                <article>
1161                    <h1>Title</h1>
1162                    <p>Important content here.</p>
1163                </article>
1164                <footer>Footer junk</footer>
1165            </body></html>
1166        ";
1167        let result = extract_article(html);
1168        assert!(result.contains("# Title"));
1169        assert!(result.contains("Important content here."));
1170        assert!(!result.contains("Navigation stuff"));
1171        assert!(!result.contains("Footer junk"));
1172    }
1173
1174    #[test]
1175    fn extracts_main_element() {
1176        let html = r"
1177            <html><body>
1178                <header>Header</header>
1179                <main>
1180                    <h2>Main Content</h2>
1181                    <p>The real stuff.</p>
1182                </main>
1183                <aside>Sidebar</aside>
1184            </body></html>
1185        ";
1186        let result = extract_article(html);
1187        assert!(result.contains("## Main Content"));
1188        assert!(result.contains("The real stuff."));
1189        assert!(!result.contains("Header"));
1190        assert!(!result.contains("Sidebar"));
1191    }
1192
1193    #[test]
1194    fn preserves_code_blocks() {
1195        let html = r#"
1196            <html><body><article>
1197                <p>Example:</p>
1198                <pre><code class="language-rust">fn main() {
1199    println!("hello");
1200}</code></pre>
1201            </article></body></html>
1202        "#;
1203        let result = extract_article(html);
1204        assert!(result.contains("```rust"));
1205        assert!(result.contains("fn main()"));
1206        assert!(result.contains("```"));
1207    }
1208
1209    #[test]
1210    fn converts_links() {
1211        let html = r#"<html><body><article><p>See <a href="https://example.com">docs</a></p></article></body></html>"#;
1212        let result = extract_article(html);
1213        assert!(result.contains("[docs](https://example.com)"));
1214    }
1215
1216    #[test]
1217    fn converts_tables() {
1218        let html = r"
1219            <html><body><article>
1220                <table>
1221                    <tr><th>Name</th><th>Value</th></tr>
1222                    <tr><td>foo</td><td>42</td></tr>
1223                </table>
1224            </article></body></html>
1225        ";
1226        let result = extract_article(html);
1227        assert!(result.contains("| Name | Value |"));
1228        assert!(result.contains("| foo | 42 |"));
1229    }
1230
1231    #[test]
1232    fn crush_rejects_small_input() {
1233        let small = "<html><body><p>hi</p></body></html>";
1234        assert!(crush_if_beneficial(small).is_none());
1235    }
1236
1237    #[test]
1238    fn crush_is_deterministic() {
1239        let html = format!(
1240            "<html><body><nav>{}</nav><article><h1>Title</h1><p>{}</p></article><footer>{}</footer></body></html>",
1241            "x".repeat(3000),
1242            "content ".repeat(200),
1243            "y".repeat(3000)
1244        );
1245        let r1 = crush_if_beneficial(&html);
1246        let r2 = crush_if_beneficial(&html);
1247        assert_eq!(r1.as_ref().map(|r| &r.text), r2.as_ref().map(|r| &r.text));
1248    }
1249}
1250
1251#[cfg(test)]
1252mod edge_tests {
1253    use super::*;
1254
1255    #[test]
1256    fn handles_malformed_html_gracefully() {
1257        let broken = "<html><body><div><p>Unclosed paragraph<div>Nested wrong</p></div>";
1258        let result = extract_article(broken);
1259        assert!(result.contains("Unclosed paragraph") || result.contains("Nested wrong"));
1260    }
1261
1262    #[test]
1263    fn handles_empty_html() {
1264        let empty = "<html><body></body></html>";
1265        assert!(crush_if_beneficial(empty).is_none());
1266    }
1267
1268    #[test]
1269    fn handles_unicode_content() {
1270        let html = "<html><body><article><h1>\u{65E5}\u{672C}\u{8A9E}</h1><p>\u{00DC}nic\u{00F6}d\u{00E9} with emojis \u{1F680}</p></article></body></html>";
1271        let result = extract_article(html);
1272        assert!(result.contains("\u{65E5}\u{672C}\u{8A9E}"));
1273        assert!(result.contains("\u{1F680}"));
1274    }
1275
1276    #[test]
1277    fn handles_deeply_nested_structures() {
1278        let mut html = String::from("<html><body><article>");
1279        for i in 0..50 {
1280            html.push_str(&format!("<div><p>Level {i}</p>"));
1281        }
1282        for _ in 0..50 {
1283            html.push_str("</div>");
1284        }
1285        html.push_str("</article></body></html>");
1286        let result = extract_article(&html);
1287        assert!(result.contains("Level 0"));
1288        assert!(result.contains("Level 49"));
1289    }
1290
1291    #[test]
1292    fn handles_script_and_style_exclusion() {
1293        let html = "<html><body><article><script>var x = 'not content';</script><style>.foo{}</style><p>Real content.</p></article></body></html>";
1294        let result = extract_article(html);
1295        assert!(result.contains("Real content."));
1296        assert!(!result.contains("not content"));
1297        assert!(!result.contains(".foo"));
1298    }
1299
1300    #[test]
1301    fn handles_entities_correctly() {
1302        let html =
1303            "<html><body><article><p>5 &gt; 3 &amp;&amp; 2 &lt; 4</p></article></body></html>";
1304        let result = extract_article(html);
1305        assert!(result.contains("5 > 3 && 2 < 4"));
1306    }
1307
1308    #[test]
1309    fn handles_empty_article() {
1310        let html = "<html><body><article>   \n\t  </article></body></html>";
1311        let result = extract_article(html);
1312        assert!(result.trim().is_empty());
1313    }
1314}