1use scraper::{Html, Selector};
2use serde::{Deserialize, Serialize};
3use url::Url;
4
5use crate::meta::{HreflangTag, MetaTags, OpenGraphTags, TwitterTags};
6
7#[allow(clippy::expect_used)]
11mod selectors {
12 use scraper::Selector;
13 use std::sync::OnceLock;
14
15 fn cached(pattern: &str) -> &Selector {
16 static CELL: OnceLock<Selector> = OnceLock::new();
17 CELL.get_or_init(|| Selector::parse(pattern).expect("static CSS selector is valid"))
18 }
19
20 pub fn html() -> &'static Selector {
21 cached("html")
22 }
23 pub fn header() -> &'static Selector {
24 cached("header")
25 }
26 pub fn nav() -> &'static Selector {
27 cached("nav")
28 }
29 pub fn main() -> &'static Selector {
30 cached("main")
31 }
32 pub fn aside() -> &'static Selector {
33 cached("aside")
34 }
35 pub fn footer() -> &'static Selector {
36 cached("footer")
37 }
38 pub fn form() -> &'static Selector {
39 cached("form")
40 }
41 pub fn section_aria() -> &'static Selector {
42 cached("section[aria-label], section[aria-labelledby]")
43 }
44 pub fn role_banner() -> &'static Selector {
45 cached("[role=banner]")
46 }
47 pub fn role_navigation() -> &'static Selector {
48 cached("[role=navigation]")
49 }
50 pub fn role_main() -> &'static Selector {
51 cached("[role=main]")
52 }
53 pub fn role_complementary() -> &'static Selector {
54 cached("[role=complementary]")
55 }
56 pub fn role_contentinfo() -> &'static Selector {
57 cached("[role=contentinfo]")
58 }
59 pub fn input_select_textarea() -> &'static Selector {
60 cached("input, select, textarea")
61 }
62}
63
64#[derive(Debug, thiserror::Error)]
69pub enum ParseError {
70 #[error("selector compilation failed: {0}")]
71 Selector(String),
72
73 #[error("URL resolution failed: {0}")]
74 UrlResolution(#[from] url::ParseError),
75
76 #[error("JSON-LD parse error: {0}")]
77 JsonLd(String),
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Heading {
86 pub level: u8,
88 pub text: String,
90 pub length: usize,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ExtractedImage {
100 pub src: String,
102 pub alt: String,
104 pub width: Option<u32>,
106 pub height: Option<u32>,
108 pub has_alt: bool,
110 pub is_lazy_loaded: bool,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ExtractedLink {
120 pub href: String,
122 pub text: String,
124 pub rel: Vec<String>,
126 pub is_external: bool,
128 pub aria_label: Option<String>,
130 pub img_alt: Option<String>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ExtractedInput {
140 pub input_type: Option<String>,
142 pub name: Option<String>,
144 pub id: Option<String>,
146 pub has_label: bool,
148 pub aria_label: Option<String>,
150 pub aria_labelledby: Option<String>,
152 pub aria_describedby: Option<String>,
154 pub placeholder: Option<String>,
156 pub required: bool,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct ExtractedForm {
166 pub action: Option<String>,
168 pub method: String,
170 pub input_count: usize,
172 pub has_file_input: bool,
174 pub has_search_input: bool,
176 pub inputs: Vec<ExtractedInput>,
178 pub has_fieldset: bool,
180 pub has_legend: bool,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct ScriptInfo {
189 pub src: Option<String>,
191 pub r#async: bool,
193 pub defer: bool,
195 pub script_type: Option<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct StyleInfo {
204 pub href: Option<String>,
206 pub media: Option<String>,
208 pub is_inline: bool,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct StructuredData {
218 pub context: Option<String>,
220 pub r#type: Option<String>,
222 pub data: serde_json::Value,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct ParsedPage {
235 pub url: String,
237 pub meta: MetaTags,
239 pub headings: Vec<Heading>,
241 pub links: Vec<ExtractedLink>,
243 pub images: Vec<ExtractedImage>,
245 pub forms: Vec<ExtractedForm>,
247 pub scripts: Vec<ScriptInfo>,
249 pub styles: Vec<StyleInfo>,
251 pub structured_data: Vec<StructuredData>,
253 pub word_count: usize,
255
256 pub landmarks: Vec<String>,
259 pub has_skip_link: bool,
261 pub has_main_landmark: bool,
263 pub has_nav_landmark: bool,
265 pub has_positive_tabindex: bool,
267 pub tabindex_negative_count: usize,
269 pub aria_role_count: usize,
271 pub aria_label_count: usize,
273 pub has_lang_attribute: bool,
275 pub html_lang: Option<String>,
277 pub has_aria_hidden: bool,
279 pub tables_with_headers: usize,
281 pub tables_total: usize,
282 pub tables_with_captions: usize,
283
284 pub og_image_width: Option<u32>,
287 pub og_image_height: Option<u32>,
289}
290
291pub struct HtmlParser;
312
313impl HtmlParser {
314 pub fn parse(html: &str, url: &Url) -> Result<ParsedPage, ParseError> {
325 let document = Html::parse_document(html);
326
327 let meta = Self::extract_meta(&document, url);
328 let headings = Self::extract_headings(&document);
329 let links = Self::extract_links(&document, url);
330 let images = Self::extract_images(&document);
331 let forms = Self::extract_forms(&document);
332 let scripts = Self::extract_scripts(&document);
333 let styles = Self::extract_styles(&document);
334 let structured_data = Self::extract_structured_data(&document);
335 let word_count = Self::count_words(&document);
336
337 let accessibility = Self::extract_accessibility(&document);
338 let social = Self::extract_social(&document);
339
340 Ok(ParsedPage {
341 url: url.to_string(),
342 meta,
343 headings,
344 links,
345 images,
346 forms,
347 scripts,
348 styles,
349 structured_data,
350 word_count,
351 landmarks: accessibility.0,
352 has_skip_link: accessibility.1,
353 has_main_landmark: accessibility.2,
354 has_nav_landmark: accessibility.3,
355 has_positive_tabindex: accessibility.4,
356 tabindex_negative_count: accessibility.5,
357 aria_role_count: accessibility.6,
358 aria_label_count: accessibility.7,
359 has_lang_attribute: accessibility.8,
360 html_lang: accessibility.9,
361 has_aria_hidden: accessibility.10,
362 tables_with_headers: accessibility.11,
363 tables_total: accessibility.12,
364 tables_with_captions: accessibility.13,
365 og_image_width: social.0,
366 og_image_height: social.1,
367 })
368 }
369
370 fn extract_meta(document: &Html, page_url: &Url) -> MetaTags {
375 let mut title = Self::select_text(document, "title");
376
377 if title.is_none() {
379 title = Self::get_meta_content(document, "og:title");
380 }
381 if title.is_none() {
382 title = Self::get_meta_content(document, "twitter:title");
383 }
384
385 let description = Self::get_meta_content(document, "description")
386 .or_else(|| Self::get_meta_content(document, "og:description"))
387 .or_else(|| Self::get_meta_content(document, "twitter:description"));
388
389 let canonical = Self::get_attr(document, "link[rel=canonical]", "href")
390 .and_then(|href| page_url.join(&href).ok());
391
392 let robots = Self::get_meta_content(document, "robots");
393 let language = Self::get_attr(document, "html", "lang")
394 .or_else(|| Self::get_meta_content(document, "language"));
395 let charset = Self::get_attr(document, "meta[charset]", "charset").or_else(|| {
396 Self::get_meta_content(document, "content-type").and_then(|ct| {
397 ct.split(';')
398 .find(|p| p.trim().starts_with("charset="))
399 .map(|p| p.trim().trim_start_matches("charset=").to_string())
400 })
401 });
402 let viewport = Self::get_meta_content(document, "viewport");
403
404 let og = OpenGraphTags {
405 title: Self::get_meta_content(document, "og:title"),
406 description: Self::get_meta_content(document, "og:description"),
407 image: Self::get_meta_content(document, "og:image"),
408 url: Self::get_meta_content(document, "og:url"),
409 r#type: Self::get_meta_content(document, "og:type"),
410 site_name: Self::get_meta_content(document, "og:site_name"),
411 locale: Self::get_meta_content(document, "og:locale"),
412 };
413
414 let twitter = TwitterTags {
415 card: Self::get_meta_content(document, "twitter:card"),
416 site: Self::get_meta_content(document, "twitter:site"),
417 creator: Self::get_meta_content(document, "twitter:creator"),
418 title: Self::get_meta_content(document, "twitter:title"),
419 description: Self::get_meta_content(document, "twitter:description"),
420 image: Self::get_meta_content(document, "twitter:image"),
421 image_alt: Self::get_meta_content(document, "twitter:image:alt"),
422 };
423
424 let hreflang = Self::extract_hreflang(document, page_url);
425
426 MetaTags {
427 title,
428 description,
429 canonical,
430 robots,
431 language,
432 charset,
433 viewport,
434 og,
435 twitter,
436 hreflang,
437 }
438 }
439
440 fn extract_hreflang(document: &Html, page_url: &Url) -> Vec<HreflangTag> {
441 let selector = match Selector::parse("link[hreflang]") {
442 Ok(s) => s,
443 Err(_) => return Vec::new(),
444 };
445
446 document
447 .select(&selector)
448 .filter_map(|el| {
449 let lang = el.value().attr("hreflang")?.to_string();
450 let href = el.value().attr("href")?;
451 let url = page_url.join(href).ok()?;
452 Some(HreflangTag { lang, url })
453 })
454 .collect()
455 }
456
457 fn extract_headings(document: &Html) -> Vec<Heading> {
462 let mut headings = Vec::new();
463 for level in 1..=6 {
464 let selector = match Selector::parse(&format!("h{level}")) {
465 Ok(s) => s,
466 Err(_) => continue,
467 };
468 for el in document.select(&selector) {
469 let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
470 if !text.is_empty() {
471 headings.push(Heading {
472 level,
473 length: text.len(),
474 text,
475 });
476 }
477 }
478 }
479 headings
480 }
481
482 fn extract_links(document: &Html, page_url: &Url) -> Vec<ExtractedLink> {
487 let selector = match Selector::parse("a[href]") {
488 Ok(s) => s,
489 Err(_) => return Vec::new(),
490 };
491
492 let page_domain = page_url.domain().unwrap_or("");
493
494 document
495 .select(&selector)
496 .filter_map(|el| {
497 let raw_href = el.value().attr("href")?.to_string();
498 let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
499
500 let rel: Vec<String> = el
501 .value()
502 .attr("rel")
503 .map(|r| r.split_whitespace().map(String::from).collect())
504 .unwrap_or_default();
505
506 let resolved_url = page_url.join(&raw_href).ok()?;
508 let href = resolved_url.to_string();
509
510 let is_external = resolved_url.domain().unwrap_or("") != page_domain;
511
512 let aria_label = el.value().attr("aria-label").map(String::from);
514 let img_alt = el
515 .select(&Selector::parse("img").ok()?)
516 .next()
517 .and_then(|img| img.value().attr("alt"))
518 .map(String::from);
519
520 Some(ExtractedLink {
521 href,
522 text,
523 rel,
524 is_external,
525 aria_label,
526 img_alt,
527 })
528 })
529 .collect()
530 }
531
532 fn extract_images(document: &Html) -> Vec<ExtractedImage> {
537 let selector = match Selector::parse("img") {
538 Ok(s) => s,
539 Err(_) => return Vec::new(),
540 };
541
542 document
543 .select(&selector)
544 .map(|el| {
545 let src = el.value().attr("src").unwrap_or("").to_string();
546 let alt = el.value().attr("alt").unwrap_or("").to_string();
547 let width = el.value().attr("width").and_then(|w| w.parse::<u32>().ok());
548 let height = el
549 .value()
550 .attr("height")
551 .and_then(|h| h.parse::<u32>().ok());
552 let has_alt = el.value().attr("alt").is_some();
553 let is_lazy_loaded = el
554 .value()
555 .attr("loading")
556 .map(|l| l == "lazy")
557 .unwrap_or(false)
558 || el.value().attr("data-src").is_some();
559
560 ExtractedImage {
561 src,
562 alt,
563 width,
564 height,
565 has_alt,
566 is_lazy_loaded,
567 }
568 })
569 .collect()
570 }
571
572 fn extract_forms(document: &Html) -> Vec<ExtractedForm> {
577 let selector = match Selector::parse("form") {
578 Ok(s) => s,
579 Err(_) => return Vec::new(),
580 };
581
582 let input_sel = match Selector::parse("input, select, textarea") {
583 Ok(s) => s,
584 Err(_) => return Vec::new(),
585 };
586
587 let label_sel = match Selector::parse("label") {
588 Ok(s) => s,
589 Err(_) => return Vec::new(),
590 };
591
592 document
593 .select(&selector)
594 .map(|form| {
595 let action = form.value().attr("action").map(String::from);
596 let method = form.value().attr("method").unwrap_or("get").to_lowercase();
597
598 let inputs: Vec<_> = form.select(&input_sel).collect();
599 let input_count = inputs.len();
600 let has_file_input = inputs
601 .iter()
602 .any(|i| i.value().attr("type") == Some("file"));
603 let has_search_input = inputs.iter().any(|i| {
604 i.value().attr("type") == Some("search")
605 || i.value().attr("role") == Some("search")
606 });
607
608 let label_for_ids: std::collections::HashSet<String> = form
610 .select(&label_sel)
611 .filter_map(|l| l.value().attr("for").map(String::from))
612 .collect();
613
614 let inputs_in_labels: std::collections::HashSet<ego_tree::NodeId> = {
616 let inner_input_sel = selectors::input_select_textarea();
617 form.select(&label_sel)
618 .flat_map(|label| label.select(inner_input_sel))
619 .map(|input| input.id())
620 .collect()
621 };
622
623 let extracted_inputs: Vec<ExtractedInput> = inputs
624 .iter()
625 .map(|input| {
626 let input_type = input.value().attr("type").map(String::from);
627 let name = input.value().attr("name").map(String::from);
628 let id = input.value().attr("id").map(String::from);
629 let aria_label = input.value().attr("aria-label").map(String::from);
630 let aria_labelledby =
631 input.value().attr("aria-labelledby").map(String::from);
632 let aria_describedby =
633 input.value().attr("aria-describedby").map(String::from);
634 let placeholder = input.value().attr("placeholder").map(String::from);
635 let required = input.value().attr("required").is_some()
636 || input.value().attr("aria-required") == Some("true");
637
638 let has_explicit_label = id
639 .as_ref()
640 .map(|id_val| label_for_ids.contains(id_val))
641 .unwrap_or(false);
642
643 let has_implicit_label = inputs_in_labels.contains(&input.id());
644
645 let has_label = has_explicit_label
646 || has_implicit_label
647 || aria_label.is_some()
648 || aria_labelledby.is_some();
649
650 ExtractedInput {
651 input_type,
652 name,
653 id,
654 has_label,
655 aria_label,
656 aria_labelledby,
657 aria_describedby,
658 placeholder,
659 required,
660 }
661 })
662 .collect();
663
664 let has_fieldset = Selector::parse("fieldset")
665 .ok()
666 .and_then(|s| form.select(&s).next().is_some().then_some(true))
667 .unwrap_or(false);
668 let has_legend = Selector::parse("legend")
669 .ok()
670 .and_then(|s| form.select(&s).next().is_some().then_some(true))
671 .unwrap_or(false);
672
673 ExtractedForm {
674 action,
675 method,
676 input_count,
677 has_file_input,
678 has_search_input,
679 inputs: extracted_inputs,
680 has_fieldset,
681 has_legend,
682 }
683 })
684 .collect()
685 }
686
687 fn extract_scripts(document: &Html) -> Vec<ScriptInfo> {
692 let selector = match Selector::parse("script") {
693 Ok(s) => s,
694 Err(_) => return Vec::new(),
695 };
696
697 document
698 .select(&selector)
699 .map(|el| {
700 let src = el.value().attr("src").map(String::from);
701 let r#async = el.value().attr("async").is_some();
702 let defer = el.value().attr("defer").is_some();
703 let script_type = el.value().attr("type").map(String::from);
704
705 ScriptInfo {
706 src,
707 r#async,
708 defer,
709 script_type,
710 }
711 })
712 .collect()
713 }
714
715 fn extract_styles(document: &Html) -> Vec<StyleInfo> {
720 let mut styles = Vec::new();
721
722 let link_sel = match Selector::parse("link[rel=stylesheet]") {
724 Ok(s) => s,
725 Err(_) => return styles,
726 };
727
728 for el in document.select(&link_sel) {
729 let href = el.value().attr("href").map(String::from);
730 let media = el.value().attr("media").map(String::from);
731
732 styles.push(StyleInfo {
733 href,
734 media,
735 is_inline: false,
736 });
737 }
738
739 let style_sel = match Selector::parse("style") {
741 Ok(s) => s,
742 Err(_) => return styles,
743 };
744
745 for el in document.select(&style_sel) {
746 let has_content = !el.text().collect::<String>().trim().is_empty();
747 if has_content {
748 styles.push(StyleInfo {
749 href: None,
750 media: None,
751 is_inline: true,
752 });
753 }
754 }
755
756 styles
757 }
758
759 fn extract_structured_data(document: &Html) -> Vec<StructuredData> {
764 let selector = match Selector::parse("script[type=\"application/ld+json\"]") {
765 Ok(s) => s,
766 Err(_) => return Vec::new(),
767 };
768
769 document
770 .select(&selector)
771 .filter_map(|el| {
772 let raw: String = el.text().collect();
773 let raw = raw.trim();
774 if raw.is_empty() {
775 return None;
776 }
777
778 let value: serde_json::Value = match serde_json::from_str(raw) {
779 Ok(v) => v,
780 Err(_) => return None,
781 };
782
783 let context = value
784 .get("@context")
785 .and_then(|c| c.as_str())
786 .map(String::from);
787 let r#type = value
788 .get("@type")
789 .and_then(|t| t.as_str())
790 .map(String::from);
791
792 Some(StructuredData {
793 context,
794 r#type,
795 data: value,
796 })
797 })
798 .collect()
799 }
800
801 fn count_words(document: &Html) -> usize {
806 let body = Selector::parse("body").ok();
807 let script = Selector::parse("script").ok();
808 let style = Selector::parse("style").ok();
809 let noscript = Selector::parse("noscript").ok();
810
811 let mut skip_ids = std::collections::HashSet::new();
813 for sel in [&script, &style, &noscript].into_iter().flatten() {
814 for el in document.select(sel) {
815 skip_ids.insert(el.id());
816 }
817 }
818
819 let root = body
820 .as_ref()
821 .and_then(|sel| document.select(sel).next())
822 .map(|el| el.id())
823 .unwrap_or_else(|| document.root_element().id());
824
825 let mut text = String::new();
826 let tree = &document.tree;
827
828 fn collect_text(
829 tree: &ego_tree::Tree<scraper::Node>,
830 node_id: ego_tree::NodeId,
831 skip: &std::collections::HashSet<ego_tree::NodeId>,
832 text: &mut String,
833 ) {
834 let Some(node) = tree.get(node_id) else {
835 return; };
837 match node.value() {
838 scraper::Node::Element(el) => {
839 let tag = el.name();
840 if tag == "script" || tag == "style" || tag == "noscript" || tag == "svg" {
841 return;
842 }
843 }
844 scraper::Node::Text(t) => {
845 text.push_str(t);
846 text.push(' ');
847 }
848 _ => {}
849 }
850 for child_id in node.children() {
851 let child_id = child_id.id();
852 if !skip.contains(&child_id) {
853 collect_text(tree, child_id, skip, text);
854 }
855 }
856 }
857
858 collect_text(tree, root, &skip_ids, &mut text);
859
860 text.split_whitespace().filter(|w| !w.is_empty()).count()
861 }
862
863 fn extract_accessibility(
871 document: &Html,
872 ) -> (
873 Vec<String>,
874 bool,
875 bool,
876 bool,
877 bool,
878 usize,
879 usize,
880 usize,
881 bool,
882 Option<String>,
883 bool,
884 usize,
885 usize,
886 usize,
887 ) {
888 let mut landmarks = Vec::new();
889 let mut has_skip_link = false;
890 let mut has_main = false;
891 let mut has_nav = false;
892 let mut has_positive_tabindex = false;
893 let mut tabindex_negative_count = 0usize;
894 let mut aria_role_count = 0usize;
895 let mut aria_label_count = 0usize;
896 let mut has_aria_hidden = false;
897 let mut tables_with_headers = 0usize;
898 let mut tables_total = 0usize;
899 let mut tables_with_captions = 0usize;
900
901 let has_lang;
903 let html_lang;
904 if let Some(html_el) = document.select(selectors::html()).next() {
905 html_lang = html_el.value().attr("lang").map(String::from);
906 has_lang = html_lang.is_some();
907 } else {
908 has_lang = false;
909 html_lang = None;
910 }
911
912 if document.select(selectors::header()).next().is_some() {
914 landmarks.push("banner".to_string());
915 }
916 if document.select(selectors::nav()).next().is_some() {
917 landmarks.push("navigation".to_string());
918 has_nav = true;
919 }
920 if document.select(selectors::main()).next().is_some() {
921 landmarks.push("main".to_string());
922 has_main = true;
923 }
924 if document.select(selectors::aside()).next().is_some() {
925 landmarks.push("complementary".to_string());
926 }
927 if document.select(selectors::footer()).next().is_some() {
928 landmarks.push("contentinfo".to_string());
929 }
930 if document.select(selectors::form()).next().is_some() {
931 landmarks.push("form".to_string());
932 }
933 if document.select(selectors::section_aria()).next().is_some() {
934 landmarks.push("region".to_string());
935 }
936 if document.select(selectors::role_banner()).next().is_some()
937 && !landmarks.contains(&"banner".to_string())
938 {
939 landmarks.push("banner".to_string());
940 }
941 if document
942 .select(selectors::role_navigation())
943 .next()
944 .is_some()
945 && !landmarks.contains(&"navigation".to_string())
946 {
947 landmarks.push("navigation".to_string());
948 has_nav = true;
949 }
950 if document.select(selectors::role_main()).next().is_some()
951 && !landmarks.contains(&"main".to_string())
952 {
953 landmarks.push("main".to_string());
954 has_main = true;
955 }
956 if document
957 .select(selectors::role_complementary())
958 .next()
959 .is_some()
960 && !landmarks.contains(&"complementary".to_string())
961 {
962 landmarks.push("complementary".to_string());
963 }
964 if document
965 .select(selectors::role_contentinfo())
966 .next()
967 .is_some()
968 && !landmarks.contains(&"contentinfo".to_string())
969 {
970 landmarks.push("contentinfo".to_string());
971 }
972
973 if let Ok(sel) = Selector::parse("a[href^=\"#\"]") {
975 for el in document.select(&sel) {
976 let text: String = el.text().collect::<Vec<_>>().join("").to_lowercase();
977 let class = el.value().attr("class").unwrap_or("").to_lowercase();
978 let id = el.value().attr("href").unwrap_or("").to_lowercase();
979 if text.contains("skip") || class.contains("skip") || id.contains("skip") {
980 has_skip_link = true;
981 break;
982 }
983 }
984 }
985
986 for node_ref in document.root_element().descendants() {
988 let el = match node_ref.value() {
989 scraper::Node::Element(e) => e,
990 _ => continue,
991 };
992 if let Some(ti) = el.attr("tabindex") {
994 if let Ok(val) = ti.parse::<i32>() {
995 if val > 0 {
996 has_positive_tabindex = true;
997 } else if val == -1 {
998 tabindex_negative_count += 1;
999 }
1000 }
1001 }
1002
1003 if el.attr("role").is_some() {
1005 aria_role_count += 1;
1006 }
1007
1008 if el.attr("aria-label").is_some() || el.attr("aria-labelledby").is_some() {
1010 aria_label_count += 1;
1011 }
1012
1013 if el.attr("aria-hidden") == Some("true") {
1015 has_aria_hidden = true;
1016 }
1017 }
1018
1019 if let Ok(table_sel) = Selector::parse("table") {
1021 if let Ok(th_sel) = Selector::parse("th") {
1022 if let Ok(caption_sel) = Selector::parse("caption") {
1023 for table in document.select(&table_sel) {
1024 tables_total += 1;
1025 if table.select(&th_sel).next().is_some() {
1026 tables_with_headers += 1;
1027 }
1028 if table.select(&caption_sel).next().is_some() {
1029 tables_with_captions += 1;
1030 }
1031 }
1032 }
1033 }
1034 }
1035
1036 (
1037 landmarks,
1038 has_skip_link,
1039 has_main,
1040 has_nav,
1041 has_positive_tabindex,
1042 tabindex_negative_count,
1043 aria_role_count,
1044 aria_label_count,
1045 has_lang,
1046 html_lang,
1047 has_aria_hidden,
1048 tables_with_headers,
1049 tables_total,
1050 tables_with_captions,
1051 )
1052 }
1053
1054 fn extract_social(document: &Html) -> (Option<u32>, Option<u32>) {
1060 let width =
1061 Self::get_meta_content(document, "og:image:width").and_then(|w| w.parse::<u32>().ok());
1062 let height =
1063 Self::get_meta_content(document, "og:image:height").and_then(|h| h.parse::<u32>().ok());
1064 (width, height)
1065 }
1066
1067 fn select_text(document: &Html, selector_str: &str) -> Option<String> {
1072 let selector = Selector::parse(selector_str).ok()?;
1073 let el = document.select(&selector).next()?;
1074 let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
1075 if text.is_empty() {
1076 None
1077 } else {
1078 Some(text)
1079 }
1080 }
1081
1082 fn get_meta_content(document: &Html, name_or_property: &str) -> Option<String> {
1085 let by_name = format!("meta[name=\"{name_or_property}\"]");
1087 if let Ok(sel) = Selector::parse(&by_name) {
1088 if let Some(val) = document
1089 .select(&sel)
1090 .next()
1091 .and_then(|el| el.value().attr("content"))
1092 {
1093 let val = val.trim().to_string();
1094 if !val.is_empty() {
1095 return Some(val);
1096 }
1097 }
1098 }
1099
1100 let by_prop = format!("meta[property=\"{name_or_property}\"]");
1102 if let Ok(sel) = Selector::parse(&by_prop) {
1103 if let Some(val) = document
1104 .select(&sel)
1105 .next()
1106 .and_then(|el| el.value().attr("content"))
1107 {
1108 let val = val.trim().to_string();
1109 if !val.is_empty() {
1110 return Some(val);
1111 }
1112 }
1113 }
1114
1115 None
1116 }
1117
1118 fn get_attr(document: &Html, selector_str: &str, attr: &str) -> Option<String> {
1120 let selector = Selector::parse(selector_str).ok()?;
1121 document
1122 .select(&selector)
1123 .next()
1124 .and_then(|el| el.value().attr(attr))
1125 .map(String::from)
1126 }
1127}
1128
1129#[cfg(test)]
1130#[allow(clippy::unwrap_used)]
1131mod tests {
1132 use super::*;
1133
1134 fn test_url() -> Url {
1135 Url::parse("https://example.com/page").unwrap()
1136 }
1137
1138 #[test]
1139 fn test_parse_title() {
1140 let html =
1141 r#"<!DOCTYPE html><html><head><title>My Page</title></head><body></body></html>"#;
1142 let page = HtmlParser::parse(html, &test_url()).unwrap();
1143 assert_eq!(page.meta.title.as_deref(), Some("My Page"));
1144 }
1145
1146 #[test]
1147 fn test_parse_meta_description() {
1148 let html = r#"<!DOCTYPE html><html><head>
1149 <meta name="description" content="A great page">
1150 </head><body></body></html>"#;
1151 let page = HtmlParser::parse(html, &test_url()).unwrap();
1152 assert_eq!(page.meta.description.as_deref(), Some("A great page"));
1153 }
1154
1155 #[test]
1156 fn test_parse_canonical() {
1157 let html = r#"<!DOCTYPE html><html><head>
1158 <link rel="canonical" href="/canonical-page">
1159 </head><body></body></html>"#;
1160 let page = HtmlParser::parse(html, &test_url()).unwrap();
1161 assert!(page.meta.canonical.is_some());
1162 assert!(page
1163 .meta
1164 .canonical
1165 .unwrap()
1166 .as_str()
1167 .contains("canonical-page"));
1168 }
1169
1170 #[test]
1171 fn test_parse_open_graph() {
1172 let html = r#"<!DOCTYPE html><html><head>
1173 <meta property="og:title" content="OG Title">
1174 <meta property="og:description" content="OG Desc">
1175 <meta property="og:image" content="https://example.com/img.png">
1176 <meta property="og:url" content="https://example.com">
1177 <meta property="og:type" content="website">
1178 </head><body></body></html>"#;
1179 let page = HtmlParser::parse(html, &test_url()).unwrap();
1180 assert_eq!(page.meta.og.title.as_deref(), Some("OG Title"));
1181 assert_eq!(page.meta.og.description.as_deref(), Some("OG Desc"));
1182 assert_eq!(
1183 page.meta.og.image.as_deref(),
1184 Some("https://example.com/img.png")
1185 );
1186 assert_eq!(page.meta.og.r#type.as_deref(), Some("website"));
1187 }
1188
1189 #[test]
1190 fn test_parse_twitter_cards() {
1191 let html = r#"<!DOCTYPE html><html><head>
1192 <meta name="twitter:card" content="summary_large_image">
1193 <meta name="twitter:site" content="@example">
1194 <meta name="twitter:creator" content="@author">
1195 <meta name="twitter:title" content="TW Title">
1196 <meta name="twitter:description" content="TW Desc">
1197 <meta name="twitter:image" content="https://example.com/tw.png">
1198 </head><body></body></html>"#;
1199 let page = HtmlParser::parse(html, &test_url()).unwrap();
1200 assert_eq!(
1201 page.meta.twitter.card.as_deref(),
1202 Some("summary_large_image")
1203 );
1204 assert_eq!(page.meta.twitter.site.as_deref(), Some("@example"));
1205 assert_eq!(page.meta.twitter.creator.as_deref(), Some("@author"));
1206 assert_eq!(page.meta.twitter.title.as_deref(), Some("TW Title"));
1207 }
1208
1209 #[test]
1210 fn test_parse_headings() {
1211 let html = r#"<!DOCTYPE html><html><body>
1212 <h1>Main Title</h1>
1213 <h2>Section</h2>
1214 <h2>Another</h2>
1215 <h3>Sub</h3>
1216 </body></html>"#;
1217 let page = HtmlParser::parse(html, &test_url()).unwrap();
1218 assert_eq!(page.headings.len(), 4);
1219 assert_eq!(page.headings[0].level, 1);
1220 assert_eq!(page.headings[0].text, "Main Title");
1221 assert_eq!(page.headings[1].level, 2);
1222 assert_eq!(page.headings[2].level, 2);
1223 assert_eq!(page.headings[3].level, 3);
1224 }
1225
1226 #[test]
1227 fn test_extract_links() {
1228 let html = r#"<!DOCTYPE html><html><body>
1229 <a href="/internal">Internal</a>
1230 <a href="https://external.com/page">External</a>
1231 <a href="/page" rel="nofollow noopen">Nofollow</a>
1232 </body></html>"#;
1233 let page = HtmlParser::parse(html, &test_url()).unwrap();
1234 assert_eq!(page.links.len(), 3);
1235
1236 assert_eq!(page.links[0].href, "https://example.com/internal");
1237 assert_eq!(page.links[0].text, "Internal");
1238 assert!(!page.links[0].is_external);
1239
1240 assert_eq!(page.links[1].href, "https://external.com/page");
1241 assert!(page.links[1].is_external);
1242
1243 assert!(page.links[2].rel.contains(&"nofollow".to_string()));
1244 }
1245
1246 #[test]
1247 fn test_extract_images() {
1248 let html = r#"<!DOCTYPE html><html><body>
1249 <img src="/img1.png" alt="Picture" width="100" height="200">
1250 <img src="/img2.jpg">
1251 <img src="/img3.webp" loading="lazy" data-src="/img3-real.webp">
1252 </body></html>"#;
1253 let page = HtmlParser::parse(html, &test_url()).unwrap();
1254 assert_eq!(page.images.len(), 3);
1255
1256 assert_eq!(page.images[0].src, "/img1.png");
1257 assert_eq!(page.images[0].alt, "Picture");
1258 assert_eq!(page.images[0].width, Some(100));
1259 assert_eq!(page.images[0].height, Some(200));
1260 assert!(page.images[0].has_alt);
1261 assert!(!page.images[0].is_lazy_loaded);
1262
1263 assert!(!page.images[1].has_alt);
1264
1265 assert!(page.images[2].is_lazy_loaded);
1266 }
1267
1268 #[test]
1269 fn test_extract_forms() {
1270 let html = r#"<!DOCTYPE html><html><body>
1271 <form action="/submit" method="post">
1272 <input type="text" name="q">
1273 <input type="file" name="doc">
1274 </form>
1275 <form>
1276 <input type="search" name="s">
1277 </form>
1278 </body></html>"#;
1279 let page = HtmlParser::parse(html, &test_url()).unwrap();
1280 assert_eq!(page.forms.len(), 2);
1281
1282 assert_eq!(page.forms[0].action.as_deref(), Some("/submit"));
1283 assert_eq!(page.forms[0].method, "post");
1284 assert_eq!(page.forms[0].input_count, 2);
1285 assert!(page.forms[0].has_file_input);
1286
1287 assert!(page.forms[1].has_search_input);
1288 }
1289
1290 #[test]
1291 fn test_extract_scripts() {
1292 let html = r#"<!DOCTYPE html><html><body>
1293 <script src="/app.js" async></script>
1294 <script defer src="/lib.js"></script>
1295 <script type="application/ld+json">{"@type":"WebSite"}</script>
1296 <script>console.log("hi")</script>
1297 </body></html>"#;
1298 let page = HtmlParser::parse(html, &test_url()).unwrap();
1299 assert_eq!(page.scripts.len(), 4);
1301 assert!(page.scripts[0].r#async);
1302 assert!(page.scripts[1].defer);
1303 assert_eq!(
1304 page.scripts[2].script_type.as_deref(),
1305 Some("application/ld+json")
1306 );
1307 }
1308
1309 #[test]
1310 fn test_extract_styles() {
1311 let html = r#"<!DOCTYPE html><html><head>
1312 <link rel="stylesheet" href="/style.css">
1313 <link rel="stylesheet" href="/print.css" media="print">
1314 <style>body { margin: 0; }</style>
1315 </head><body></body></html>"#;
1316 let page = HtmlParser::parse(html, &test_url()).unwrap();
1317 assert_eq!(page.styles.len(), 3);
1318 assert!(!page.styles[0].is_inline);
1319 assert_eq!(page.styles[0].href.as_deref(), Some("/style.css"));
1320 assert_eq!(page.styles[1].media.as_deref(), Some("print"));
1321 assert!(page.styles[2].is_inline);
1322 }
1323
1324 #[test]
1325 fn test_extract_json_ld() {
1326 let html = r#"<!DOCTYPE html><html><body>
1327 <script type="application/ld+json">
1328 {
1329 "@context": "https://schema.org",
1330 "@type": "Article",
1331 "headline": "Test"
1332 }
1333 </script>
1334 </body></html>"#;
1335 let page = HtmlParser::parse(html, &test_url()).unwrap();
1336 assert_eq!(page.structured_data.len(), 1);
1337 assert_eq!(
1338 page.structured_data[0].context.as_deref(),
1339 Some("https://schema.org")
1340 );
1341 assert_eq!(page.structured_data[0].r#type.as_deref(), Some("Article"));
1342 }
1343
1344 #[test]
1345 fn test_word_count() {
1346 let html = r#"<!DOCTYPE html><html><body>
1347 <h1>Hello World</h1>
1348 <p>This is a test paragraph with some words.</p>
1349 <script>var x = 1;</script>
1350 <style>.a { color: red; }</style>
1351 </body></html>"#;
1352 let page = HtmlParser::parse(html, &test_url()).unwrap();
1353 assert_eq!(page.word_count, 10);
1355 }
1356
1357 #[test]
1358 fn test_word_count_excludes_script_and_style() {
1359 let html = r#"<!DOCTYPE html><html><body>
1360 <p>Visible text here.</p>
1361 <script>function foo() { return "not counted"; }</script>
1362 <style>.hidden { display: none; }</style>
1363 <noscript>JavaScript is required</noscript>
1364 </body></html>"#;
1365 let page = HtmlParser::parse(html, &test_url()).unwrap();
1366 assert_eq!(page.word_count, 3);
1368 }
1369
1370 #[test]
1371 fn test_hreflang() {
1372 let html = r#"<!DOCTYPE html><html><head>
1373 <link rel="alternate" hreflang="en" href="https://example.com/en">
1374 <link rel="alternate" hreflang="fr" href="https://example.com/fr">
1375 <link rel="alternate" hreflang="x-default" href="https://example.com">
1376 </head><body></body></html>"#;
1377 let page = HtmlParser::parse(html, &test_url()).unwrap();
1378 assert_eq!(page.meta.hreflang.len(), 3);
1379 assert_eq!(page.meta.hreflang[0].lang, "en");
1380 assert_eq!(page.meta.hreflang[1].lang, "fr");
1381 assert_eq!(page.meta.hreflang[2].lang, "x-default");
1382 }
1383
1384 #[test]
1385 fn test_robots_meta() {
1386 let html = r#"<!DOCTYPE html><html><head>
1387 <meta name="robots" content="noindex, nofollow">
1388 </head><body></body></html>"#;
1389 let page = HtmlParser::parse(html, &test_url()).unwrap();
1390 assert!(page.meta.is_noindex());
1391 assert!(page.meta.is_nofollow());
1392 }
1393
1394 #[test]
1395 fn test_language_and_charset() {
1396 let html = r#"<!DOCTYPE html><html lang="en"><head>
1397 <meta charset="utf-8">
1398 <meta name="viewport" content="width=device-width, initial-scale=1">
1399 </head><body></body></html>"#;
1400 let page = HtmlParser::parse(html, &test_url()).unwrap();
1401 assert_eq!(page.meta.language.as_deref(), Some("en"));
1402 assert!(page.meta.charset.is_some());
1403 assert!(page.meta.viewport.is_some());
1404 }
1405
1406 #[test]
1407 fn test_empty_html() {
1408 let page = HtmlParser::parse("", &test_url()).unwrap();
1409 assert!(page.meta.title.is_none());
1410 assert!(page.headings.is_empty());
1411 assert!(page.links.is_empty());
1412 assert!(page.images.is_empty());
1413 assert_eq!(page.word_count, 0);
1414 }
1415
1416 #[test]
1417 fn test_title_fallback_to_og() {
1418 let html = r#"<!DOCTYPE html><html><head>
1419 <meta property="og:title" content="OG Fallback Title">
1420 </head><body></body></html>"#;
1421 let page = HtmlParser::parse(html, &test_url()).unwrap();
1422 assert_eq!(page.meta.title.as_deref(), Some("OG Fallback Title"));
1423 }
1424
1425 #[test]
1426 fn test_parsed_page_serialization() {
1427 let html = r#"<!DOCTYPE html><html><head><title>Test</title></head>
1428 <body><h1>Hi</h1><a href="/link">link</a></body></html>"#;
1429 let page = HtmlParser::parse(html, &test_url()).unwrap();
1430 let json = serde_json::to_string(&page).unwrap();
1431 let deser: ParsedPage = serde_json::from_str(&json).unwrap();
1432 assert_eq!(page.meta.title, deser.meta.title);
1433 assert_eq!(page.headings.len(), deser.headings.len());
1434 assert_eq!(page.word_count, deser.word_count);
1435 }
1436}