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 main content container if available to eliminate boilerplate
11        let content_selector = Selector::parse(
12            "article, main, div[role='main'], div.main-content, div.post-content, div#content, body",
13        )
14        .ok();
15
16        let root_element = content_selector
17            .as_ref()
18            .and_then(|sel| document.select(sel).next())
19            .unwrap_or_else(|| document.root_element());
20
21        let mut output = String::new();
22        Self::walk_node(&root_element, &mut output, base_url);
23
24        Self::clean_markdown(&output)
25    }
26
27    pub fn convert_element(element: &ElementRef, base_url: Option<&str>) -> String {
28        let mut output = String::new();
29        Self::walk_node(element, &mut output, base_url);
30        Self::clean_markdown(&output)
31    }
32
33    fn walk_node(element: &ElementRef, output: &mut String, base_url: Option<&str>) {
34        let tag_name = element.value().name();
35
36        // Skip non-content / hidden tags
37        match tag_name {
38            "script" | "style" | "noscript" | "svg" | "canvas" | "iframe" | "header" | "footer"
39            | "nav" | "dialog" => return,
40            _ => {}
41        }
42
43        match tag_name {
44            "h1" => output.push_str("\n\n# "),
45            "h2" => output.push_str("\n\n## "),
46            "h3" => output.push_str("\n\n### "),
47            "h4" => output.push_str("\n\n#### "),
48            "h5" => output.push_str("\n\n##### "),
49            "h6" => output.push_str("\n\n###### "),
50            "p" => output.push_str("\n\n"),
51            "br" => output.push('\n'),
52            "hr" => output.push_str("\n\n---\n\n"),
53            "blockquote" => output.push_str("\n\n> "),
54            "li" => output.push_str("\n- "),
55            "pre" => output.push_str("\n\n```\n"),
56            "code" => output.push('`'),
57            "strong" | "b" => output.push_str("**"),
58            "em" | "i" => output.push('*'),
59            "a" => {
60                let href = element.value().attr("href").unwrap_or("");
61                let full_url = Self::resolve_url(href, base_url);
62                let text = element
63                    .text()
64                    .collect::<Vec<_>>()
65                    .join(" ")
66                    .trim()
67                    .to_string();
68                if !text.is_empty() && !full_url.is_empty() && !full_url.starts_with("javascript:")
69                {
70                    output.push_str(&format!("[{}]({})", text, full_url));
71                    return; // Handled children
72                }
73            }
74            "img" => {
75                let src = element
76                    .value()
77                    .attr("src")
78                    .or_else(|| element.value().attr("data-src"))
79                    .unwrap_or("");
80                let alt = element.value().attr("alt").unwrap_or("image");
81                let full_url = Self::resolve_url(src, base_url);
82                if !full_url.is_empty() {
83                    output.push_str(&format!("![{}]({})", alt, full_url));
84                }
85                return;
86            }
87            "table" => {
88                Self::render_table(element, output);
89                return;
90            }
91            _ => {}
92        }
93
94        for child in element.children() {
95            match child.value() {
96                Node::Element(_) => {
97                    if let Some(child_ref) = ElementRef::wrap(child) {
98                        Self::walk_node(&child_ref, output, base_url);
99                    }
100                }
101                Node::Text(text) => {
102                    let raw = text.as_ref();
103                    if tag_name == "pre" || tag_name == "code" {
104                        output.push_str(raw);
105                    } else {
106                        let trimmed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
107                        if !trimmed.is_empty() {
108                            if raw.starts_with(char::is_whitespace)
109                                && !output.ends_with(' ')
110                                && !output.ends_with('\n')
111                            {
112                                output.push(' ');
113                            }
114                            output.push_str(&trimmed);
115                            if raw.ends_with(char::is_whitespace) {
116                                output.push(' ');
117                            }
118                        }
119                    }
120                }
121                _ => {}
122            }
123        }
124
125        match tag_name {
126            "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "blockquote" => output.push_str("\n\n"),
127            "pre" => output.push_str("\n```\n\n"),
128            "code" => output.push('`'),
129            "strong" | "b" => output.push_str("**"),
130            "em" | "i" => output.push('*'),
131            _ => {}
132        }
133    }
134
135    fn render_table(table: &ElementRef, output: &mut String) {
136        let row_sel = Selector::parse("tr").expect("valid static selector");
137        let cell_sel = Selector::parse("th, td").expect("valid static selector");
138
139        let mut rows = Vec::new();
140        for tr in table.select(&row_sel) {
141            let cells: Vec<String> = tr
142                .select(&cell_sel)
143                .map(|c| c.text().collect::<Vec<_>>().join(" ").trim().to_string())
144                .collect();
145            if !cells.is_empty() {
146                rows.push(cells);
147            }
148        }
149
150        if rows.is_empty() {
151            return;
152        }
153
154        output.push_str("\n\n");
155        let max_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
156
157        // Header row
158        if let Some(headers) = rows.first() {
159            output.push_str("| ");
160            for i in 0..max_cols {
161                let val = headers.get(i).map(|s| s.as_str()).unwrap_or("");
162                output.push_str(val);
163                output.push_str(" | ");
164            }
165            output.push('\n');
166
167            // Separator
168            output.push_str("| ");
169            for _ in 0..max_cols {
170                output.push_str("--- | ");
171            }
172            output.push('\n');
173        }
174
175        // Body rows
176        for row in rows.iter().skip(1) {
177            output.push_str("| ");
178            for i in 0..max_cols {
179                let val = row.get(i).map(|s| s.as_str()).unwrap_or("");
180                output.push_str(val);
181                output.push_str(" | ");
182            }
183            output.push('\n');
184        }
185        output.push('\n');
186    }
187
188    fn resolve_url(href: &str, base_url: Option<&str>) -> String {
189        if href.is_empty() {
190            return String::new();
191        }
192        if href.starts_with("http://") || href.starts_with("https://") || href.starts_with("data:")
193        {
194            return href.to_string();
195        }
196        if let Some(base) = base_url {
197            if href.starts_with("//") {
198                return format!("https:{}", href);
199            }
200            if href.starts_with('/') {
201                if let Some(idx) = base.find("://") {
202                    let after = &base[idx + 3..];
203                    let host = after.split('/').next().unwrap_or(after);
204                    let scheme = &base[..idx + 3];
205                    return format!("{}{}{}", scheme, host, href);
206                }
207            }
208            let trimmed_base = base.split('?').next().unwrap_or(base);
209            let parent = if trimmed_base.ends_with('/') {
210                trimmed_base
211            } else if let Some(last_slash) = trimmed_base.rfind('/') {
212                &trimmed_base[..last_slash + 1]
213            } else {
214                trimmed_base
215            };
216            return format!("{}{}", parent, href);
217        }
218        href.to_string()
219    }
220
221    fn clean_markdown(input: &str) -> String {
222        let mut result = String::new();
223        let mut newline_count = 0;
224
225        for line in input.lines() {
226            let trimmed = line.trim();
227            if trimmed.is_empty() {
228                if newline_count < 2 {
229                    result.push('\n');
230                    newline_count += 1;
231                }
232            } else {
233                if newline_count > 0 && !result.is_empty() && !result.ends_with('\n') {
234                    result.push('\n');
235                }
236                result.push_str(trimmed);
237                result.push('\n');
238                newline_count = 0;
239            }
240        }
241
242        result.trim().to_string()
243    }
244}