Skip to main content

crawlkit_engine/
parser.rs

1use scraper::{Html, Selector};
2use serde::{Deserialize, Serialize};
3use url::Url;
4
5use crate::meta::{HreflangTag, MetaTags, OpenGraphTags, TwitterTags};
6
7/// Cached CSS selectors compiled once, reused on every parse call.
8/// `OnceLock` guarantees thread-safe lazy initialization with zero cost after first use.
9/// All selector patterns are static compile-time-known strings.
10#[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/// Errors that can occur during HTML parsing.
65///
66/// Covers CSS selector compilation failures, URL resolution errors,
67/// and JSON-LD parsing issues.
68#[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/// A heading extracted from the page (H1–H6).
81///
82/// Used by the [`HeadingHierarchyAnalyzer`](crate::HeadingHierarchyAnalyzer)
83/// to check heading hierarchy and count H1 tags.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Heading {
86    /// Heading level (1-6).
87    pub level: u8,
88    /// Text content of the heading.
89    pub text: String,
90    /// Character length of the heading text.
91    pub length: usize,
92}
93
94/// An image extracted from the page.
95///
96/// Used by the [`ImageAnalyzer`](crate::ImageAnalyzer) to check for
97/// missing alt text, lazy loading, and dimension attributes.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ExtractedImage {
100    /// Image source URL.
101    pub src: String,
102    /// Alt text for accessibility.
103    pub alt: String,
104    /// Width attribute (if specified).
105    pub width: Option<u32>,
106    /// Height attribute (if specified).
107    pub height: Option<u32>,
108    /// Whether the image has an alt attribute.
109    pub has_alt: bool,
110    /// Whether the image uses lazy loading (`loading="lazy"` or `data-src`).
111    pub is_lazy_loaded: bool,
112}
113
114/// A link extracted from the page.
115///
116/// Used by the [`LinkAnalyzer`](crate::LinkAnalyzer) to compute internal/external
117/// link counts, nofollow detection, and orphan page identification.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ExtractedLink {
120    /// Resolved href URL.
121    pub href: String,
122    /// Link anchor text.
123    pub text: String,
124    /// Rel attribute values (e.g., "nofollow", "noopener").
125    pub rel: Vec<String>,
126    /// Whether the link points to a different domain.
127    pub is_external: bool,
128    /// ARIA label for the link (accessibility).
129    pub aria_label: Option<String>,
130    /// Alt text from images inside the link (accessibility).
131    pub img_alt: Option<String>,
132}
133
134/// An input element inside a form.
135///
136/// Tracks accessibility attributes (labels, ARIA) and input metadata
137/// for form analysis.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ExtractedInput {
140    /// Input type attribute (text, email, file, etc.).
141    pub input_type: Option<String>,
142    /// Input name attribute.
143    pub name: Option<String>,
144    /// Input id attribute.
145    pub id: Option<String>,
146    /// Whether the input has an associated label.
147    pub has_label: bool,
148    /// ARIA label for the input.
149    pub aria_label: Option<String>,
150    /// ARIA labelledby reference.
151    pub aria_labelledby: Option<String>,
152    /// ARIA describedby reference.
153    pub aria_describedby: Option<String>,
154    /// Placeholder text.
155    pub placeholder: Option<String>,
156    /// Whether the input is required.
157    pub required: bool,
158}
159
160/// A form detected on the page.
161///
162/// Used by accessibility analyzers to check form structure, labeling,
163/// and fieldset/legend usage.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct ExtractedForm {
166    /// Form action URL.
167    pub action: Option<String>,
168    /// HTTP method (get, post).
169    pub method: String,
170    /// Number of input elements.
171    pub input_count: usize,
172    /// Whether the form contains a file input.
173    pub has_file_input: bool,
174    /// Whether the form contains a search input.
175    pub has_search_input: bool,
176    /// Extracted input elements with accessibility info.
177    pub inputs: Vec<ExtractedInput>,
178    /// Whether the form uses a fieldset.
179    pub has_fieldset: bool,
180    /// Whether the form uses a legend.
181    pub has_legend: bool,
182}
183
184/// Script tag information.
185///
186/// Tracks script loading attributes for performance analysis.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct ScriptInfo {
189    /// External script source URL.
190    pub src: Option<String>,
191    /// Whether the script has the `async` attribute.
192    pub r#async: bool,
193    /// Whether the script has the `defer` attribute.
194    pub defer: bool,
195    /// Script type attribute (e.g., "application/ld+json").
196    pub script_type: Option<String>,
197}
198
199/// Style/link stylesheet information.
200///
201/// Tracks stylesheet loading for performance and render-blocking analysis.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct StyleInfo {
204    /// External stylesheet URL.
205    pub href: Option<String>,
206    /// Media query (e.g., "print", "screen").
207    pub media: Option<String>,
208    /// Whether this is an inline `<style>` block.
209    pub is_inline: bool,
210}
211
212/// Structured data extracted from JSON-LD `<script>` blocks.
213///
214/// Contains the parsed `@context`, `@type`, and full JSON data for
215/// schema validation and analysis.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct StructuredData {
218    /// The `@context` value (e.g., "https://schema.org").
219    pub context: Option<String>,
220    /// The `@type` value (e.g., "Article", "Product").
221    pub r#type: Option<String>,
222    /// The full structured data JSON.
223    pub data: serde_json::Value,
224}
225
226/// Complete parsed representation of a page.
227///
228/// Contains all SEO-relevant data extracted from raw HTML by [`HtmlParser`],
229/// including meta tags, headings, links, images, forms, scripts, styles,
230/// structured data, accessibility landmarks, and social media metadata.
231///
232/// This is the primary input to the analysis engine.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct ParsedPage {
235    /// The page URL.
236    pub url: String,
237    /// Extracted meta tags.
238    pub meta: MetaTags,
239    /// Headings (H1-H6) in document order.
240    pub headings: Vec<Heading>,
241    /// All links on the page.
242    pub links: Vec<ExtractedLink>,
243    /// All images on the page.
244    pub images: Vec<ExtractedImage>,
245    /// Forms detected on the page.
246    pub forms: Vec<ExtractedForm>,
247    /// Script tags on the page.
248    pub scripts: Vec<ScriptInfo>,
249    /// Stylesheets (external and inline).
250    pub styles: Vec<StyleInfo>,
251    /// JSON-LD structured data blocks.
252    pub structured_data: Vec<StructuredData>,
253    /// Word count of visible text content.
254    pub word_count: usize,
255
256    // Accessibility fields
257    /// Landmark roles found on the page (e.g. "banner", "main", "navigation").
258    pub landmarks: Vec<String>,
259    /// Whether the page has a skip-to-content link.
260    pub has_skip_link: bool,
261    /// Whether the page has a `<main>` element or role="main".
262    pub has_main_landmark: bool,
263    /// Whether the page has a `<nav>` element or role="navigation".
264    pub has_nav_landmark: bool,
265    /// Whether any element has tabindex > 0 (positive tabindex).
266    pub has_positive_tabindex: bool,
267    /// Number of elements with tabindex=-1 (removed from tab order).
268    pub tabindex_negative_count: usize,
269    /// Number of ARIA roles used on the page.
270    pub aria_role_count: usize,
271    /// Number of elements with aria-label or aria-labelledby.
272    pub aria_label_count: usize,
273    /// Whether the html element has a lang attribute.
274    pub has_lang_attribute: bool,
275    /// The html lang attribute value.
276    pub html_lang: Option<String>,
277    /// Whether any element uses aria-hidden="true".
278    pub has_aria_hidden: bool,
279    /// Table accessibility summary.
280    pub tables_with_headers: usize,
281    pub tables_total: usize,
282    pub tables_with_captions: usize,
283
284    // Social media fields
285    /// OG image width (from og:image:width meta tag).
286    pub og_image_width: Option<u32>,
287    /// OG image height (from og:image:height meta tag).
288    pub og_image_height: Option<u32>,
289}
290
291/// HTML parser that extracts structured data from raw HTML.
292///
293/// Stateless parser using the `scraper` crate for CSS selector-based
294/// extraction. All methods are static and thread-safe.
295///
296/// # Examples
297///
298/// ```rust
299/// use crawlkit_engine::{HtmlParser, parser::ParseError};
300/// use url::Url;
301///
302/// let html = r#"<!DOCTYPE html><html><head><title>Test</title></head>
303/// <body><h1>Hello</h1><a href="/link">link</a></body></html>"#;
304/// let url = Url::parse("https://example.com/page").unwrap();
305/// let page = HtmlParser::parse(html, &url).unwrap();
306///
307/// assert_eq!(page.meta.title.as_deref(), Some("Test"));
308/// assert_eq!(page.headings.len(), 1);
309/// assert_eq!(page.links.len(), 1);
310/// ```
311pub struct HtmlParser;
312
313impl HtmlParser {
314    /// Parse an HTML document and extract all SEO-relevant data.
315    ///
316    /// Extracts meta tags, headings, links, images, forms, scripts,
317    /// stylesheets, structured data (JSON-LD), accessibility landmarks,
318    /// and social media metadata.
319    ///
320    /// # Errors
321    ///
322    /// Currently always returns `Ok`. The `ParseError` type is reserved
323    /// for future selector compilation or URL resolution failures.
324    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    // ---------------------------------------------------------------------------
371    // Meta tags
372    // ---------------------------------------------------------------------------
373
374    fn extract_meta(document: &Html, page_url: &Url) -> MetaTags {
375        let mut title = Self::select_text(document, "title");
376
377        // Fall back to <meta property="og:title"> or <meta name="twitter:title">
378        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    // ---------------------------------------------------------------------------
458    // Headings
459    // ---------------------------------------------------------------------------
460
461    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    // ---------------------------------------------------------------------------
483    // Links
484    // ---------------------------------------------------------------------------
485
486    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                // Resolve relative URLs against the page URL
507                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                // Accessibility: extract aria-label and img alt for link text analysis
513                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    // ---------------------------------------------------------------------------
533    // Images
534    // ---------------------------------------------------------------------------
535
536    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    // ---------------------------------------------------------------------------
573    // Forms
574    // ---------------------------------------------------------------------------
575
576    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                // Collect all label `for` targets within this form
609                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                // Collect all input nodes that are descendants of a <label>
615                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    // ---------------------------------------------------------------------------
688    // Scripts
689    // ---------------------------------------------------------------------------
690
691    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    // ---------------------------------------------------------------------------
716    // Styles
717    // ---------------------------------------------------------------------------
718
719    fn extract_styles(document: &Html) -> Vec<StyleInfo> {
720        let mut styles = Vec::new();
721
722        // External stylesheets via <link rel="stylesheet">
723        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        // Inline <style> blocks
740        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    // ---------------------------------------------------------------------------
760    // Structured data (JSON-LD)
761    // ---------------------------------------------------------------------------
762
763    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    // ---------------------------------------------------------------------------
802    // Word count
803    // ---------------------------------------------------------------------------
804
805    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        // Collect the set of node IDs to skip (script/style/noscript elements).
812        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; // Node not found; skip safely
836            };
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    // ---------------------------------------------------------------------------
864    // Accessibility data extraction
865    // ---------------------------------------------------------------------------
866
867    /// Returns (landmarks, has_skip_link, has_main, has_nav, has_positive_tabindex,
868    /// tabindex_neg_count, aria_role_count, aria_label_count, has_lang, html_lang,
869    /// has_aria_hidden, tables_with_headers, tables_total, tables_with_captions).
870    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        // Check html lang
902        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        // Landmark detection via semantic HTML elements
913        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        // Skip link: first <a> whose href starts with "#" and contains "skip" in text or class
974        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        // Tabindex and ARIA attribute scanning
987        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            // tabindex
993            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            // ARIA roles
1004            if el.attr("role").is_some() {
1005                aria_role_count += 1;
1006            }
1007
1008            // ARIA labels
1009            if el.attr("aria-label").is_some() || el.attr("aria-labelledby").is_some() {
1010                aria_label_count += 1;
1011            }
1012
1013            // aria-hidden
1014            if el.attr("aria-hidden") == Some("true") {
1015                has_aria_hidden = true;
1016            }
1017        }
1018
1019        // Table accessibility
1020        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    // ---------------------------------------------------------------------------
1055    // Social media data extraction
1056    // ---------------------------------------------------------------------------
1057
1058    /// Returns (og_image_width, og_image_height).
1059    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    // ---------------------------------------------------------------------------
1068    // Helpers
1069    // ---------------------------------------------------------------------------
1070
1071    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    /// Get the `content` attribute of `<meta name="X" content="...">` or
1083    /// `<meta property="X" content="...">`.
1084    fn get_meta_content(document: &Html, name_or_property: &str) -> Option<String> {
1085        // Try name= first
1086        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        // Try property= (OG tags)
1101        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    /// Get an attribute value from the first element matching a selector.
1119    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        // 4 total script tags
1300        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        // "Hello World" = 2, "This is a test paragraph with some words." = 8
1354        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        // Only "Visible text here." should count = 3
1367        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}