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/// Streaming HTML parser that processes content incrementally.
1130///
1131/// Buffers HTML chunks as they arrive and parses when a complete document
1132/// is detected. This is useful for processing HTML from streaming sources
1133/// like HTTP responses.
1134///
1135/// # Examples
1136///
1137/// ```rust
1138/// use crawlkit_engine::parser::StreamingHtmlParser;
1139///
1140/// let mut parser = StreamingHtmlParser::new();
1141/// parser.feed("<!DOCTYPE html><html><head><title>Test</title></head>");
1142/// parser.feed("<body><h1>Hello</h1></body></html>");
1143///
1144/// assert!(parser.has_complete_document());
1145/// let page = parser.parse().unwrap();
1146/// assert_eq!(page.meta.title.as_deref(), Some("Test"));
1147/// ```
1148pub struct StreamingHtmlParser {
1149    buffer: String,
1150}
1151
1152impl StreamingHtmlParser {
1153    /// Create a new streaming parser.
1154    pub fn new() -> Self {
1155        Self {
1156            buffer: String::new(),
1157        }
1158    }
1159
1160    /// Feed a chunk of HTML content into the parser.
1161    ///
1162    /// The chunk is appended to the internal buffer for later parsing.
1163    pub fn feed(&mut self, chunk: &str) {
1164        self.buffer.push_str(chunk);
1165    }
1166
1167    /// Check if the accumulated content contains a complete HTML document.
1168    ///
1169    /// Returns `true` if the buffer contains `</html>` or `</body>` tags,
1170    /// indicating the document is likely complete.
1171    pub fn has_complete_document(&self) -> bool {
1172        self.buffer.contains("</html>") || self.buffer.contains("</body>")
1173    }
1174
1175    /// Parse the accumulated HTML content.
1176    ///
1177    /// Delegates to [`HtmlParser::parse`] with a blank URL.
1178    /// Returns a [`ParsedPage`] containing all extracted SEO data.
1179    ///
1180    /// # Errors
1181    ///
1182    /// Returns `ParseError` if parsing fails (currently never happens).
1183    pub fn parse(&mut self) -> Result<ParsedPage, ParseError> {
1184        let url = url::Url::parse("about:blank")?;
1185        HtmlParser::parse(&self.buffer, &url)
1186    }
1187
1188    /// Get the current buffer size in bytes.
1189    pub fn buffer_size(&self) -> usize {
1190        self.buffer.len()
1191    }
1192
1193    /// Clear the internal buffer.
1194    pub fn clear(&mut self) {
1195        self.buffer.clear();
1196    }
1197
1198    /// Get a reference to the buffered content.
1199    pub fn buffer(&self) -> &str {
1200        &self.buffer
1201    }
1202
1203    /// Consume the parser and return the buffered content.
1204    pub fn into_inner(self) -> String {
1205        self.buffer
1206    }
1207}
1208
1209impl Default for StreamingHtmlParser {
1210    fn default() -> Self {
1211        Self::new()
1212    }
1213}
1214
1215#[cfg(test)]
1216#[allow(clippy::unwrap_used)]
1217mod tests {
1218    use super::*;
1219
1220    fn test_url() -> Url {
1221        Url::parse("https://example.com/page").unwrap()
1222    }
1223
1224    #[test]
1225    fn test_parse_title() {
1226        let html =
1227            r#"<!DOCTYPE html><html><head><title>My Page</title></head><body></body></html>"#;
1228        let page = HtmlParser::parse(html, &test_url()).unwrap();
1229        assert_eq!(page.meta.title.as_deref(), Some("My Page"));
1230    }
1231
1232    #[test]
1233    fn test_parse_meta_description() {
1234        let html = r#"<!DOCTYPE html><html><head>
1235            <meta name="description" content="A great page">
1236        </head><body></body></html>"#;
1237        let page = HtmlParser::parse(html, &test_url()).unwrap();
1238        assert_eq!(page.meta.description.as_deref(), Some("A great page"));
1239    }
1240
1241    #[test]
1242    fn test_parse_canonical() {
1243        let html = r#"<!DOCTYPE html><html><head>
1244            <link rel="canonical" href="/canonical-page">
1245        </head><body></body></html>"#;
1246        let page = HtmlParser::parse(html, &test_url()).unwrap();
1247        assert!(page.meta.canonical.is_some());
1248        assert!(page
1249            .meta
1250            .canonical
1251            .unwrap()
1252            .as_str()
1253            .contains("canonical-page"));
1254    }
1255
1256    #[test]
1257    fn test_parse_open_graph() {
1258        let html = r#"<!DOCTYPE html><html><head>
1259            <meta property="og:title" content="OG Title">
1260            <meta property="og:description" content="OG Desc">
1261            <meta property="og:image" content="https://example.com/img.png">
1262            <meta property="og:url" content="https://example.com">
1263            <meta property="og:type" content="website">
1264        </head><body></body></html>"#;
1265        let page = HtmlParser::parse(html, &test_url()).unwrap();
1266        assert_eq!(page.meta.og.title.as_deref(), Some("OG Title"));
1267        assert_eq!(page.meta.og.description.as_deref(), Some("OG Desc"));
1268        assert_eq!(
1269            page.meta.og.image.as_deref(),
1270            Some("https://example.com/img.png")
1271        );
1272        assert_eq!(page.meta.og.r#type.as_deref(), Some("website"));
1273    }
1274
1275    #[test]
1276    fn test_parse_twitter_cards() {
1277        let html = r#"<!DOCTYPE html><html><head>
1278            <meta name="twitter:card" content="summary_large_image">
1279            <meta name="twitter:site" content="@example">
1280            <meta name="twitter:creator" content="@author">
1281            <meta name="twitter:title" content="TW Title">
1282            <meta name="twitter:description" content="TW Desc">
1283            <meta name="twitter:image" content="https://example.com/tw.png">
1284        </head><body></body></html>"#;
1285        let page = HtmlParser::parse(html, &test_url()).unwrap();
1286        assert_eq!(
1287            page.meta.twitter.card.as_deref(),
1288            Some("summary_large_image")
1289        );
1290        assert_eq!(page.meta.twitter.site.as_deref(), Some("@example"));
1291        assert_eq!(page.meta.twitter.creator.as_deref(), Some("@author"));
1292        assert_eq!(page.meta.twitter.title.as_deref(), Some("TW Title"));
1293    }
1294
1295    #[test]
1296    fn test_parse_headings() {
1297        let html = r#"<!DOCTYPE html><html><body>
1298            <h1>Main Title</h1>
1299            <h2>Section</h2>
1300            <h2>Another</h2>
1301            <h3>Sub</h3>
1302        </body></html>"#;
1303        let page = HtmlParser::parse(html, &test_url()).unwrap();
1304        assert_eq!(page.headings.len(), 4);
1305        assert_eq!(page.headings[0].level, 1);
1306        assert_eq!(page.headings[0].text, "Main Title");
1307        assert_eq!(page.headings[1].level, 2);
1308        assert_eq!(page.headings[2].level, 2);
1309        assert_eq!(page.headings[3].level, 3);
1310    }
1311
1312    #[test]
1313    fn test_extract_links() {
1314        let html = r#"<!DOCTYPE html><html><body>
1315            <a href="/internal">Internal</a>
1316            <a href="https://external.com/page">External</a>
1317            <a href="/page" rel="nofollow noopen">Nofollow</a>
1318        </body></html>"#;
1319        let page = HtmlParser::parse(html, &test_url()).unwrap();
1320        assert_eq!(page.links.len(), 3);
1321
1322        assert_eq!(page.links[0].href, "https://example.com/internal");
1323        assert_eq!(page.links[0].text, "Internal");
1324        assert!(!page.links[0].is_external);
1325
1326        assert_eq!(page.links[1].href, "https://external.com/page");
1327        assert!(page.links[1].is_external);
1328
1329        assert!(page.links[2].rel.contains(&"nofollow".to_string()));
1330    }
1331
1332    #[test]
1333    fn test_extract_images() {
1334        let html = r#"<!DOCTYPE html><html><body>
1335            <img src="/img1.png" alt="Picture" width="100" height="200">
1336            <img src="/img2.jpg">
1337            <img src="/img3.webp" loading="lazy" data-src="/img3-real.webp">
1338        </body></html>"#;
1339        let page = HtmlParser::parse(html, &test_url()).unwrap();
1340        assert_eq!(page.images.len(), 3);
1341
1342        assert_eq!(page.images[0].src, "/img1.png");
1343        assert_eq!(page.images[0].alt, "Picture");
1344        assert_eq!(page.images[0].width, Some(100));
1345        assert_eq!(page.images[0].height, Some(200));
1346        assert!(page.images[0].has_alt);
1347        assert!(!page.images[0].is_lazy_loaded);
1348
1349        assert!(!page.images[1].has_alt);
1350
1351        assert!(page.images[2].is_lazy_loaded);
1352    }
1353
1354    #[test]
1355    fn test_extract_forms() {
1356        let html = r#"<!DOCTYPE html><html><body>
1357            <form action="/submit" method="post">
1358                <input type="text" name="q">
1359                <input type="file" name="doc">
1360            </form>
1361            <form>
1362                <input type="search" name="s">
1363            </form>
1364        </body></html>"#;
1365        let page = HtmlParser::parse(html, &test_url()).unwrap();
1366        assert_eq!(page.forms.len(), 2);
1367
1368        assert_eq!(page.forms[0].action.as_deref(), Some("/submit"));
1369        assert_eq!(page.forms[0].method, "post");
1370        assert_eq!(page.forms[0].input_count, 2);
1371        assert!(page.forms[0].has_file_input);
1372
1373        assert!(page.forms[1].has_search_input);
1374    }
1375
1376    #[test]
1377    fn test_extract_scripts() {
1378        let html = r#"<!DOCTYPE html><html><body>
1379            <script src="/app.js" async></script>
1380            <script defer src="/lib.js"></script>
1381            <script type="application/ld+json">{"@type":"WebSite"}</script>
1382            <script>console.log("hi")</script>
1383        </body></html>"#;
1384        let page = HtmlParser::parse(html, &test_url()).unwrap();
1385        // 4 total script tags
1386        assert_eq!(page.scripts.len(), 4);
1387        assert!(page.scripts[0].r#async);
1388        assert!(page.scripts[1].defer);
1389        assert_eq!(
1390            page.scripts[2].script_type.as_deref(),
1391            Some("application/ld+json")
1392        );
1393    }
1394
1395    #[test]
1396    fn test_extract_styles() {
1397        let html = r#"<!DOCTYPE html><html><head>
1398            <link rel="stylesheet" href="/style.css">
1399            <link rel="stylesheet" href="/print.css" media="print">
1400            <style>body { margin: 0; }</style>
1401        </head><body></body></html>"#;
1402        let page = HtmlParser::parse(html, &test_url()).unwrap();
1403        assert_eq!(page.styles.len(), 3);
1404        assert!(!page.styles[0].is_inline);
1405        assert_eq!(page.styles[0].href.as_deref(), Some("/style.css"));
1406        assert_eq!(page.styles[1].media.as_deref(), Some("print"));
1407        assert!(page.styles[2].is_inline);
1408    }
1409
1410    #[test]
1411    fn test_extract_json_ld() {
1412        let html = r#"<!DOCTYPE html><html><body>
1413            <script type="application/ld+json">
1414            {
1415                "@context": "https://schema.org",
1416                "@type": "Article",
1417                "headline": "Test"
1418            }
1419            </script>
1420        </body></html>"#;
1421        let page = HtmlParser::parse(html, &test_url()).unwrap();
1422        assert_eq!(page.structured_data.len(), 1);
1423        assert_eq!(
1424            page.structured_data[0].context.as_deref(),
1425            Some("https://schema.org")
1426        );
1427        assert_eq!(page.structured_data[0].r#type.as_deref(), Some("Article"));
1428    }
1429
1430    #[test]
1431    fn test_word_count() {
1432        let html = r#"<!DOCTYPE html><html><body>
1433            <h1>Hello World</h1>
1434            <p>This is a test paragraph with some words.</p>
1435            <script>var x = 1;</script>
1436            <style>.a { color: red; }</style>
1437        </body></html>"#;
1438        let page = HtmlParser::parse(html, &test_url()).unwrap();
1439        // "Hello World" = 2, "This is a test paragraph with some words." = 8
1440        assert_eq!(page.word_count, 10);
1441    }
1442
1443    #[test]
1444    fn test_word_count_excludes_script_and_style() {
1445        let html = r#"<!DOCTYPE html><html><body>
1446            <p>Visible text here.</p>
1447            <script>function foo() { return "not counted"; }</script>
1448            <style>.hidden { display: none; }</style>
1449            <noscript>JavaScript is required</noscript>
1450        </body></html>"#;
1451        let page = HtmlParser::parse(html, &test_url()).unwrap();
1452        // Only "Visible text here." should count = 3
1453        assert_eq!(page.word_count, 3);
1454    }
1455
1456    #[test]
1457    fn test_hreflang() {
1458        let html = r#"<!DOCTYPE html><html><head>
1459            <link rel="alternate" hreflang="en" href="https://example.com/en">
1460            <link rel="alternate" hreflang="fr" href="https://example.com/fr">
1461            <link rel="alternate" hreflang="x-default" href="https://example.com">
1462        </head><body></body></html>"#;
1463        let page = HtmlParser::parse(html, &test_url()).unwrap();
1464        assert_eq!(page.meta.hreflang.len(), 3);
1465        assert_eq!(page.meta.hreflang[0].lang, "en");
1466        assert_eq!(page.meta.hreflang[1].lang, "fr");
1467        assert_eq!(page.meta.hreflang[2].lang, "x-default");
1468    }
1469
1470    #[test]
1471    fn test_robots_meta() {
1472        let html = r#"<!DOCTYPE html><html><head>
1473            <meta name="robots" content="noindex, nofollow">
1474        </head><body></body></html>"#;
1475        let page = HtmlParser::parse(html, &test_url()).unwrap();
1476        assert!(page.meta.is_noindex());
1477        assert!(page.meta.is_nofollow());
1478    }
1479
1480    #[test]
1481    fn test_language_and_charset() {
1482        let html = r#"<!DOCTYPE html><html lang="en"><head>
1483            <meta charset="utf-8">
1484            <meta name="viewport" content="width=device-width, initial-scale=1">
1485        </head><body></body></html>"#;
1486        let page = HtmlParser::parse(html, &test_url()).unwrap();
1487        assert_eq!(page.meta.language.as_deref(), Some("en"));
1488        assert!(page.meta.charset.is_some());
1489        assert!(page.meta.viewport.is_some());
1490    }
1491
1492    #[test]
1493    fn test_empty_html() {
1494        let page = HtmlParser::parse("", &test_url()).unwrap();
1495        assert!(page.meta.title.is_none());
1496        assert!(page.headings.is_empty());
1497        assert!(page.links.is_empty());
1498        assert!(page.images.is_empty());
1499        assert_eq!(page.word_count, 0);
1500    }
1501
1502    #[test]
1503    fn test_title_fallback_to_og() {
1504        let html = r#"<!DOCTYPE html><html><head>
1505            <meta property="og:title" content="OG Fallback Title">
1506        </head><body></body></html>"#;
1507        let page = HtmlParser::parse(html, &test_url()).unwrap();
1508        assert_eq!(page.meta.title.as_deref(), Some("OG Fallback Title"));
1509    }
1510
1511    #[test]
1512    fn test_parsed_page_serialization() {
1513        let html = r#"<!DOCTYPE html><html><head><title>Test</title></head>
1514        <body><h1>Hi</h1><a href="/link">link</a></body></html>"#;
1515        let page = HtmlParser::parse(html, &test_url()).unwrap();
1516        let json = serde_json::to_string(&page).unwrap();
1517        let deser: ParsedPage = serde_json::from_str(&json).unwrap();
1518        assert_eq!(page.meta.title, deser.meta.title);
1519        assert_eq!(page.headings.len(), deser.headings.len());
1520        assert_eq!(page.word_count, deser.word_count);
1521    }
1522
1523    #[test]
1524    fn test_streaming_parser_new() {
1525        let parser = StreamingHtmlParser::new();
1526        assert_eq!(parser.buffer_size(), 0);
1527        assert!(!parser.has_complete_document());
1528    }
1529
1530    #[test]
1531    fn test_streaming_parser_default() {
1532        let parser = StreamingHtmlParser::default();
1533        assert_eq!(parser.buffer_size(), 0);
1534    }
1535
1536    #[test]
1537    fn test_streaming_parser_feed() {
1538        let mut parser = StreamingHtmlParser::new();
1539        parser.feed("<html><body>");
1540        assert_eq!(parser.buffer_size(), 12);
1541        assert!(!parser.has_complete_document());
1542
1543        parser.feed("<h1>Hello</h1>");
1544        assert_eq!(parser.buffer_size(), 26);
1545        assert!(!parser.has_complete_document());
1546    }
1547
1548    #[test]
1549    fn test_streaming_parser_complete_document_html() {
1550        let mut parser = StreamingHtmlParser::new();
1551        parser.feed("<!DOCTYPE html><html><head><title>Test</title></head>");
1552        parser.feed("<body><h1>Hello</h1></body></html>");
1553        assert!(parser.has_complete_document());
1554    }
1555
1556    #[test]
1557    fn test_streaming_parser_complete_document_body() {
1558        let mut parser = StreamingHtmlParser::new();
1559        parser.feed("<!DOCTYPE html><html><head><title>Test</title></head>");
1560        parser.feed("<body><h1>Hello</h1></body>");
1561        assert!(parser.has_complete_document());
1562    }
1563
1564    #[test]
1565    fn test_streaming_parser_parse() {
1566        let mut parser = StreamingHtmlParser::new();
1567        parser.feed(r#"<!DOCTYPE html><html><head><title>Stream Test</title></head>"#);
1568        parser.feed(r#"<body><h1>Hello World</h1><a href="/link">link</a></body></html>"#);
1569
1570        let page = parser.parse().unwrap();
1571        assert_eq!(page.meta.title.as_deref(), Some("Stream Test"));
1572        assert_eq!(page.url, "about:blank");
1573    }
1574
1575    #[test]
1576    fn test_streaming_parser_parse_incomplete() {
1577        let mut parser = StreamingHtmlParser::new();
1578        parser.feed(r#"<!DOCTYPE html><html><head><title>Incomplete</title></head>"#);
1579        parser.feed(r#"<body><h1>Partial content"#);
1580
1581        // Should still parse, even without complete document markers
1582        let page = parser.parse().unwrap();
1583        assert_eq!(page.meta.title.as_deref(), Some("Incomplete"));
1584    }
1585
1586    #[test]
1587    fn test_streaming_parser_clear() {
1588        let mut parser = StreamingHtmlParser::new();
1589        parser.feed("<html><body><h1>Hello</h1></body></html>");
1590        assert_eq!(parser.buffer_size(), 40);
1591
1592        parser.clear();
1593        assert_eq!(parser.buffer_size(), 0);
1594        assert!(!parser.has_complete_document());
1595    }
1596
1597    #[test]
1598    fn test_streaming_parser_into_inner() {
1599        let mut parser = StreamingHtmlParser::new();
1600        parser.feed("<html><body></body></html>");
1601        let buffer = parser.into_inner();
1602        assert_eq!(buffer, "<html><body></body></html>");
1603    }
1604
1605    #[test]
1606    fn test_streaming_parser_multiple_chunks() {
1607        let mut parser = StreamingHtmlParser::new();
1608        parser.feed("<!DOCTYPE html>");
1609        parser.feed("<html>");
1610        parser.feed("<head>");
1611        parser.feed("<title>Multi Chunk</title>");
1612        parser.feed("</head>");
1613        parser.feed("<body>");
1614        parser.feed("<p>Content</p>");
1615        parser.feed("</body>");
1616        parser.feed("</html>");
1617
1618        assert!(parser.has_complete_document());
1619        let page = parser.parse().unwrap();
1620        assert_eq!(page.meta.title.as_deref(), Some("Multi Chunk"));
1621        assert_eq!(page.word_count, 1);
1622    }
1623}