Skip to main content

web_search/providers/
web_capture.rs

1//! web-capture component-library provider.
2//!
3//! Issue #3 (R3/R4) asks `web-search` to use `link-assistant/web-capture` as a
4//! component library so this project can focus on search aggregation rather than
5//! re-implementing per-provider scraping. Mirrors the JavaScript
6//! `src/providers/web-capture.js`.
7//!
8//! The Rust provider delegates to the published `web-capture` crate and keeps
9//! the same graceful empty-result behavior the JavaScript provider uses for
10//! component errors.
11
12use async_trait::async_trait;
13
14use super::base::{SearchOptions, SearchProvider, SearchResult};
15use crate::error::SearchError;
16
17/// Providers exposed by web-capture's search contract.
18pub const SUPPORTED_PROVIDERS: [&str; 5] = web_capture::SEARCH_PROVIDERS;
19
20/// Provider that delegates to the web-capture component library.
21pub struct WebCaptureProvider {
22    name: String,
23    engine: String,
24    enabled: bool,
25    weight: f64,
26}
27
28impl WebCaptureProvider {
29    /// Create a provider bound to a web-capture engine (default `wikipedia`).
30    pub fn new(engine: impl Into<String>) -> Self {
31        let engine = engine.into();
32        Self {
33            name: format!("wc:{engine}"),
34            engine,
35            enabled: true,
36            weight: 1.0,
37        }
38    }
39
40    /// The web-capture engine this provider delegates to.
41    pub fn engine(&self) -> &str {
42        &self.engine
43    }
44
45    /// Adapt normalized web-capture items into the web-search result contract.
46    pub fn adapt_items(&self, items: Vec<web_capture::SearchResultItem>) -> Vec<SearchResult> {
47        items
48            .into_iter()
49            .enumerate()
50            .filter_map(|(index, item)| {
51                if item.url.trim().is_empty() {
52                    return None;
53                }
54
55                Some(SearchResult {
56                    title: if item.title.trim().is_empty() {
57                        "Untitled".to_string()
58                    } else {
59                        item.title
60                    },
61                    url: item.url,
62                    snippet: item.snippet,
63                    source: self.name.clone(),
64                    rank: if item.rank == 0 { index + 1 } else { item.rank },
65                    score: None,
66                    sources: None,
67                })
68            })
69            .collect()
70    }
71}
72
73impl Default for WebCaptureProvider {
74    fn default() -> Self {
75        Self::new("wikipedia")
76    }
77}
78
79#[async_trait]
80impl SearchProvider for WebCaptureProvider {
81    fn name(&self) -> &str {
82        &self.name
83    }
84
85    fn is_available(&self) -> bool {
86        self.enabled
87    }
88
89    fn weight(&self) -> f64 {
90        self.weight
91    }
92
93    fn set_weight(&mut self, weight: f64) {
94        self.weight = weight.clamp(0.0, 1.0);
95    }
96
97    fn set_enabled(&mut self, enabled: bool) {
98        self.enabled = enabled;
99    }
100
101    async fn search(
102        &self,
103        query: &str,
104        options: &SearchOptions,
105    ) -> Result<Vec<SearchResult>, SearchError> {
106        if query.trim().is_empty() {
107            return Ok(Vec::new());
108        }
109
110        let limit = options.limit.unwrap_or(web_capture::DEFAULT_LIMIT);
111
112        match web_capture::search(query, &self.engine, limit, "fetch", "").await {
113            Ok(result) => Ok(self.adapt_items(result.results)),
114            Err(message) => {
115                tracing::warn!(
116                    provider = self.name(),
117                    error = %message,
118                    "WebCaptureProvider returned no results"
119                );
120                Ok(Vec::new())
121            }
122        }
123    }
124}