lc/search/
duckduckgo.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5use super::{SearchResult, SearchResults};
6
7#[derive(Debug, Serialize, Deserialize)]
8pub struct DuckDuckGoResponse {
9    #[serde(rename = "Abstract")]
10    pub abstract_text: String,
11    #[serde(rename = "AbstractText")]
12    pub abstract_text_alt: String,
13    #[serde(rename = "AbstractSource")]
14    pub abstract_source: String,
15    #[serde(rename = "AbstractURL")]
16    pub abstract_url: String,
17    #[serde(rename = "Image")]
18    pub image: String,
19    #[serde(rename = "Heading")]
20    pub heading: String,
21    #[serde(rename = "Answer")]
22    pub answer: String,
23    #[serde(rename = "AnswerType")]
24    pub answer_type: String,
25    #[serde(rename = "Definition")]
26    pub definition: String,
27    #[serde(rename = "DefinitionSource")]
28    pub definition_source: String,
29    #[serde(rename = "DefinitionURL")]
30    pub definition_url: String,
31    #[serde(rename = "RelatedTopics")]
32    pub related_topics: Vec<DuckDuckGoRelatedTopic>,
33    #[serde(rename = "Results")]
34    pub results: Vec<DuckDuckGoResult>,
35    #[serde(rename = "Type")]
36    pub result_type: String,
37    #[serde(rename = "Redirect")]
38    pub redirect: String,
39}
40
41#[derive(Debug, Serialize, Deserialize)]
42pub struct DuckDuckGoRelatedTopic {
43    #[serde(rename = "Result")]
44    pub result: Option<String>,
45    #[serde(rename = "Icon")]
46    pub icon: Option<DuckDuckGoIcon>,
47    #[serde(rename = "FirstURL")]
48    pub first_url: Option<String>,
49    #[serde(rename = "Text")]
50    pub text: Option<String>,
51}
52
53#[derive(Debug, Serialize, Deserialize)]
54pub struct DuckDuckGoResult {
55    #[serde(rename = "Result")]
56    pub result: String,
57    #[serde(rename = "FirstURL")]
58    pub first_url: String,
59    #[serde(rename = "Icon")]
60    pub icon: Option<DuckDuckGoIcon>,
61    #[serde(rename = "Text")]
62    pub text: String,
63}
64
65#[derive(Debug, Serialize, Deserialize)]
66pub struct DuckDuckGoIcon {
67    #[serde(rename = "URL")]
68    pub url: String,
69    #[serde(rename = "Height")]
70    pub height: Option<serde_json::Value>,
71    #[serde(rename = "Width")]
72    pub width: Option<serde_json::Value>,
73}
74
75pub struct DuckDuckGoProvider {
76    pub url: String,
77    pub headers: HashMap<String, String>,
78}
79
80impl DuckDuckGoProvider {
81    pub fn new(url: String, headers: HashMap<String, String>) -> Self {
82        Self { url, headers }
83    }
84
85    pub async fn search(&self, query: &str, count: Option<usize>) -> Result<SearchResults> {
86        let client = reqwest::Client::new();
87
88        // Build query parameters for DuckDuckGo Instant Answer API
89        let params = vec![
90            ("q", query.to_string()),
91            ("format", "json".to_string()),
92            ("no_redirect", "1".to_string()),
93            ("no_html", "1".to_string()),
94            ("skip_disambig", "1".to_string()),
95        ];
96
97        crate::debug_log!(
98            "DuckDuckGo: Making GET request to {} with params: {:?}",
99            self.url,
100            params
101        );
102
103        let mut request = client.get(&self.url).query(&params);
104
105        // Add custom headers if provided
106        for (key, value) in &self.headers {
107            request = request.header(key, value);
108        }
109
110        let response = request.send().await?;
111
112        let status = response.status();
113        crate::debug_log!("DuckDuckGo: Received response with status: {}", status);
114
115        if !status.is_success() {
116            let error_text = response.text().await.unwrap_or_default();
117            crate::debug_log!("DuckDuckGo: Error response: {}", error_text);
118            anyhow::bail!(
119                "DuckDuckGo request failed with status {}: {}",
120                status,
121                error_text
122            );
123        }
124
125        let response_text = response.text().await?;
126        crate::debug_log!(
127            "DuckDuckGo: Response body length: {} bytes",
128            response_text.len()
129        );
130
131        let ddg_response: DuckDuckGoResponse = serde_json::from_str(&response_text)
132            .map_err(|e| anyhow::anyhow!("Failed to parse DuckDuckGo response: {}", e))?;
133
134        crate::debug_log!("DuckDuckGo: Successfully parsed response");
135
136        let mut results = Vec::new();
137        let max_results = count.unwrap_or(10);
138
139        // Add abstract/answer as first result if available
140        if !ddg_response.abstract_text.is_empty() && !ddg_response.abstract_url.is_empty() {
141            let title = if !ddg_response.heading.is_empty() {
142                ddg_response.heading.clone()
143            } else {
144                format!("About {}", query)
145            };
146
147            results.push(SearchResult {
148                title,
149                url: ddg_response.abstract_url.clone(),
150                snippet: ddg_response.abstract_text.clone(),
151                published_date: None,
152                author: Some(ddg_response.abstract_source.clone()),
153                score: None,
154            });
155        }
156
157        // Add definition if available
158        if !ddg_response.definition.is_empty() && !ddg_response.definition_url.is_empty() {
159            results.push(SearchResult {
160                title: format!("Definition: {}", query),
161                url: ddg_response.definition_url.clone(),
162                snippet: ddg_response.definition.clone(),
163                published_date: None,
164                author: Some(ddg_response.definition_source.clone()),
165                score: None,
166            });
167        }
168
169        // Add answer if available
170        if !ddg_response.answer.is_empty() {
171            results.push(SearchResult {
172                title: format!("Answer: {}", query),
173                url: format!("https://duckduckgo.com/?q={}", urlencoding::encode(query)),
174                snippet: ddg_response.answer.clone(),
175                published_date: None,
176                author: Some("DuckDuckGo".to_string()),
177                score: None,
178            });
179        }
180
181        // Add results from Results array
182        for result in ddg_response
183            .results
184            .iter()
185            .take(max_results.saturating_sub(results.len()))
186        {
187            if !result.text.is_empty() && !result.first_url.is_empty() {
188                // Extract title from the result text (usually the first part before " - ")
189                let title = if let Some(dash_pos) = result.text.find(" - ") {
190                    result.text[..dash_pos].to_string()
191                } else {
192                    result.text.clone()
193                };
194
195                let snippet = if let Some(dash_pos) = result.text.find(" - ") {
196                    result.text[dash_pos + 3..].to_string()
197                } else {
198                    String::new()
199                };
200
201                results.push(SearchResult {
202                    title,
203                    url: result.first_url.clone(),
204                    snippet,
205                    published_date: None,
206                    author: None,
207                    score: None,
208                });
209            }
210        }
211
212        // Add related topics if we need more results
213        if results.len() < max_results {
214            for topic in ddg_response
215                .related_topics
216                .iter()
217                .take(max_results.saturating_sub(results.len()))
218            {
219                if let (Some(text), Some(url)) = (&topic.text, &topic.first_url) {
220                    if !text.is_empty() && !url.is_empty() {
221                        // Extract title from the topic text
222                        let title = if let Some(dash_pos) = text.find(" - ") {
223                            text[..dash_pos].to_string()
224                        } else {
225                            text.clone()
226                        };
227
228                        let snippet = if let Some(dash_pos) = text.find(" - ") {
229                            text[dash_pos + 3..].to_string()
230                        } else {
231                            String::new()
232                        };
233
234                        results.push(SearchResult {
235                            title,
236                            url: url.clone(),
237                            snippet,
238                            published_date: None,
239                            author: None,
240                            score: None,
241                        });
242                    }
243                }
244            }
245        }
246
247        crate::debug_log!(
248            "DuckDuckGo: Successfully extracted {} results",
249            results.len()
250        );
251
252        Ok(SearchResults {
253            query: query.to_string(),
254            provider: "DuckDuckGo".to_string(),
255            results,
256            total_results: None,  // DuckDuckGo API doesn't provide total count
257            search_time_ms: None, // API doesn't provide timing info
258        })
259    }
260}
261
262/// Search function that matches the interface used by other providers
263pub async fn search(
264    provider_config: &super::SearchProviderConfig,
265    query: &str,
266    count: Option<usize>,
267) -> anyhow::Result<super::SearchResults> {
268    let provider =
269        DuckDuckGoProvider::new(provider_config.url.clone(), provider_config.headers.clone());
270
271    provider.search(query, count).await
272}