Skip to main content

sparrow/tools/
web_search.rs

1//! Web search tool — DuckDuckGo-based search with structured results.
2
3use std::process::Command;
4
5/// Search the web and return results.
6pub fn web_search(query: &str, max_results: usize) -> anyhow::Result<Vec<SearchResult>> {
7    // Try DuckDuckGo via curl (no API key needed)
8    let output = Command::new("curl")
9        .args([
10            "-s",
11            "-A", "sparrow/0.5",
12            "--max-time", "10",
13            &format!("https://api.duckduckgo.com/?q={}&format=json&no_html=1", 
14                urlencoding(query)),
15        ])
16        .output()?;
17
18    if output.status.success() {
19        let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
20        return parse_duckduckgo(&json, max_results);
21    }
22
23    // Fallback: try SearXNG (self-hosted, no API key)
24    if let Ok(output) = Command::new("curl")
25        .args(["-s", "--max-time", "10",
26            &format!("https://searx.be/search?q={}&format=json", urlencoding(query))])
27        .output()
28    {
29        if output.status.success() {
30            let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
31            return parse_searx(&json, max_results);
32        }
33    }
34
35    // Last resort: suggest manual search
36    Ok(vec![SearchResult {
37        title: format!("Search: {}", query),
38        url: format!("https://duckduckgo.com/?q={}", urlencoding(query)),
39        snippet: "No results. Open the URL in your browser to search manually.".into(),
40    }])
41}
42
43#[derive(Debug, Clone, serde::Serialize)]
44pub struct SearchResult {
45    pub title: String,
46    pub url: String,
47    pub snippet: String,
48}
49
50fn parse_duckduckgo(json: &serde_json::Value, max: usize) -> anyhow::Result<Vec<SearchResult>> {
51    let mut results = Vec::new();
52    if let Some(items) = json.get("RelatedTopics").and_then(|t| t.as_array()) {
53        for item in items.iter().take(max) {
54            if let (Some(text), Some(url)) = (item.get("Text").and_then(|t| t.as_str()),
55                                               item.get("FirstURL").and_then(|u| u.as_str())) {
56                results.push(SearchResult {
57                    title: text.chars().take(80).collect(),
58                    url: url.to_string(),
59                    snippet: text.to_string(),
60                });
61            }
62        }
63    }
64    Ok(results)
65}
66
67fn parse_searx(json: &serde_json::Value, max: usize) -> anyhow::Result<Vec<SearchResult>> {
68    let mut results = Vec::new();
69    if let Some(items) = json.get("results").and_then(|r| r.as_array()) {
70        for item in items.iter().take(max) {
71            results.push(SearchResult {
72                title: item.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(),
73                url: item.get("url").and_then(|u| u.as_str()).unwrap_or("").to_string(),
74                snippet: item.get("content").or(item.get("snippet"))
75                    .and_then(|s| s.as_str()).unwrap_or("").to_string(),
76            });
77        }
78    }
79    Ok(results)
80}
81
82fn urlencoding(s: &str) -> String {
83    s.replace(' ', "+")
84        .replace('&', "%26")
85        .replace('=', "%3D")
86        .replace('#', "%23")
87}