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 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 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 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 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 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 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 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 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 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}