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