swiftide_integrations/scraping/
loader.rs1use 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")]
12pub struct ScrapingLoader {
17 spider_website: Website,
18}
19
20static 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
28fn 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 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 #[allow(dead_code)]
93 pub fn from_spider(spider_website: Website) -> Self {
94 Self { spider_website }
95 }
96
97 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 Box::pin(spider_website.crawl()).await;
149 tracing::info!("[Spider] Scrape loop finished");
150 });
151
152 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 let mock_server = MockServer::start().await;
196
197 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 let loader = ScrapingLoader::from_url(mock_server.uri());
207
208 let stream = loader.into_stream();
210
211 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 let mock_server = MockServer::start().await;
225
226 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 let loader = ScrapingLoader::from_url(mock_server.uri());
246
247 let stream = loader.into_stream();
249
250 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}