headless_engine/dom/
markdown.rs1use 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 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 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; }
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 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 output.push_str("| ");
169 for _ in 0..max_cols {
170 output.push_str("--- | ");
171 }
172 output.push('\n');
173 }
174
175 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}