1use std::collections::HashSet;
2
3use scraper::{ElementRef, Html, Selector};
4
5use crate::page::{
6 FormInfo, Heading, HtmlPage, ImageInfo, JsonLdBlock, LinkInfo, OpenGraphTags, TwitterCardTags,
7};
8
9pub fn parse_html(url_or_path: &str, source: &str, base_is_local: bool) -> HtmlPage {
10 let document = Html::parse_document(source);
11
12 let title = select_text(&document, "title");
13 let meta_description = meta_content(&document, "description");
14 let canonical_urls = select_attr_all(&document, "link[rel='canonical']", "href");
15 let language = select_attr(&document, "html", "lang");
16
17 let headings = extract_headings(&document);
18 let links = extract_links(&document, base_is_local);
19 let images = extract_images(&document);
20 let open_graph = extract_open_graph(&document);
21 let twitter_card = extract_twitter(&document);
22 let json_ld_blocks = extract_json_ld(&document);
23 let main_text = extract_main_text(&document);
24 let has_main_landmark = has_element(&document, "main, [role='main']");
25 let forms = extract_forms(&document);
26 let breadcrumbs_detected = has_element(
27 &document,
28 "nav[aria-label*='breadcrumb' i], .breadcrumb, [itemtype*='BreadcrumbList']",
29 );
30
31 HtmlPage {
32 url_or_path: url_or_path.to_string(),
33 source: source.to_string(),
34 title,
35 meta_description,
36 canonical_urls,
37 language,
38 headings,
39 links,
40 images,
41 open_graph,
42 twitter_card,
43 json_ld_blocks,
44 main_text,
45 has_main_landmark,
46 forms,
47 breadcrumbs_detected,
48 }
49}
50
51fn select_text(document: &Html, selector: &str) -> Option<String> {
52 let sel = Selector::parse(selector).ok()?;
53 document
54 .select(&sel)
55 .next()
56 .map(|el| normalize_text(&el.text().collect::<String>()))
57 .filter(|s| !s.is_empty())
58}
59
60fn select_attr(document: &Html, selector: &str, attr: &str) -> Option<String> {
61 let sel = Selector::parse(selector).ok()?;
62 document
63 .select(&sel)
64 .next()
65 .and_then(|el| el.value().attr(attr).map(str::to_string))
66 .filter(|s| !s.is_empty())
67}
68
69fn select_attr_all(document: &Html, selector: &str, attr: &str) -> Vec<String> {
70 let Ok(sel) = Selector::parse(selector) else {
71 return Vec::new();
72 };
73 document
74 .select(&sel)
75 .filter_map(|el| el.value().attr(attr).map(str::to_string))
76 .collect()
77}
78
79fn meta_content(document: &Html, name: &str) -> Option<String> {
80 let selector = format!("meta[name='{name}'], meta[property='{name}']");
81 let Ok(sel) = Selector::parse(&selector) else {
82 return None;
83 };
84 document
85 .select(&sel)
86 .next()
87 .and_then(|el| {
88 el.value()
89 .attr("content")
90 .or_else(|| el.value().attr("value"))
91 .map(str::to_string)
92 })
93 .filter(|s| !s.is_empty())
94}
95
96fn extract_headings(document: &Html) -> Vec<Heading> {
97 let Ok(sel) = Selector::parse("h1, h2, h3, h4, h5, h6") else {
98 return Vec::new();
99 };
100 document
101 .select(&sel)
102 .filter_map(|el| {
103 let tag = el.value().name();
104 let level = tag.chars().nth(1)?.to_digit(10)? as u8;
105 let text = normalize_text(&el.text().collect::<String>());
106 if text.is_empty() {
107 return None;
108 }
109 Some(Heading { level, text })
110 })
111 .collect()
112}
113
114fn extract_links(document: &Html, base_is_local: bool) -> Vec<LinkInfo> {
115 let Ok(sel) = Selector::parse("a[href]") else {
116 return Vec::new();
117 };
118 document
119 .select(&sel)
120 .filter_map(|el| {
121 let href = el.value().attr("href")?.to_string();
122 if href.starts_with('#') || href.starts_with("javascript:") {
123 return None;
124 }
125 let text = normalize_text(&el.text().collect::<String>());
126 let is_internal = is_internal_link(&href, base_is_local);
127 Some(LinkInfo {
128 href,
129 text,
130 is_internal,
131 })
132 })
133 .collect()
134}
135
136fn is_internal_link(href: &str, base_is_local: bool) -> bool {
137 if href.starts_with("http://") || href.starts_with("https://") {
138 return false;
139 }
140 base_is_local || !href.starts_with("http")
143}
144
145fn extract_images(document: &Html) -> Vec<ImageInfo> {
146 let Ok(sel) = Selector::parse("img") else {
147 return Vec::new();
148 };
149 let caption_sel = Selector::parse("figcaption").ok();
150 let has_figcaption = caption_sel
151 .as_ref()
152 .map(|s| document.select(s).next().is_some())
153 .unwrap_or(false);
154
155 document
156 .select(&sel)
157 .map(|el| {
158 let src = el
159 .value()
160 .attr("src")
161 .or_else(|| el.value().attr("data-src"))
162 .unwrap_or("")
163 .to_string();
164 let alt = el.value().attr("alt").map(str::to_string);
165 let width = el.value().attr("width").map(str::to_string);
166 let height = el.value().attr("height").map(str::to_string);
167 let in_figure = el
168 .parent()
169 .and_then(ElementRef::wrap)
170 .map(|p| p.value().name() == "figure")
171 .unwrap_or(false);
172 ImageInfo {
173 src,
174 alt,
175 width,
176 height,
177 has_caption: has_figcaption && in_figure,
178 }
179 })
180 .collect()
181}
182
183fn extract_open_graph(document: &Html) -> OpenGraphTags {
184 OpenGraphTags {
185 title: meta_content(document, "og:title"),
186 description: meta_content(document, "og:description"),
187 image: meta_content(document, "og:image"),
188 url: meta_content(document, "og:url"),
189 type_: meta_content(document, "og:type"),
190 }
191}
192
193fn extract_twitter(document: &Html) -> TwitterCardTags {
194 TwitterCardTags {
195 card: meta_content(document, "twitter:card"),
196 title: meta_content(document, "twitter:title"),
197 description: meta_content(document, "twitter:description"),
198 image: meta_content(document, "twitter:image"),
199 }
200}
201
202fn extract_json_ld(document: &Html) -> Vec<JsonLdBlock> {
203 let Ok(sel) = Selector::parse("script[type='application/ld+json']") else {
204 return Vec::new();
205 };
206 document
207 .select(&sel)
208 .map(|el| {
209 let raw = el.text().collect::<String>().trim().to_string();
210 let valid_json = serde_json::from_str::<serde_json::Value>(&raw).is_ok();
211 let types = if valid_json {
212 json_ld_types(&raw)
213 } else {
214 Vec::new()
215 };
216 JsonLdBlock {
217 raw,
218 valid_json,
219 types,
220 }
221 })
222 .filter(|b| !b.raw.is_empty())
223 .collect()
224}
225
226fn json_ld_types(raw: &str) -> Vec<String> {
227 let mut types = HashSet::new();
228 if let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) {
229 collect_types(&value, &mut types);
230 }
231 let mut v: Vec<_> = types.into_iter().collect();
232 v.sort();
233 v
234}
235
236fn collect_types(value: &serde_json::Value, types: &mut HashSet<String>) {
237 match value {
238 serde_json::Value::Object(map) => {
239 if let Some(serde_json::Value::String(t)) = map.get("@type") {
240 types.insert(t.clone());
241 }
242 for v in map.values() {
243 collect_types(v, types);
244 }
245 }
246 serde_json::Value::Array(arr) => {
247 for v in arr {
248 collect_types(v, types);
249 }
250 }
251 _ => {}
252 }
253}
254
255fn extract_main_text(document: &Html) -> String {
256 if let Some(text) = text_from_selector(document, "main, [role='main'], article") {
257 if text.len() > 50 {
258 return text;
259 }
260 }
261 text_from_selector(document, "body").unwrap_or_default()
262}
263
264fn text_from_selector(document: &Html, selector: &str) -> Option<String> {
265 let sel = Selector::parse(selector).ok()?;
266 let text: String = document.select(&sel).flat_map(|el| el.text()).collect();
267 let normalized = normalize_text(&text);
268 if normalized.is_empty() {
269 None
270 } else {
271 Some(normalized)
272 }
273}
274
275fn has_element(document: &Html, selector: &str) -> bool {
276 Selector::parse(selector)
277 .ok()
278 .map(|sel| document.select(&sel).next().is_some())
279 .unwrap_or(false)
280}
281
282fn extract_forms(document: &Html) -> Vec<FormInfo> {
283 let Ok(form_sel) = Selector::parse("form") else {
284 return Vec::new();
285 };
286 let input_sel = Selector::parse("input, textarea, select").ok();
287
288 document
289 .select(&form_sel)
290 .map(|form| {
291 let id = form.value().attr("id").map(str::to_string);
292 let mut inputs_without_label = Vec::new();
293 if let Some(ref input_sel) = input_sel {
294 for input in form.select(input_sel) {
295 let input_type = input.value().attr("type").unwrap_or("text");
296 if matches!(input_type, "hidden" | "submit" | "button" | "reset") {
297 continue;
298 }
299 let id_attr = input.value().attr("id");
300 let has_label = id_attr.is_some_and(|id| form_has_label_for(&form, id))
301 || is_inside_label(&input);
302
303 if !has_label {
304 let name = input
305 .value()
306 .attr("name")
307 .or_else(|| input.value().attr("id"))
308 .unwrap_or("unnamed")
309 .to_string();
310 inputs_without_label.push(name);
311 }
312 }
313 }
314 FormInfo {
315 id,
316 inputs_without_label,
317 }
318 })
319 .collect()
320}
321
322fn form_has_label_for(form: &ElementRef<'_>, input_id: &str) -> bool {
323 let Ok(label_sel) = Selector::parse("label[for]") else {
324 return false;
325 };
326 form.select(&label_sel)
327 .any(|label| label.value().attr("for") == Some(input_id))
328}
329
330fn is_inside_label(el: &ElementRef<'_>) -> bool {
331 let mut current = el.parent();
332 while let Some(node) = current {
333 if let Some(parent_el) = ElementRef::wrap(node) {
334 if parent_el.value().name() == "label" {
335 return true;
336 }
337 current = parent_el.parent();
338 } else {
339 break;
340 }
341 }
342 false
343}
344
345fn normalize_text(text: &str) -> String {
346 text.split_whitespace().collect::<Vec<_>>().join(" ")
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn extracts_title_and_headings() {
355 let html = r#"<!DOCTYPE html>
356 <html lang="en">
357 <head><title>Test Page</title>
358 <meta name="description" content="A test page.">
359 <link rel="canonical" href="https://example.com/">
360 </head>
361 <body>
362 <h1>Hello</h1>
363 <h2>World</h2>
364 <p>Some content here for extraction.</p>
365 </body></html>"#;
366 let page = parse_html("/test.html", html, true);
367 assert_eq!(page.title.as_deref(), Some("Test Page"));
368 assert_eq!(page.meta_description.as_deref(), Some("A test page."));
369 assert_eq!(page.headings.len(), 2);
370 assert_eq!(page.language.as_deref(), Some("en"));
371 }
372
373 #[test]
374 fn is_internal_link_classifies_hrefs() {
375 assert!(!is_internal_link("https://example.com/", true));
376 assert!(!is_internal_link("http://example.com/", false));
377
378 assert!(is_internal_link("about.html", true));
379 assert!(is_internal_link("/docs", true));
380
381 assert!(is_internal_link("about.html", false));
382 assert!(is_internal_link("ftp://files.example.com/x", false));
384 }
385
386 #[test]
387 fn form_label_detection_handles_special_characters_in_id() {
388 let html = r#"<!DOCTYPE html>
389 <html lang="en"><body>
390 <form>
391 <label for="user's-name">Name</label>
392 <input id="user's-name" name="name" type="text">
393 </form>
394 </body></html>"#;
395 let page = parse_html("/test.html", html, true);
396 assert!(page.forms[0].inputs_without_label.is_empty());
397 }
398}