Skip to main content

swiftide_integrations/scraping/
loader.rs

1use derive_builder::Builder;
2use spider::configuration::{SpiderCloudConfig, SpiderCloudMode};
3use spider::website::Website;
4
5use swiftide_core::{
6    Loader,
7    indexing::{IndexingStream, TextNode},
8};
9
10#[derive(Debug, Builder, Clone)]
11#[builder(pattern = "owned")]
12/// Scrapes a given website
13///
14/// Under the hood uses the `spider` crate to scrape the website.
15/// For more configuration options see their documentation.
16pub struct ScrapingLoader {
17    spider_website: Website,
18}
19
20/// Spider Cloud config resolved from the environment once, or `None` when
21/// `SPIDER_CLOUD_API_KEY` is unset.
22static SPIDER_CLOUD: std::sync::OnceLock<Option<SpiderCloudConfig>> = std::sync::OnceLock::new();
23
24fn spider_cloud() -> Option<&'static SpiderCloudConfig> {
25    SPIDER_CLOUD.get_or_init(spider_cloud_from_env).as_ref()
26}
27
28/// Spider Cloud fetches from its own infrastructure, so it cannot reach hosts
29/// that are only routable from here. Those are left on the direct path.
30fn is_locally_routable(url: &str) -> bool {
31    use spider::url::Host;
32
33    let Ok(parsed) = spider::url::Url::parse(url) else {
34        return false;
35    };
36
37    match parsed.host() {
38        Some(Host::Domain(host)) => {
39            host.eq_ignore_ascii_case("localhost")
40                || host.rsplit_once('.').is_some_and(|(_, suffix)| {
41                    suffix.eq_ignore_ascii_case("localhost") || suffix.eq_ignore_ascii_case("local")
42                })
43        }
44        Some(Host::Ipv4(ip)) => {
45            ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified()
46        }
47        Some(Host::Ipv6(ip)) => ip.is_loopback() || ip.is_unspecified(),
48        None => false,
49    }
50}
51
52fn spider_cloud_from_env() -> Option<SpiderCloudConfig> {
53    let api_key = std::env::var("SPIDER_CLOUD_API_KEY").ok()?;
54    let api_key = api_key.trim();
55
56    if api_key.is_empty() {
57        return None;
58    }
59
60    // Anything unrecognized falls through to `Smart`, which proxies and
61    // escalates to the unblocker only when it sees bot protection.
62    let mode = match std::env::var("SPIDER_CLOUD_MODE")
63        .unwrap_or_default()
64        .trim()
65        .to_ascii_lowercase()
66        .as_str()
67    {
68        "proxy" => SpiderCloudMode::Proxy,
69        "api" => SpiderCloudMode::Api,
70        "unblocker" => SpiderCloudMode::Unblocker,
71        "fallback" => SpiderCloudMode::Fallback,
72        _ => SpiderCloudMode::Smart,
73    };
74
75    let mut config = SpiderCloudConfig::new(api_key).with_mode(mode);
76
77    if let Ok(api_url) = std::env::var("SPIDER_CLOUD_API_URL")
78        && !api_url.trim().is_empty()
79    {
80        config = config.with_api_url(api_url.trim());
81    }
82
83    Some(config)
84}
85
86impl ScrapingLoader {
87    pub fn builder() -> ScrapingLoaderBuilder {
88        ScrapingLoaderBuilder::default()
89    }
90
91    // Constructs a scrapingloader from a `spider::Website` configuration
92    #[allow(dead_code)]
93    pub fn from_spider(spider_website: Website) -> Self {
94        Self { spider_website }
95    }
96
97    /// Constructs a scrapingloader from a given url
98    pub fn from_url(url: impl AsRef<str>) -> Self {
99        Self::from_spider(Website::new(url.as_ref()))
100    }
101}
102
103impl Loader for ScrapingLoader {
104    type Output = String;
105
106    fn into_stream(mut self) -> IndexingStream<String> {
107        let (tx, rx) = tokio::sync::mpsc::channel(1000);
108        if let Some(config) = spider_cloud() {
109            if is_locally_routable(self.spider_website.get_url().inner()) {
110                tracing::debug!("[Spider] Local host, skipping Spider Cloud");
111            } else {
112                tracing::info!(mode = ?config.mode, "[Spider] Using Spider Cloud");
113                self.spider_website.with_spider_cloud_config(config.clone());
114            }
115        }
116        let mut spider_rx = self.spider_website.subscribe(0);
117        tracing::info!("Subscribed to spider");
118
119        let _recv_thread = tokio::spawn(async move {
120            while let Ok(res) = spider_rx.recv().await {
121                let html = res.get_html();
122                let original_size = html.len();
123
124                let node = TextNode::builder()
125                    .chunk(html)
126                    .original_size(original_size)
127                    .path(res.get_url())
128                    .build();
129
130                tracing::debug!(?node, "[Spider] Received node from spider");
131
132                if let Err(error) = tx.send(node).await {
133                    tracing::error!(?error, "[Spider] Failed to send node to stream");
134                    break;
135                }
136            }
137        });
138
139        let mut spider_website = self.spider_website;
140
141        let _scrape_thread = tokio::spawn(async move {
142            tracing::info!("[Spider] Starting scrape loop");
143            // TODO: It would be much nicer if this used `scrape` instead, as it is supposedly
144            // more concurrent
145            //
146            // Boxed because spider's crawl future is large enough to trip
147            // `clippy::large_futures` when held across an await in a task.
148            Box::pin(spider_website.crawl()).await;
149            tracing::info!("[Spider] Scrape loop finished");
150        });
151
152        // NOTE: Handles should stay alive because of rx, but feels a bit fishy
153        rx.into()
154    }
155
156    fn into_stream_boxed(self: Box<Self>) -> IndexingStream<String> {
157        self.into_stream()
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use anyhow::Result;
165    use futures_util::StreamExt;
166    use swiftide_core::indexing::Loader;
167    use wiremock::matchers::{method, path};
168    use wiremock::{Mock, MockServer, Request, ResponseTemplate};
169
170    #[test]
171    fn test_is_locally_routable() {
172        for url in [
173            "http://localhost:8080",
174            "http://127.0.0.1:3000",
175            "http://[::1]:3000",
176            "http://192.168.1.10",
177            "http://10.0.0.1",
178            "http://nas.local",
179        ] {
180            assert!(is_locally_routable(url), "{url} should be local");
181        }
182
183        for url in [
184            "https://example.com",
185            "https://books.toscrape.com",
186            "http://1.1.1.1",
187        ] {
188            assert!(!is_locally_routable(url), "{url} should not be local");
189        }
190    }
191
192    #[test_log::test(tokio::test(flavor = "multi_thread"))]
193    async fn test_scraping_loader_with_wiremock() {
194        // Set up the wiremock server to simulate the remote web server
195        let mock_server = MockServer::start().await;
196
197        // Mocked response for the page we will scrape
198        let body = "<html><body><h1>Test Page</h1></body></html>";
199        Mock::given(method("GET"))
200            .and(path("/"))
201            .respond_with(ResponseTemplate::new(200).set_body_string(body))
202            .mount(&mock_server)
203            .await;
204
205        // Create an instance of ScrapingLoader using the mock server's URL
206        let loader = ScrapingLoader::from_url(mock_server.uri());
207
208        // Execute the into_stream method
209        let stream = loader.into_stream();
210
211        // Process the stream to check if we get the expected result
212        let nodes = stream.collect::<Vec<Result<TextNode>>>().await;
213
214        assert_eq!(nodes.len(), 1);
215
216        let first_node = nodes.first().unwrap().as_ref().unwrap();
217
218        assert_eq!(first_node.chunk, body);
219    }
220
221    #[test_log::test(tokio::test(flavor = "multi_thread"))]
222    async fn test_scraping_loader_multiple_pages() {
223        // Set up the wiremock server to simulate the remote web server
224        let mock_server = MockServer::start().await;
225
226        // Mocked response for the page we will scrape
227        let body = "<html><body><h1>Test Page</h1><a href=\"/other\">link</a></body></html>";
228        Mock::given(method("GET"))
229            .and(path("/"))
230            .respond_with(ResponseTemplate::new(200).set_body_string(body))
231            .mount(&mock_server)
232            .await;
233
234        let body2 = "<html><body><h1>Test Page 2</h1></body></html>";
235        Mock::given(method("GET"))
236            .and(path("/other"))
237            .respond_with(move |_req: &Request| {
238                std::thread::sleep(std::time::Duration::from_secs(1));
239                ResponseTemplate::new(200).set_body_string(body2)
240            })
241            .mount(&mock_server)
242            .await;
243
244        // Create an instance of ScrapingLoader using the mock server's URL
245        let loader = ScrapingLoader::from_url(mock_server.uri());
246
247        // Execute the into_stream method
248        let stream = loader.into_stream();
249
250        // Process the stream to check if we get the expected result
251        let mut nodes = stream.collect::<Vec<Result<TextNode>>>().await;
252
253        assert_eq!(nodes.len(), 2);
254
255        let first_node = nodes.pop().unwrap().unwrap();
256
257        assert_eq!(first_node.chunk, body2);
258
259        let second_node = nodes.pop().unwrap().unwrap();
260
261        assert_eq!(second_node.chunk, body);
262    }
263}