Skip to main content

headless_engine/dom/
mod.rs

1pub mod interactive;
2pub mod markdown;
3pub mod screenshot;
4
5use anyhow::Result;
6pub use interactive::{InteractiveElement, InteractiveParser, PageObservation};
7use markdown::HtmlToMarkdown;
8use scraper::{Html, Selector};
9pub use screenshot::{PageRenderer, RealBrowserScreenshot, ScreenshotResult};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct OrganicResult {
14    pub title: String,
15    pub link: String,
16    pub snippet: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct NewsResult {
21    pub headline: String,
22    pub source: String,
23    pub time_ago: String,
24    pub link: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct VideoResult {
29    pub title: String,
30    pub video_id: String,
31    pub url: String,
32    pub channel: String,
33    pub duration: String,
34    pub views: String,
35    pub published_time: String,
36    pub description: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ImageResult {
41    pub title: String,
42    pub image_url: String,
43    pub source_url: String,
44    pub domain: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct AiOverview {
49    pub summary: String,
50    pub source_references: Vec<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct KnowledgePanel {
55    pub title: String,
56    pub subtitle: String,
57    pub description: String,
58    pub attributes: Vec<(String, String)>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct LinkInfo {
63    pub text: String,
64    pub href: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct FormInputInfo {
69    pub name: String,
70    pub input_type: String,
71    pub value: String,
72    pub placeholder: String,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct FormInfo {
77    pub action: String,
78    pub method: String,
79    pub inputs: Vec<FormInputInfo>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SearchResults {
84    pub page_title: String,
85    pub ai_overview: Option<AiOverview>,
86    pub knowledge_panel: Option<KnowledgePanel>,
87    pub image_results: Vec<ImageResult>,
88    pub video_results: Vec<VideoResult>,
89    pub news_results: Vec<NewsResult>,
90    pub organic_results: Vec<OrganicResult>,
91    pub related_questions: Vec<String>,
92    pub is_captcha_detected: bool,
93    pub total_results_found: usize,
94}
95
96impl SearchResults {
97    pub fn to_markdown(&self) -> String {
98        let mut md = String::new();
99        md.push_str(&format!("# {}\n\n", self.page_title));
100
101        if let Some(ai) = &self.ai_overview {
102            md.push_str("## โœจ Google AI Overview\n\n");
103            md.push_str(&ai.summary);
104            md.push_str("\n\n");
105            if !ai.source_references.is_empty() {
106                md.push_str("**Sources:**\n");
107                for s in &ai.source_references {
108                    md.push_str(&format!("- [{}]({})\n", s, s));
109                }
110                md.push_str("\n");
111            }
112        }
113
114        if let Some(kp) = &self.knowledge_panel {
115            md.push_str(&format!("## ๐Ÿ›๏ธ Knowledge Panel: {}\n\n", kp.title));
116            if !kp.subtitle.is_empty() {
117                md.push_str(&format!("*{}*\n\n", kp.subtitle));
118            }
119            if !kp.description.is_empty() {
120                md.push_str(&format!("{}\n\n", kp.description));
121            }
122            if !kp.attributes.is_empty() {
123                for (k, v) in &kp.attributes {
124                    md.push_str(&format!("- **{}**: {}\n", k, v));
125                }
126                md.push_str("\n");
127            }
128        }
129
130        if !self.organic_results.is_empty() {
131            md.push_str("## ๐Ÿ” Organic Search Results\n\n");
132            for (i, res) in self.organic_results.iter().enumerate() {
133                md.push_str(&format!("### {}. [{}]({})\n\n", i + 1, res.title, res.link));
134                if !res.snippet.is_empty() {
135                    md.push_str(&format!("{}\n\n", res.snippet));
136                }
137            }
138        }
139
140        if !self.related_questions.is_empty() {
141            md.push_str("## โ“ People Also Ask\n\n");
142            for q in &self.related_questions {
143                md.push_str(&format!("- {}\n", q));
144            }
145            md.push_str("\n");
146        }
147
148        md.trim().to_string()
149    }
150}
151
152pub struct DomTree {
153    pub raw_content: String,
154    document: Html,
155}
156
157impl DomTree {
158    pub fn parse(content: &str) -> Result<Self> {
159        let document = Html::parse_document(content);
160        Ok(Self {
161            raw_content: content.to_string(),
162            document,
163        })
164    }
165
166    pub fn extract(&self, selector_str: Option<&str>) -> Option<String> {
167        if let Some(sel) = selector_str {
168            if let Ok(selector) = Selector::parse(sel) {
169                let matches: Vec<String> = self
170                    .document
171                    .select(&selector)
172                    .map(|el| el.html())
173                    .collect();
174                if !matches.is_empty() {
175                    return Some(matches.join("\n"));
176                }
177            }
178        }
179        Some(self.raw_content.clone())
180    }
181
182    pub fn extract_markdown(&self, selector_str: Option<&str>, base_url: Option<&str>) -> String {
183        if selector_str.is_none() {
184            let search_results = self.parse_google_search_results();
185            if !search_results.organic_results.is_empty()
186                || search_results.ai_overview.is_some()
187                || search_results.knowledge_panel.is_some()
188            {
189                return search_results.to_markdown();
190            }
191        }
192
193        if let Some(sel) = selector_str {
194            if let Ok(selector) = Selector::parse(sel) {
195                let parts: Vec<String> = self
196                    .document
197                    .select(&selector)
198                    .map(|el| HtmlToMarkdown::convert_element(&el, base_url))
199                    .collect();
200                if !parts.is_empty() {
201                    return parts.join("\n\n---\n\n");
202                }
203            }
204        }
205        HtmlToMarkdown::convert(&self.raw_content, base_url)
206    }
207
208    pub fn extract_interactive_elements(&self, base_url: Option<&str>) -> Vec<InteractiveElement> {
209        InteractiveParser::parse(&self.raw_content, base_url)
210    }
211
212    pub async fn screenshot_async(
213        &self,
214        url: &str,
215        title: &str,
216        base_url: Option<&str>,
217    ) -> ScreenshotResult {
218        let interactive = self.extract_interactive_elements(base_url);
219        let search_results = self.parse_google_search_results();
220        PageRenderer::render_async(
221            url,
222            title,
223            &self.raw_content,
224            &interactive,
225            Some(&search_results),
226        )
227        .await
228    }
229
230    pub fn screenshot(&self, url: &str, title: &str, base_url: Option<&str>) -> ScreenshotResult {
231        let interactive = self.extract_interactive_elements(base_url);
232        let search_results = self.parse_google_search_results();
233        PageRenderer::render(
234            url,
235            title,
236            &self.raw_content,
237            &interactive,
238            Some(&search_results),
239        )
240    }
241
242    pub fn extract_links(&self, base_url: Option<&str>) -> Vec<LinkInfo> {
243        let mut links = Vec::new();
244        if let Ok(a_sel) = Selector::parse("a[href]") {
245            for a in self.document.select(&a_sel) {
246                let raw_href = a.value().attr("href").unwrap_or("");
247                let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
248                if !raw_href.is_empty() && !raw_href.starts_with("javascript:") {
249                    let full_url = if let Some(base) = base_url {
250                        if raw_href.starts_with("http://") || raw_href.starts_with("https://") {
251                            raw_href.to_string()
252                        } else if raw_href.starts_with("//") {
253                            format!("https:{}", raw_href)
254                        } else if raw_href.starts_with('/') {
255                            if let Some(idx) = base.find("://") {
256                                let after = &base[idx + 3..];
257                                let host = after.split('/').next().unwrap_or(after);
258                                let scheme = &base[..idx + 3];
259                                format!("{}{}{}", scheme, host, raw_href)
260                            } else {
261                                raw_href.to_string()
262                            }
263                        } else {
264                            format!("{}/{}", base.trim_end_matches('/'), raw_href)
265                        }
266                    } else {
267                        raw_href.to_string()
268                    };
269
270                    if !links.iter().any(|l: &LinkInfo| l.href == full_url) {
271                        links.push(LinkInfo {
272                            text: if text.is_empty() {
273                                full_url.clone()
274                            } else {
275                                text
276                            },
277                            href: full_url,
278                        });
279                    }
280                }
281            }
282        }
283        links
284    }
285
286    pub fn extract_forms(&self) -> Vec<FormInfo> {
287        let mut forms = Vec::new();
288        if let Ok(form_sel) = Selector::parse("form") {
289            let input_sel = Selector::parse("input, textarea, select").ok();
290            for form in self.document.select(&form_sel) {
291                let action = form.value().attr("action").unwrap_or("").to_string();
292                let method = form.value().attr("method").unwrap_or("GET").to_uppercase();
293
294                let mut inputs = Vec::new();
295                if let Some(ref in_sel) = input_sel {
296                    for inp in form.select(in_sel) {
297                        let name = inp.value().attr("name").unwrap_or("").to_string();
298                        let input_type = inp.value().attr("type").unwrap_or("text").to_string();
299                        let value = inp.value().attr("value").unwrap_or("").to_string();
300                        let placeholder = inp.value().attr("placeholder").unwrap_or("").to_string();
301
302                        inputs.push(FormInputInfo {
303                            name,
304                            input_type,
305                            value,
306                            placeholder,
307                        });
308                    }
309                }
310
311                forms.push(FormInfo {
312                    action,
313                    method,
314                    inputs,
315                });
316            }
317        }
318        forms
319    }
320
321    pub fn parse_google_search_results(&self) -> SearchResults {
322        let title_selector = Selector::parse("title").ok();
323        let page_title = title_selector
324            .and_then(|sel| self.document.select(&sel).next())
325            .map(|el| el.text().collect::<String>().trim().to_string())
326            .unwrap_or_else(|| "Search Results".to_string());
327
328        let is_captcha_detected = page_title.contains("Sorry")
329            || page_title.contains("unusual traffic")
330            || self.raw_content.contains("sorry/index?continue=")
331            || self.raw_content.contains("id=\"captcha-form\"")
332            || (self.raw_content.contains("challenges.cloudflare.com")
333                && self.raw_content.contains("cf-turnstile-wrapper"))
334            || self.raw_content.contains("hcaptcha-box");
335
336        let mut organic_results = Vec::new();
337        let mut news_results = Vec::new();
338        let mut video_results = Vec::new();
339        let mut image_results = Vec::new();
340        let mut related_questions = Vec::new();
341        let mut ai_overview: Option<AiOverview> = None;
342        let mut knowledge_panel: Option<KnowledgePanel> = None;
343
344        // 1. AI Overview Extraction (Google Search Generative Experience / Quick Answers)
345        if let Ok(ai_sel) = Selector::parse("div[data-attrid='wa:/description'], div.YzSd6e, div.NFZabb, div.V3FYCf, div[aria-label*='AI Overview'], div.kno-rdesc") {
346            if let Some(ai_el) = self.document.select(&ai_sel).next() {
347                let summary = ai_el.text().collect::<Vec<_>>().join(" ").trim().to_string();
348                if !summary.is_empty() && summary.len() > 20 {
349                    let mut source_refs = Vec::new();
350                    if let Ok(ref_sel) = Selector::parse("a[href]") {
351                        for a in ai_el.select(&ref_sel) {
352                            if let Some(href) = a.value().attr("href") {
353                                if href.starts_with("http") && !source_refs.contains(&href.to_string()) {
354                                    source_refs.push(href.to_string());
355                                }
356                            }
357                        }
358                    }
359                    ai_overview = Some(AiOverview {
360                        summary,
361                        source_references: source_refs,
362                    });
363                }
364            }
365        }
366
367        // 2. Knowledge Panel Extraction (Entities, Celebrities, Places, Organizations)
368        if let Ok(kp_title_sel) =
369            Selector::parse("div[data-attrid='title'], h2[data-attrid='title'], div.BNeawe.vvjwJb")
370        {
371            if let Some(kp_title_el) = self.document.select(&kp_title_sel).next() {
372                let title = kp_title_el
373                    .text()
374                    .collect::<Vec<_>>()
375                    .join(" ")
376                    .trim()
377                    .to_string();
378                if !title.is_empty() {
379                    let subtitle = Selector::parse("div[data-attrid='subtitle'], div.BNeawe.UPmit")
380                        .ok()
381                        .and_then(|s| self.document.select(&s).next())
382                        .map(|el| el.text().collect::<Vec<_>>().join(" ").trim().to_string())
383                        .unwrap_or_default();
384
385                    let description =
386                        Selector::parse("div[data-attrid='description'], div.kno-rdesc")
387                            .ok()
388                            .and_then(|s| self.document.select(&s).next())
389                            .map(|el| el.text().collect::<Vec<_>>().join(" ").trim().to_string())
390                            .unwrap_or_default();
391
392                    let mut attributes = Vec::new();
393                    if let Ok(attr_sel) = Selector::parse("div.rVusze, div[data-attrid]:not([data-attrid='title']):not([data-attrid='subtitle'])") {
394                        for attr_el in self.document.select(&attr_sel) {
395                            let text = attr_el.text().collect::<Vec<_>>().join(" ").trim().to_string();
396                            if let Some(colon_idx) = text.find(':') {
397                                let k = text[..colon_idx].trim().to_string();
398                                let v = text[colon_idx + 1..].trim().to_string();
399                                if !k.is_empty() && !v.is_empty() {
400                                    attributes.push((k, v));
401                                }
402                            }
403                        }
404                    }
405
406                    if !description.is_empty() || !attributes.is_empty() {
407                        knowledge_panel = Some(KnowledgePanel {
408                            title,
409                            subtitle,
410                            description,
411                            attributes,
412                        });
413                    }
414                }
415            }
416        }
417
418        // 3. Google Images Mode Extraction (`udm=2` / `tbm=isch` / image galleries)
419        if let Ok(img_box_sel) =
420            Selector::parse("div[data-ri], div.isv-r, div.F0uyec, div.eA0Zlc, table.e2BEnf")
421        {
422            let img_sel = Selector::parse("img").ok();
423            let a_sel = Selector::parse("a[href]").ok();
424
425            for img_box in self.document.select(&img_box_sel) {
426                let img_el = img_sel.as_ref().and_then(|s| img_box.select(s).next());
427                let image_url = img_el
428                    .and_then(|img| {
429                        img.value()
430                            .attr("src")
431                            .or_else(|| img.value().attr("data-src"))
432                            .or_else(|| img.value().attr("data-iurl"))
433                    })
434                    .unwrap_or_default()
435                    .to_string();
436
437                let a_el = a_sel.as_ref().and_then(|s| img_box.select(s).next());
438                let source_url = a_el
439                    .and_then(|a| a.value().attr("href"))
440                    .unwrap_or_default()
441                    .to_string();
442
443                let title = img_box
444                    .text()
445                    .collect::<Vec<_>>()
446                    .join(" ")
447                    .trim()
448                    .to_string();
449                let domain = if let Some(idx) = source_url.find("://") {
450                    let after = &source_url[idx + 3..];
451                    after.split('/').next().unwrap_or_default().to_string()
452                } else {
453                    String::new()
454                };
455
456                if !image_url.is_empty()
457                    && !image_results
458                        .iter()
459                        .any(|i: &ImageResult| i.image_url == image_url)
460                {
461                    image_results.push(ImageResult {
462                        title,
463                        image_url,
464                        source_url,
465                        domain,
466                    });
467                }
468            }
469        }
470
471        // 4. YouTube Search InitialData Extraction
472        if self.raw_content.contains("ytInitialData") {
473            if let Some(start_idx) = self
474                .raw_content
475                .find("var ytInitialData =")
476                .or_else(|| self.raw_content.find("ytInitialData ="))
477            {
478                let rest = &self.raw_content[start_idx..];
479                if let Some(brace_idx) = rest.find('{') {
480                    let json_str = &rest[brace_idx..];
481                    if let Some(semi_idx) = json_str
482                        .find(";</script>")
483                        .or_else(|| json_str.find(";\n"))
484                        .or_else(|| json_str.find(";var "))
485                    {
486                        let candidate = &json_str[..semi_idx];
487                        if let Ok(parsed_json) =
488                            serde_json::from_str::<serde_json::Value>(candidate)
489                        {
490                            Self::extract_youtube_videos(&parsed_json, &mut video_results);
491                        }
492                    }
493                }
494            }
495
496            for v in &video_results {
497                let snippet = format!(
498                    "Channel: {} | Duration: {} | Views: {} | Uploaded: {} - {}",
499                    v.channel, v.duration, v.views, v.published_time, v.description
500                );
501                organic_results.push(OrganicResult {
502                    title: v.title.clone(),
503                    link: v.url.clone(),
504                    snippet,
505                });
506            }
507        }
508
509        // 5. RSS / XML News Feed Extraction
510        if self.raw_content.contains("<item>") || self.raw_content.contains("<entry>") {
511            if let Ok(item_sel) = Selector::parse("item, entry") {
512                let title_sel = Selector::parse("title").ok();
513                let pubdate_sel = Selector::parse("pubDate, published, updated").ok();
514                let desc_sel = Selector::parse("description, summary").ok();
515
516                for item in self.document.select(&item_sel) {
517                    let full_title = title_sel
518                        .as_ref()
519                        .and_then(|s| item.select(s).next())
520                        .map(|t| t.text().collect::<String>().trim().to_string())
521                        .unwrap_or_default();
522
523                    if full_title.is_empty() {
524                        continue;
525                    }
526
527                    let (headline, source) = if let Some(idx) = full_title.rfind(" - ") {
528                        (
529                            full_title[..idx].trim().to_string(),
530                            full_title[idx + 3..].trim().to_string(),
531                        )
532                    } else {
533                        (full_title.clone(), "Google News".to_string())
534                    };
535
536                    let time_ago = pubdate_sel
537                        .as_ref()
538                        .and_then(|s| item.select(s).next())
539                        .map(|t| t.text().collect::<String>().trim().to_string())
540                        .unwrap_or_default();
541
542                    let desc_html = desc_sel
543                        .as_ref()
544                        .and_then(|s| item.select(s).next())
545                        .map(|t| t.inner_html())
546                        .unwrap_or_default();
547
548                    let mut link = String::new();
549                    if let Some(href_idx) = desc_html.find("href=\"") {
550                        let after = &desc_html[href_idx + 6..];
551                        if let Some(end_idx) = after.find('"') {
552                            link = after[..end_idx].to_string();
553                        }
554                    }
555
556                    let snippet = {
557                        let desc_doc = Html::parse_fragment(&desc_html);
558                        desc_doc
559                            .root_element()
560                            .text()
561                            .collect::<Vec<_>>()
562                            .join(" ")
563                            .trim()
564                            .to_string()
565                    };
566
567                    news_results.push(NewsResult {
568                        headline: headline.clone(),
569                        source,
570                        time_ago,
571                        link: link.clone(),
572                    });
573
574                    organic_results.push(OrganicResult {
575                        title: headline,
576                        link,
577                        snippet,
578                    });
579                }
580            }
581        }
582
583        // 6. DuckDuckGo / Universal HTML SERP Extraction
584        if let Ok(ddg_sel) = Selector::parse("div.result, div.web-result, div.results_links") {
585            let title_sel = Selector::parse("a.result__url, h2.result__title a, a.result__a").ok();
586            let snip_sel = Selector::parse("a.result__snippet, div.result__snippet").ok();
587
588            for res_el in self.document.select(&ddg_sel) {
589                let title_el = title_sel.as_ref().and_then(|s| res_el.select(s).next());
590                let title = title_el
591                    .map(|t| t.text().collect::<Vec<_>>().join(" ").trim().to_string())
592                    .unwrap_or_default();
593                let link = title_el
594                    .and_then(|t| t.value().attr("href"))
595                    .unwrap_or_default()
596                    .to_string();
597                let snippet = snip_sel
598                    .as_ref()
599                    .and_then(|s| res_el.select(s).next())
600                    .map(|sn| sn.text().collect::<Vec<_>>().join(" ").trim().to_string())
601                    .unwrap_or_default();
602
603                if !title.is_empty()
604                    && !link.is_empty()
605                    && !organic_results.iter().any(|r| r.link == link)
606                {
607                    organic_results.push(OrganicResult {
608                        title,
609                        link,
610                        snippet,
611                    });
612                }
613            }
614        }
615
616        // 7. Google / Bing Standard HTML SERP Extraction (with Videos and Organic snippets)
617        if let Ok(h3_selector) = Selector::parse("h3, h2, div[role='heading']") {
618            let a_selector = Selector::parse("a[href]").ok();
619
620            for h3_el in self.document.select(&h3_selector) {
621                let title = h3_el
622                    .text()
623                    .collect::<Vec<_>>()
624                    .join(" ")
625                    .trim()
626                    .to_string();
627                if title.is_empty()
628                    || title.eq_ignore_ascii_case("search results")
629                    || title.eq_ignore_ascii_case("people also ask")
630                    || title.len() < 3
631                {
632                    continue;
633                }
634
635                let mut found_link = String::new();
636                let mut current = h3_el.parent();
637
638                for _ in 0..6 {
639                    if let Some(parent_node) = current {
640                        if let Some(el_ref) = scraper::ElementRef::wrap(parent_node) {
641                            if el_ref.value().name() == "a" {
642                                if let Some(href) = el_ref.value().attr("href") {
643                                    found_link = href.to_string();
644                                    break;
645                                }
646                            }
647                            if let Some(ref a_sel) = a_selector {
648                                if let Some(a_el) = el_ref.select(a_sel).next() {
649                                    if let Some(href) = a_el.value().attr("href") {
650                                        found_link = href.to_string();
651                                        break;
652                                    }
653                                }
654                            }
655                        }
656                        current = parent_node.parent();
657                    } else {
658                        break;
659                    }
660                }
661
662                let mut clean_url = found_link;
663                if clean_url.starts_with("/url?q=") {
664                    if let Some(end_idx) = clean_url.find("&sa=") {
665                        clean_url = clean_url[7..end_idx].to_string();
666                    } else {
667                        clean_url = clean_url[7..].to_string();
668                    }
669                }
670
671                if !clean_url.starts_with("http")
672                    || clean_url.contains("google.com/")
673                    || clean_url.contains("bing.com/")
674                    || clean_url.contains("duckduckgo.com/")
675                {
676                    continue;
677                }
678
679                let mut snippet = String::new();
680                if let Some(parent_node) = h3_el.parent().and_then(|p| p.parent()) {
681                    if let Some(container_el) = scraper::ElementRef::wrap(parent_node) {
682                        let full_text = container_el
683                            .text()
684                            .collect::<Vec<_>>()
685                            .join(" ")
686                            .trim()
687                            .to_string();
688                        if full_text.starts_with(&title) {
689                            snippet = full_text[title.len()..].trim().to_string();
690                        } else {
691                            snippet = full_text;
692                        }
693                    }
694                }
695
696                // Sanitize snippet if it contains inline CSS/JS leaks
697                if snippet.contains("@keyframes")
698                    || snippet.contains("var(--")
699                    || snippet.contains('{')
700                {
701                    let mut words = Vec::new();
702                    for w in snippet.split_whitespace() {
703                        if !w.contains('{')
704                            && !w.contains('}')
705                            && !w.contains("var(--")
706                            && !w.contains("@keyframes")
707                            && !w.contains("display:")
708                            && !w.contains("animation:")
709                        {
710                            words.push(w);
711                        }
712                    }
713                    snippet = words.join(" ");
714                }
715
716                // Check if this is a video result on Google Videos
717                if clean_url.contains("youtube.com/watch") || clean_url.contains("vimeo.com") {
718                    let video_id = if let Some(idx) = clean_url.find("v=") {
719                        clean_url[idx + 2..]
720                            .split('&')
721                            .next()
722                            .unwrap_or_default()
723                            .to_string()
724                    } else {
725                        String::new()
726                    };
727
728                    if !video_results.iter().any(|v| v.url == clean_url) {
729                        video_results.push(VideoResult {
730                            title: title.clone(),
731                            video_id,
732                            url: clean_url.clone(),
733                            channel: "Web Video".to_string(),
734                            duration: String::new(),
735                            views: String::new(),
736                            published_time: String::new(),
737                            description: snippet.clone(),
738                        });
739                    }
740                }
741
742                let lower_snippet = snippet.to_lowercase();
743                if lower_snippet.contains("hours ago")
744                    || lower_snippet.contains("days ago")
745                    || lower_snippet.contains("mins ago")
746                {
747                    let parts: Vec<&str> = snippet.split('ยท').collect();
748                    let source = if parts.len() > 1 {
749                        parts[0].trim().to_string()
750                    } else {
751                        "Web News".to_string()
752                    };
753                    let time_ago = parts.get(1).unwrap_or(&"").trim().to_string();
754
755                    if !news_results
756                        .iter()
757                        .any(|n| n.headline == title || n.link == clean_url)
758                    {
759                        news_results.push(NewsResult {
760                            headline: title.clone(),
761                            source,
762                            time_ago,
763                            link: clean_url.clone(),
764                        });
765                    }
766                }
767
768                if !organic_results.iter().any(|r| r.link == clean_url) {
769                    organic_results.push(OrganicResult {
770                        title,
771                        link: clean_url,
772                        snippet,
773                    });
774                }
775            }
776        }
777
778        // 8. People Also Ask / Related Questions
779        if let Ok(q_selector) =
780            Selector::parse("div.cb7Db, div[data-q], div.related-question-pair, div.CSkcDe")
781        {
782            for q_el in self.document.select(&q_selector) {
783                let q_text = q_el.text().collect::<Vec<_>>().join(" ").trim().to_string();
784                if !q_text.is_empty()
785                    && q_text.len() > 5
786                    && q_text.len() < 150
787                    && !q_text.contains('{')
788                    && !q_text.contains("@keyframes")
789                    && !related_questions.contains(&q_text)
790                {
791                    related_questions.push(q_text);
792                }
793            }
794        }
795
796        let total_results_found =
797            organic_results.len() + news_results.len() + video_results.len() + image_results.len();
798
799        SearchResults {
800            page_title,
801            ai_overview,
802            knowledge_panel,
803            image_results,
804            video_results,
805            news_results,
806            organic_results,
807            related_questions,
808            is_captcha_detected,
809            total_results_found,
810        }
811    }
812
813    fn extract_youtube_videos(val: &serde_json::Value, list: &mut Vec<VideoResult>) {
814        match val {
815            serde_json::Value::Object(map) => {
816                if let Some(vr) = map.get("videoRenderer") {
817                    let video_id = vr
818                        .get("videoId")
819                        .and_then(|v| v.as_str())
820                        .unwrap_or_default()
821                        .to_string();
822
823                    if !video_id.is_empty() {
824                        let title = vr
825                            .get("title")
826                            .and_then(|t| t.get("runs"))
827                            .and_then(|r| r.as_array())
828                            .and_then(|arr| arr.first())
829                            .and_then(|item| item.get("text"))
830                            .and_then(|t| t.as_str())
831                            .unwrap_or_default()
832                            .to_string();
833
834                        let channel = vr
835                            .get("ownerText")
836                            .and_then(|o| o.get("runs"))
837                            .and_then(|r| r.as_array())
838                            .and_then(|arr| arr.first())
839                            .and_then(|item| item.get("text"))
840                            .and_then(|t| t.as_str())
841                            .unwrap_or_default()
842                            .to_string();
843
844                        let duration = vr
845                            .get("lengthText")
846                            .and_then(|l| l.get("simpleText"))
847                            .and_then(|t| t.as_str())
848                            .unwrap_or_default()
849                            .to_string();
850
851                        let views = vr
852                            .get("viewCountText")
853                            .and_then(|v| v.get("simpleText"))
854                            .and_then(|t| t.as_str())
855                            .unwrap_or_default()
856                            .to_string();
857
858                        let published_time = vr
859                            .get("publishedTimeText")
860                            .and_then(|p| p.get("simpleText"))
861                            .and_then(|t| t.as_str())
862                            .unwrap_or_default()
863                            .to_string();
864
865                        let description = vr
866                            .get("descriptionSnippet")
867                            .and_then(|d| d.get("runs"))
868                            .and_then(|r| r.as_array())
869                            .map(|arr| {
870                                arr.iter()
871                                    .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
872                                    .collect::<Vec<_>>()
873                                    .join("")
874                            })
875                            .unwrap_or_default();
876
877                        let url = format!("https://www.youtube.com/watch?v={}", video_id);
878
879                        if !title.is_empty() && !list.iter().any(|v| v.video_id == video_id) {
880                            list.push(VideoResult {
881                                title,
882                                video_id,
883                                url,
884                                channel,
885                                duration,
886                                views,
887                                published_time,
888                                description,
889                            });
890                        }
891                    }
892                }
893                for v in map.values() {
894                    Self::extract_youtube_videos(v, list);
895                }
896            }
897            serde_json::Value::Array(arr) => {
898                for v in arr {
899                    Self::extract_youtube_videos(v, list);
900                }
901            }
902            _ => {}
903        }
904    }
905}