Skip to main content

headless_engine/dom/
markdown.rs

1use scraper::node::Node;
2use scraper::{ElementRef, Html, Selector};
3
4pub struct HtmlToMarkdown;
5
6impl HtmlToMarkdown {
7    pub fn convert(html_str: &str, base_url: Option<&str>) -> String {
8        let document = Html::parse_document(html_str);
9
10        // Try extracting high-signal main content container first
11        let content_candidates = [
12            "#search",
13            "#rso",
14            "#rcnt",
15            "#mw-content-text",
16            "article",
17            "main",
18            "div[role='main']",
19            "div.main-content",
20            "div.post-content",
21            "div.article-body",
22            "div#content",
23            "body",
24        ];
25
26        let mut root_element = document.root_element();
27        for candidate in content_candidates {
28            if let Ok(sel) = Selector::parse(candidate) {
29                if let Some(el) = document.select(&sel).next() {
30                    root_element = el;
31                    break;
32                }
33            }
34        }
35
36        let mut output = String::new();
37        Self::walk_node(&root_element, &mut output, base_url);
38
39        Self::clean_markdown(&output)
40    }
41
42    pub fn convert_element(element: &ElementRef, base_url: Option<&str>) -> String {
43        let mut output = String::new();
44        Self::walk_node(element, &mut output, base_url);
45        Self::clean_markdown(&output)
46    }
47
48    fn walk_node(element: &ElementRef, output: &mut String, base_url: Option<&str>) {
49        let tag_name = element.value().name();
50
51        // 1. Skip non-content / hidden tags
52        match tag_name {
53            "script" | "style" | "noscript" | "svg" | "canvas" | "iframe" | "header" | "footer"
54            | "nav" | "dialog" | "aside" => return,
55            _ => {}
56        }
57
58        // 2. Skip hidden and accessibility boilerplate elements
59        if element.value().attr("aria-hidden") == Some("true")
60            || element.value().attr("hidden").is_some()
61        {
62            return;
63        }
64
65        if let Some(role) = element.value().attr("role") {
66            if role == "navigation" || role == "banner" || role == "contentinfo" || role == "search"
67            {
68                return;
69            }
70        }
71
72        if let Some(id) = element.value().attr("id") {
73            if id == "searchform"
74                || id == "fbar"
75                || id == "appbar"
76                || id == "footcnt"
77                || id == "before-appbar"
78                || id == "gb"
79            {
80                return;
81            }
82        }
83
84        if let Some(class) = element.value().attr("class") {
85            if class.contains("appbar")
86                || class.contains("fbar")
87                || class.contains("minidiv")
88                || class.contains("action-menu")
89                || class.contains("cookie-banner")
90            {
91                return;
92            }
93        }
94
95        match tag_name {
96            "h1" => output.push_str("\n\n# "),
97            "h2" => output.push_str("\n\n## "),
98            "h3" => output.push_str("\n\n### "),
99            "h4" => output.push_str("\n\n#### "),
100            "h5" => output.push_str("\n\n##### "),
101            "h6" => output.push_str("\n\n###### "),
102            "p" | "section" | "article" => output.push_str("\n\n"),
103            "br" => output.push('\n'),
104            "hr" => output.push_str("\n\n---\n\n"),
105            "blockquote" => output.push_str("\n\n> "),
106            "li" => output.push_str("\n- "),
107            "pre" => output.push_str("\n\n```\n"),
108            "code" => output.push('`'),
109            "strong" | "b" => output.push_str("**"),
110            "em" | "i" => output.push('*'),
111            "a" => {
112                let href = element.value().attr("href").unwrap_or("");
113                let full_url = Self::resolve_url(href, base_url);
114                let text = element
115                    .text()
116                    .collect::<Vec<_>>()
117                    .join(" ")
118                    .trim()
119                    .to_string();
120                if !text.is_empty() && !full_url.is_empty() && !full_url.starts_with("javascript:")
121                {
122                    output.push_str(&format!("[{}]({})", text, full_url));
123                    return; // Handled children
124                }
125            }
126            "img" => {
127                let src = element
128                    .value()
129                    .attr("src")
130                    .or_else(|| element.value().attr("data-src"))
131                    .unwrap_or("");
132                let alt = element.value().attr("alt").unwrap_or("image");
133                let full_url = Self::resolve_url(src, base_url);
134                if !full_url.is_empty() {
135                    output.push_str(&format!("![{}]({})", alt, full_url));
136                }
137                return;
138            }
139            "table" => {
140                Self::render_table(element, output);
141                return;
142            }
143            _ => {}
144        }
145
146        for child in element.children() {
147            match child.value() {
148                Node::Element(_) => {
149                    if let Some(child_ref) = ElementRef::wrap(child) {
150                        Self::walk_node(&child_ref, output, base_url);
151                    }
152                }
153                Node::Text(text) => {
154                    let raw = text.as_ref();
155                    if tag_name == "pre" || tag_name == "code" {
156                        output.push_str(raw);
157                    } else {
158                        let trimmed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
159                        if !trimmed.is_empty() {
160                            if raw.starts_with(char::is_whitespace)
161                                && !output.ends_with(' ')
162                                && !output.ends_with('\n')
163                            {
164                                output.push(' ');
165                            }
166                            output.push_str(&trimmed);
167                            if raw.ends_with(char::is_whitespace) {
168                                output.push(' ');
169                            }
170                        }
171                    }
172                }
173                _ => {}
174            }
175        }
176
177        match tag_name {
178            "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "blockquote" => output.push_str("\n\n"),
179            "pre" => output.push_str("\n```\n\n"),
180            "code" => output.push('`'),
181            "strong" | "b" => output.push_str("**"),
182            "em" | "i" => output.push('*'),
183            _ => {}
184        }
185    }
186
187    fn render_table(table: &ElementRef, output: &mut String) {
188        let row_sel = Selector::parse("tr").expect("valid static selector");
189        let cell_sel = Selector::parse("th, td").expect("valid static selector");
190
191        let mut rows = Vec::new();
192        for tr in table.select(&row_sel) {
193            let cells: Vec<String> = tr
194                .select(&cell_sel)
195                .map(|c| c.text().collect::<Vec<_>>().join(" ").trim().to_string())
196                .collect();
197            if !cells.is_empty() {
198                rows.push(cells);
199            }
200        }
201
202        if rows.is_empty() {
203            return;
204        }
205
206        output.push_str("\n\n");
207        let max_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
208
209        // Header row
210        if let Some(headers) = rows.first() {
211            output.push_str("| ");
212            for i in 0..max_cols {
213                let val = headers.get(i).map(|s| s.as_str()).unwrap_or("");
214                output.push_str(val);
215                output.push_str(" | ");
216            }
217            output.push('\n');
218
219            // Separator
220            output.push_str("| ");
221            for _ in 0..max_cols {
222                output.push_str("--- | ");
223            }
224            output.push('\n');
225        }
226
227        // Body rows
228        for row in rows.iter().skip(1) {
229            output.push_str("| ");
230            for i in 0..max_cols {
231                let val = row.get(i).map(|s| s.as_str()).unwrap_or("");
232                output.push_str(val);
233                output.push_str(" | ");
234            }
235            output.push('\n');
236        }
237        output.push('\n');
238    }
239
240    fn resolve_url(href: &str, base_url: Option<&str>) -> String {
241        if href.is_empty() {
242            return String::new();
243        }
244        if href.starts_with("http://") || href.starts_with("https://") || href.starts_with("data:")
245        {
246            return href.to_string();
247        }
248        if let Some(base) = base_url {
249            if href.starts_with("//") {
250                return format!("https:{}", href);
251            }
252            if href.starts_with('/') {
253                if let Some(idx) = base.find("://") {
254                    let after = &base[idx + 3..];
255                    let host = after.split('/').next().unwrap_or(after);
256                    let scheme = &base[..idx + 3];
257                    return format!("{}{}{}", scheme, host, href);
258                }
259            }
260            let trimmed_base = base.split('?').next().unwrap_or(base);
261            let parent = if trimmed_base.ends_with('/') {
262                trimmed_base
263            } else if let Some(last_slash) = trimmed_base.rfind('/') {
264                &trimmed_base[..last_slash + 1]
265            } else {
266                trimmed_base
267            };
268            return format!("{}{}", parent, href);
269        }
270        href.to_string()
271    }
272
273    fn clean_markdown(input: &str) -> String {
274        let mut result = String::new();
275        let mut newline_count = 0;
276
277        for line in input.lines() {
278            let trimmed = line.trim();
279            if trimmed.is_empty() {
280                if newline_count < 2 {
281                    result.push('\n');
282                    newline_count += 1;
283                }
284            } else {
285                if newline_count > 0 && !result.is_empty() && !result.ends_with('\n') {
286                    result.push('\n');
287                }
288                result.push_str(trimmed);
289                result.push('\n');
290                newline_count = 0;
291            }
292        }
293
294        result.trim().to_string()
295    }
296}