Skip to main content

lean_ctx_compress/
html_crush.rs

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