Skip to main content

oratos_html/
extract.rs

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    // Local audits: any non-http(s) href is site-relative.
141    // Remote audits: only relative/protocol-relative paths count as internal.
142    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        // Non-http(s) schemes are treated as internal on remote audits (no absolute URL).
383        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}