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 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 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 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 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 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 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 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 if snippet.contains("@keyframes") || snippet.contains("var(--") || snippet.contains('{') {
698 let mut words = Vec::new();
699 for w in snippet.split_whitespace() {
700 if !w.contains('{') && !w.contains('}') && !w.contains("var(--") && !w.contains("@keyframes") && !w.contains("display:") && !w.contains("animation:") {
701 words.push(w);
702 }
703 }
704 snippet = words.join(" ");
705 }
706
707 if clean_url.contains("youtube.com/watch") || clean_url.contains("vimeo.com") {
709 let video_id = if let Some(idx) = clean_url.find("v=") {
710 clean_url[idx + 2..]
711 .split('&')
712 .next()
713 .unwrap_or_default()
714 .to_string()
715 } else {
716 String::new()
717 };
718
719 if !video_results.iter().any(|v| v.url == clean_url) {
720 video_results.push(VideoResult {
721 title: title.clone(),
722 video_id,
723 url: clean_url.clone(),
724 channel: "Web Video".to_string(),
725 duration: String::new(),
726 views: String::new(),
727 published_time: String::new(),
728 description: snippet.clone(),
729 });
730 }
731 }
732
733 let lower_snippet = snippet.to_lowercase();
734 if lower_snippet.contains("hours ago")
735 || lower_snippet.contains("days ago")
736 || lower_snippet.contains("mins ago")
737 {
738 let parts: Vec<&str> = snippet.split('ยท').collect();
739 let source = if parts.len() > 1 {
740 parts[0].trim().to_string()
741 } else {
742 "Web News".to_string()
743 };
744 let time_ago = parts.get(1).unwrap_or(&"").trim().to_string();
745
746 if !news_results
747 .iter()
748 .any(|n| n.headline == title || n.link == clean_url)
749 {
750 news_results.push(NewsResult {
751 headline: title.clone(),
752 source,
753 time_ago,
754 link: clean_url.clone(),
755 });
756 }
757 }
758
759 if !organic_results.iter().any(|r| r.link == clean_url) {
760 organic_results.push(OrganicResult {
761 title,
762 link: clean_url,
763 snippet,
764 });
765 }
766 }
767 }
768
769 if let Ok(q_selector) =
771 Selector::parse("div.cb7Db, div[data-q], div.related-question-pair, div.CSkcDe")
772 {
773 for q_el in self.document.select(&q_selector) {
774 let q_text = q_el.text().collect::<Vec<_>>().join(" ").trim().to_string();
775 if !q_text.is_empty()
776 && q_text.len() > 5
777 && q_text.len() < 150
778 && !q_text.contains('{')
779 && !q_text.contains("@keyframes")
780 && !related_questions.contains(&q_text)
781 {
782 related_questions.push(q_text);
783 }
784 }
785 }
786
787 let total_results_found =
788 organic_results.len() + news_results.len() + video_results.len() + image_results.len();
789
790 SearchResults {
791 page_title,
792 ai_overview,
793 knowledge_panel,
794 image_results,
795 video_results,
796 news_results,
797 organic_results,
798 related_questions,
799 is_captcha_detected,
800 total_results_found,
801 }
802 }
803
804 fn extract_youtube_videos(val: &serde_json::Value, list: &mut Vec<VideoResult>) {
805 match val {
806 serde_json::Value::Object(map) => {
807 if let Some(vr) = map.get("videoRenderer") {
808 let video_id = vr
809 .get("videoId")
810 .and_then(|v| v.as_str())
811 .unwrap_or_default()
812 .to_string();
813
814 if !video_id.is_empty() {
815 let title = vr
816 .get("title")
817 .and_then(|t| t.get("runs"))
818 .and_then(|r| r.as_array())
819 .and_then(|arr| arr.first())
820 .and_then(|item| item.get("text"))
821 .and_then(|t| t.as_str())
822 .unwrap_or_default()
823 .to_string();
824
825 let channel = vr
826 .get("ownerText")
827 .and_then(|o| o.get("runs"))
828 .and_then(|r| r.as_array())
829 .and_then(|arr| arr.first())
830 .and_then(|item| item.get("text"))
831 .and_then(|t| t.as_str())
832 .unwrap_or_default()
833 .to_string();
834
835 let duration = vr
836 .get("lengthText")
837 .and_then(|l| l.get("simpleText"))
838 .and_then(|t| t.as_str())
839 .unwrap_or_default()
840 .to_string();
841
842 let views = vr
843 .get("viewCountText")
844 .and_then(|v| v.get("simpleText"))
845 .and_then(|t| t.as_str())
846 .unwrap_or_default()
847 .to_string();
848
849 let published_time = vr
850 .get("publishedTimeText")
851 .and_then(|p| p.get("simpleText"))
852 .and_then(|t| t.as_str())
853 .unwrap_or_default()
854 .to_string();
855
856 let description = vr
857 .get("descriptionSnippet")
858 .and_then(|d| d.get("runs"))
859 .and_then(|r| r.as_array())
860 .map(|arr| {
861 arr.iter()
862 .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
863 .collect::<Vec<_>>()
864 .join("")
865 })
866 .unwrap_or_default();
867
868 let url = format!("https://www.youtube.com/watch?v={}", video_id);
869
870 if !title.is_empty() && !list.iter().any(|v| v.video_id == video_id) {
871 list.push(VideoResult {
872 title,
873 video_id,
874 url,
875 channel,
876 duration,
877 views,
878 published_time,
879 description,
880 });
881 }
882 }
883 }
884 for v in map.values() {
885 Self::extract_youtube_videos(v, list);
886 }
887 }
888 serde_json::Value::Array(arr) => {
889 for v in arr {
890 Self::extract_youtube_videos(v, list);
891 }
892 }
893 _ => {}
894 }
895 }
896}