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_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 match tag_name {
53 "script" | "style" | "noscript" | "svg" | "canvas" | "iframe" | "header" | "footer"
54 | "nav" | "dialog" | "aside" => return,
55 _ => {}
56 }
57
58 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; }
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 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 output.push_str("| ");
221 for _ in 0..max_cols {
222 output.push_str("--- | ");
223 }
224 output.push('\n');
225 }
226
227 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}