capture_rust/
lib.rs

1use reqwest::Client;
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::time::Duration;
5use thiserror::Error;
6
7#[derive(Error, Debug)]
8pub enum CaptureError {
9    #[error("HTTP request failed: {0}")]
10    HttpError(#[from] reqwest::Error),
11    #[error("URL parsing failed: {0}")]
12    UrlError(#[from] url::ParseError),
13    #[error("Key and Secret are required")]
14    MissingCredentials,
15    #[error("URL is required")]
16    MissingUrl,
17    #[error("URL should be a string")]
18    InvalidUrl,
19}
20
21pub type Result<T> = std::result::Result<T, CaptureError>;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum RequestType {
25    Image,
26    Pdf,
27    Content,
28    Metadata,
29}
30
31impl RequestType {
32    fn as_str(&self) -> &'static str {
33        match self {
34            RequestType::Image => "image",
35            RequestType::Pdf => "pdf",
36            RequestType::Content => "content",
37            RequestType::Metadata => "metadata",
38        }
39    }
40}
41
42pub type RequestOptions = HashMap<String, serde_json::Value>;
43
44#[derive(Debug, Clone, Default)]
45pub struct ScreenshotOptions {
46    // Viewport Options
47    pub vw: Option<u32>,
48    pub vh: Option<u32>,
49    pub scale_factor: Option<f64>,
50
51    // Capture Customization
52    pub full: Option<bool>,
53    pub delay: Option<u32>,
54    pub wait_for: Option<String>,
55    pub wait_for_id: Option<String>,
56
57    // Visual Modifications
58    pub dark_mode: Option<bool>,
59    pub transparent: Option<bool>,
60    pub selector: Option<String>,
61    pub selector_id: Option<String>,
62
63    // Performance/Detection
64    pub block_cookie_banners: Option<bool>,
65    pub block_ads: Option<bool>,
66    pub bypass_bot_detection: Option<bool>,
67
68    // Image Options
69    pub image_type: Option<String>,
70    pub best_format: Option<bool>,
71    pub resize_width: Option<u32>,
72    pub resize_height: Option<u32>,
73
74    // Additional Options
75    pub http_auth: Option<String>,
76    pub user_agent: Option<String>,
77    pub fresh: Option<bool>,
78
79    // Generic override for any future options
80    pub additional_options: Option<RequestOptions>,
81}
82
83#[derive(Debug, Clone, Default)]
84pub struct PdfOptions {
85    // Authentication
86    pub http_auth: Option<String>,
87    pub user_agent: Option<String>,
88
89    // Page Dimensions
90    pub width: Option<String>,
91    pub height: Option<String>,
92    pub format: Option<String>,
93
94    // Margins
95    pub margin_top: Option<String>,
96    pub margin_right: Option<String>,
97    pub margin_bottom: Option<String>,
98    pub margin_left: Option<String>,
99
100    // Rendering Options
101    pub scale: Option<f64>,
102    pub landscape: Option<bool>,
103    pub delay: Option<u32>,
104
105    // Storage/Output
106    pub file_name: Option<String>,
107    pub s3_acl: Option<String>,
108    pub s3_redirect: Option<bool>,
109    pub timestamp: Option<bool>,
110
111    // Generic override for any future options
112    pub additional_options: Option<RequestOptions>,
113}
114
115#[derive(Debug, Clone, Default)]
116pub struct ContentOptions {
117    pub http_auth: Option<String>,
118    pub user_agent: Option<String>,
119    pub delay: Option<u32>,
120    pub wait_for: Option<String>,
121    pub wait_for_id: Option<String>,
122
123    // Generic override for any future options
124    pub additional_options: Option<RequestOptions>,
125}
126
127#[derive(Debug, Clone, Default)]
128pub struct MetadataOptions {
129    // Generic override for any future options
130    pub additional_options: Option<RequestOptions>,
131}
132
133impl ScreenshotOptions {
134    pub fn to_request_options(&self) -> RequestOptions {
135        let mut options = RequestOptions::new();
136
137        if let Some(vw) = self.vw {
138            options.insert("vw".to_string(), serde_json::Value::Number(vw.into()));
139        }
140        if let Some(vh) = self.vh {
141            options.insert("vh".to_string(), serde_json::Value::Number(vh.into()));
142        }
143        if let Some(scale_factor) = self.scale_factor {
144            if let Some(num) = serde_json::Number::from_f64(scale_factor) {
145                options.insert("scaleFactor".to_string(), serde_json::Value::Number(num));
146            }
147        }
148        if let Some(full) = self.full {
149            options.insert("full".to_string(), serde_json::Value::Bool(full));
150        }
151        if let Some(delay) = self.delay {
152            options.insert("delay".to_string(), serde_json::Value::Number(delay.into()));
153        }
154        if let Some(wait_for) = &self.wait_for {
155            options.insert(
156                "waitFor".to_string(),
157                serde_json::Value::String(wait_for.clone()),
158            );
159        }
160        if let Some(wait_for_id) = &self.wait_for_id {
161            options.insert(
162                "waitForId".to_string(),
163                serde_json::Value::String(wait_for_id.clone()),
164            );
165        }
166        if let Some(dark_mode) = self.dark_mode {
167            options.insert("darkMode".to_string(), serde_json::Value::Bool(dark_mode));
168        }
169        if let Some(transparent) = self.transparent {
170            options.insert(
171                "transparent".to_string(),
172                serde_json::Value::Bool(transparent),
173            );
174        }
175        if let Some(selector) = &self.selector {
176            options.insert(
177                "selector".to_string(),
178                serde_json::Value::String(selector.clone()),
179            );
180        }
181        if let Some(selector_id) = &self.selector_id {
182            options.insert(
183                "selectorId".to_string(),
184                serde_json::Value::String(selector_id.clone()),
185            );
186        }
187        if let Some(block_cookie_banners) = self.block_cookie_banners {
188            options.insert(
189                "blockCookieBanners".to_string(),
190                serde_json::Value::Bool(block_cookie_banners),
191            );
192        }
193        if let Some(block_ads) = self.block_ads {
194            options.insert("blockAds".to_string(), serde_json::Value::Bool(block_ads));
195        }
196        if let Some(bypass_bot_detection) = self.bypass_bot_detection {
197            options.insert(
198                "bypassBotDetection".to_string(),
199                serde_json::Value::Bool(bypass_bot_detection),
200            );
201        }
202        if let Some(image_type) = &self.image_type {
203            options.insert(
204                "type".to_string(),
205                serde_json::Value::String(image_type.clone()),
206            );
207        }
208        if let Some(best_format) = self.best_format {
209            options.insert(
210                "bestFormat".to_string(),
211                serde_json::Value::Bool(best_format),
212            );
213        }
214        if let Some(resize_width) = self.resize_width {
215            options.insert(
216                "resizeWidth".to_string(),
217                serde_json::Value::Number(resize_width.into()),
218            );
219        }
220        if let Some(resize_height) = self.resize_height {
221            options.insert(
222                "resizeHeight".to_string(),
223                serde_json::Value::Number(resize_height.into()),
224            );
225        }
226        if let Some(http_auth) = &self.http_auth {
227            options.insert(
228                "httpAuth".to_string(),
229                serde_json::Value::String(http_auth.clone()),
230            );
231        }
232        if let Some(user_agent) = &self.user_agent {
233            options.insert(
234                "userAgent".to_string(),
235                serde_json::Value::String(user_agent.clone()),
236            );
237        }
238        if let Some(fresh) = self.fresh {
239            options.insert("fresh".to_string(), serde_json::Value::Bool(fresh));
240        }
241
242        // Merge additional options, allowing overrides
243        if let Some(additional) = &self.additional_options {
244            for (key, value) in additional {
245                options.insert(key.clone(), value.clone());
246            }
247        }
248
249        options
250    }
251}
252
253impl PdfOptions {
254    pub fn to_request_options(&self) -> RequestOptions {
255        let mut options = RequestOptions::new();
256
257        if let Some(http_auth) = &self.http_auth {
258            options.insert(
259                "httpAuth".to_string(),
260                serde_json::Value::String(http_auth.clone()),
261            );
262        }
263        if let Some(user_agent) = &self.user_agent {
264            options.insert(
265                "userAgent".to_string(),
266                serde_json::Value::String(user_agent.clone()),
267            );
268        }
269        if let Some(width) = &self.width {
270            options.insert(
271                "width".to_string(),
272                serde_json::Value::String(width.clone()),
273            );
274        }
275        if let Some(height) = &self.height {
276            options.insert(
277                "height".to_string(),
278                serde_json::Value::String(height.clone()),
279            );
280        }
281        if let Some(format) = &self.format {
282            options.insert(
283                "format".to_string(),
284                serde_json::Value::String(format.clone()),
285            );
286        }
287        if let Some(margin_top) = &self.margin_top {
288            options.insert(
289                "marginTop".to_string(),
290                serde_json::Value::String(margin_top.clone()),
291            );
292        }
293        if let Some(margin_right) = &self.margin_right {
294            options.insert(
295                "marginRight".to_string(),
296                serde_json::Value::String(margin_right.clone()),
297            );
298        }
299        if let Some(margin_bottom) = &self.margin_bottom {
300            options.insert(
301                "marginBottom".to_string(),
302                serde_json::Value::String(margin_bottom.clone()),
303            );
304        }
305        if let Some(margin_left) = &self.margin_left {
306            options.insert(
307                "marginLeft".to_string(),
308                serde_json::Value::String(margin_left.clone()),
309            );
310        }
311        if let Some(scale) = self.scale {
312            if let Some(num) = serde_json::Number::from_f64(scale) {
313                options.insert("scale".to_string(), serde_json::Value::Number(num));
314            }
315        }
316        if let Some(landscape) = self.landscape {
317            options.insert("landscape".to_string(), serde_json::Value::Bool(landscape));
318        }
319        if let Some(delay) = self.delay {
320            options.insert("delay".to_string(), serde_json::Value::Number(delay.into()));
321        }
322        if let Some(file_name) = &self.file_name {
323            options.insert(
324                "fileName".to_string(),
325                serde_json::Value::String(file_name.clone()),
326            );
327        }
328        if let Some(s3_acl) = &self.s3_acl {
329            options.insert(
330                "s3Acl".to_string(),
331                serde_json::Value::String(s3_acl.clone()),
332            );
333        }
334        if let Some(s3_redirect) = self.s3_redirect {
335            options.insert(
336                "s3Redirect".to_string(),
337                serde_json::Value::Bool(s3_redirect),
338            );
339        }
340        if let Some(timestamp) = self.timestamp {
341            options.insert("timestamp".to_string(), serde_json::Value::Bool(timestamp));
342        }
343
344        // Merge additional options, allowing overrides
345        if let Some(additional) = &self.additional_options {
346            for (key, value) in additional {
347                options.insert(key.clone(), value.clone());
348            }
349        }
350
351        options
352    }
353}
354
355impl ContentOptions {
356    pub fn to_request_options(&self) -> RequestOptions {
357        let mut options = RequestOptions::new();
358
359        if let Some(http_auth) = &self.http_auth {
360            options.insert(
361                "httpAuth".to_string(),
362                serde_json::Value::String(http_auth.clone()),
363            );
364        }
365        if let Some(user_agent) = &self.user_agent {
366            options.insert(
367                "userAgent".to_string(),
368                serde_json::Value::String(user_agent.clone()),
369            );
370        }
371        if let Some(delay) = self.delay {
372            options.insert("delay".to_string(), serde_json::Value::Number(delay.into()));
373        }
374        if let Some(wait_for) = &self.wait_for {
375            options.insert(
376                "waitFor".to_string(),
377                serde_json::Value::String(wait_for.clone()),
378            );
379        }
380        if let Some(wait_for_id) = &self.wait_for_id {
381            options.insert(
382                "waitForId".to_string(),
383                serde_json::Value::String(wait_for_id.clone()),
384            );
385        }
386
387        // Merge additional options, allowing overrides
388        if let Some(additional) = &self.additional_options {
389            for (key, value) in additional {
390                options.insert(key.clone(), value.clone());
391            }
392        }
393
394        options
395    }
396}
397
398impl MetadataOptions {
399    pub fn to_request_options(&self) -> RequestOptions {
400        let mut options = RequestOptions::new();
401
402        // Merge additional options, allowing overrides
403        if let Some(additional) = &self.additional_options {
404            for (key, value) in additional {
405                options.insert(key.clone(), value.clone());
406            }
407        }
408
409        options
410    }
411}
412
413#[derive(Debug, Clone, Default)]
414pub struct CaptureOptions {
415    pub use_edge: bool,
416    pub timeout: Option<Duration>,
417    pub client: Option<Client>,
418}
419
420impl CaptureOptions {
421    pub fn new() -> Self {
422        Self::default()
423    }
424
425    pub fn with_edge(mut self) -> Self {
426        self.use_edge = true;
427        self
428    }
429
430    pub fn with_timeout(mut self, timeout: Duration) -> Self {
431        self.timeout = Some(timeout);
432        self
433    }
434
435    pub fn with_client(mut self, client: Client) -> Self {
436        self.client = Some(client);
437        self
438    }
439}
440
441#[derive(Debug, Deserialize)]
442pub struct ContentResponse {
443    pub success: bool,
444    pub html: String,
445    #[serde(rename = "textContent")]
446    pub text_content: String,
447}
448
449#[derive(Debug, Deserialize)]
450pub struct MetadataResponse {
451    pub success: bool,
452    pub metadata: HashMap<String, serde_json::Value>,
453}
454
455pub struct Capture {
456    key: String,
457    secret: String,
458    options: CaptureOptions,
459    client: Client,
460}
461
462impl Capture {
463    const API_URL: &'static str = "https://cdn.capture.page";
464    const EDGE_URL: &'static str = "https://edge.capture.page";
465
466    pub fn new(key: String, secret: String) -> Self {
467        let options = CaptureOptions::default();
468        let client = options.client.clone().unwrap_or_else(|| {
469            let mut builder = Client::builder();
470            if let Some(timeout) = options.timeout {
471                builder = builder.timeout(timeout);
472            }
473            builder.build().unwrap_or_else(|_| Client::new())
474        });
475
476        Self {
477            key,
478            secret,
479            options,
480            client,
481        }
482    }
483
484    pub fn with_options(key: String, secret: String, options: CaptureOptions) -> Self {
485        let client = options.client.clone().unwrap_or_else(|| {
486            let mut builder = Client::builder();
487            if let Some(timeout) = options.timeout {
488                builder = builder.timeout(timeout);
489            }
490            builder.build().unwrap_or_else(|_| Client::new())
491        });
492
493        Self {
494            key,
495            secret,
496            options,
497            client,
498        }
499    }
500
501    pub fn with_edge(mut self) -> Self {
502        self.options.use_edge = true;
503        self
504    }
505
506    pub fn with_timeout(mut self, timeout: Duration) -> Self {
507        self.options.timeout = Some(timeout);
508        // Rebuild client with new timeout
509        let builder = Client::builder().timeout(timeout);
510        self.client = builder.build().unwrap_or_else(|_| Client::new());
511        self
512    }
513
514    pub fn with_client(mut self, client: Client) -> Self {
515        self.client = client;
516        self.options.client = Some(self.client.clone());
517        self
518    }
519
520    fn generate_token(&self, secret: &str, url: &str) -> String {
521        format!("{:x}", md5::compute(format!("{secret}{url}")))
522    }
523
524    fn to_query_string(&self, options: &RequestOptions) -> String {
525        let mut params = Vec::new();
526
527        for (key, value) in options {
528            if key == "format" {
529                continue;
530            }
531
532            let value_str = match value {
533                serde_json::Value::String(s) => s.clone(),
534                serde_json::Value::Number(n) => n.to_string(),
535                serde_json::Value::Bool(b) => b.to_string(),
536                _ => continue,
537            };
538
539            if !value_str.is_empty() {
540                params.push(format!(
541                    "{}={}",
542                    urlencoding::encode(key),
543                    urlencoding::encode(&value_str)
544                ));
545            }
546        }
547
548        params.join("&")
549    }
550
551    fn build_url(
552        &self,
553        request_type: RequestType,
554        url: &str,
555        request_options: Option<&RequestOptions>,
556    ) -> Result<String> {
557        if self.key.is_empty() || self.secret.is_empty() {
558            return Err(CaptureError::MissingCredentials);
559        }
560
561        if url.is_empty() {
562            return Err(CaptureError::MissingUrl);
563        }
564
565        let mut options = request_options.cloned().unwrap_or_default();
566        options.insert(
567            "url".to_string(),
568            serde_json::Value::String(url.to_string()),
569        );
570
571        let query = self.to_query_string(&options);
572        let token = self.generate_token(&self.secret, &query);
573
574        let base_url = if self.options.use_edge {
575            Self::EDGE_URL
576        } else {
577            Self::API_URL
578        };
579
580        Ok(format!(
581            "{}/{}/{}/{}?{}",
582            base_url,
583            self.key,
584            token,
585            request_type.as_str(),
586            query
587        ))
588    }
589
590    pub fn build_image_url(&self, url: &str, options: Option<&RequestOptions>) -> Result<String> {
591        self.build_url(RequestType::Image, url, options)
592    }
593
594    pub fn build_pdf_url(&self, url: &str, options: Option<&RequestOptions>) -> Result<String> {
595        self.build_url(RequestType::Pdf, url, options)
596    }
597
598    pub fn build_content_url(&self, url: &str, options: Option<&RequestOptions>) -> Result<String> {
599        self.build_url(RequestType::Content, url, options)
600    }
601
602    pub fn build_metadata_url(
603        &self,
604        url: &str,
605        options: Option<&RequestOptions>,
606    ) -> Result<String> {
607        self.build_url(RequestType::Metadata, url, options)
608    }
609
610    // Structured options methods
611    pub fn build_screenshot_url(
612        &self,
613        url: &str,
614        options: Option<&ScreenshotOptions>,
615    ) -> Result<String> {
616        let request_options = options.map(|o| o.to_request_options());
617        self.build_url(RequestType::Image, url, request_options.as_ref())
618    }
619
620    pub fn build_pdf_url_structured(
621        &self,
622        url: &str,
623        options: Option<&PdfOptions>,
624    ) -> Result<String> {
625        let request_options = options.map(|o| o.to_request_options());
626        self.build_url(RequestType::Pdf, url, request_options.as_ref())
627    }
628
629    pub fn build_content_url_structured(
630        &self,
631        url: &str,
632        options: Option<&ContentOptions>,
633    ) -> Result<String> {
634        let request_options = options.map(|o| o.to_request_options());
635        self.build_url(RequestType::Content, url, request_options.as_ref())
636    }
637
638    pub fn build_metadata_url_structured(
639        &self,
640        url: &str,
641        options: Option<&MetadataOptions>,
642    ) -> Result<String> {
643        let request_options = options.map(|o| o.to_request_options());
644        self.build_url(RequestType::Metadata, url, request_options.as_ref())
645    }
646
647    pub async fn fetch_image(
648        &self,
649        url: &str,
650        options: Option<&RequestOptions>,
651    ) -> Result<Vec<u8>> {
652        let capture_url = self.build_image_url(url, options)?;
653        let response = self.client.get(&capture_url).send().await?;
654        let bytes = response.bytes().await?;
655        Ok(bytes.to_vec())
656    }
657
658    pub async fn fetch_pdf(&self, url: &str, options: Option<&RequestOptions>) -> Result<Vec<u8>> {
659        let capture_url = self.build_pdf_url(url, options)?;
660        let response = self.client.get(&capture_url).send().await?;
661        let bytes = response.bytes().await?;
662        Ok(bytes.to_vec())
663    }
664
665    pub async fn fetch_content(
666        &self,
667        url: &str,
668        options: Option<&RequestOptions>,
669    ) -> Result<ContentResponse> {
670        let capture_url = self.build_content_url(url, options)?;
671        let response = self.client.get(&capture_url).send().await?;
672        let content = response.json::<ContentResponse>().await?;
673        Ok(content)
674    }
675
676    pub async fn fetch_metadata(
677        &self,
678        url: &str,
679        options: Option<&RequestOptions>,
680    ) -> Result<MetadataResponse> {
681        let capture_url = self.build_metadata_url(url, options)?;
682        let response = self.client.get(&capture_url).send().await?;
683        let metadata = response.json::<MetadataResponse>().await?;
684        Ok(metadata)
685    }
686
687    // Structured options fetch methods
688    pub async fn fetch_screenshot(
689        &self,
690        url: &str,
691        options: Option<&ScreenshotOptions>,
692    ) -> Result<Vec<u8>> {
693        let capture_url = self.build_screenshot_url(url, options)?;
694        let response = self.client.get(&capture_url).send().await?;
695        let bytes = response.bytes().await?;
696        Ok(bytes.to_vec())
697    }
698
699    pub async fn fetch_pdf_structured(
700        &self,
701        url: &str,
702        options: Option<&PdfOptions>,
703    ) -> Result<Vec<u8>> {
704        let capture_url = self.build_pdf_url_structured(url, options)?;
705        let response = self.client.get(&capture_url).send().await?;
706        let bytes = response.bytes().await?;
707        Ok(bytes.to_vec())
708    }
709
710    pub async fn fetch_content_structured(
711        &self,
712        url: &str,
713        options: Option<&ContentOptions>,
714    ) -> Result<ContentResponse> {
715        let capture_url = self.build_content_url_structured(url, options)?;
716        let response = self.client.get(&capture_url).send().await?;
717        let content = response.json::<ContentResponse>().await?;
718        Ok(content)
719    }
720
721    pub async fn fetch_metadata_structured(
722        &self,
723        url: &str,
724        options: Option<&MetadataOptions>,
725    ) -> Result<MetadataResponse> {
726        let capture_url = self.build_metadata_url_structured(url, options)?;
727        let response = self.client.get(&capture_url).send().await?;
728        let metadata = response.json::<MetadataResponse>().await?;
729        Ok(metadata)
730    }
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    #[test]
738    fn test_capture_new() {
739        let capture = Capture::new("test_key".to_string(), "test_secret".to_string());
740        assert_eq!(capture.key, "test_key");
741        assert_eq!(capture.secret, "test_secret");
742        assert!(!capture.options.use_edge);
743    }
744
745    #[test]
746    fn test_capture_with_edge() {
747        let options = CaptureOptions::new().with_edge();
748        let capture =
749            Capture::with_options("test_key".to_string(), "test_secret".to_string(), options);
750        assert!(capture.options.use_edge);
751    }
752
753    #[test]
754    fn test_build_image_url() {
755        let capture = Capture::new("test_key".to_string(), "test_secret".to_string());
756        let url = capture
757            .build_image_url("https://example.com", None)
758            .unwrap();
759        assert!(url.contains("test_key"));
760        assert!(url.contains("image"));
761        assert!(url.contains("https://cdn.capture.page"));
762    }
763
764    #[test]
765    fn test_build_image_url_with_edge() {
766        let options = CaptureOptions::new().with_edge();
767        let capture =
768            Capture::with_options("test_key".to_string(), "test_secret".to_string(), options);
769        let url = capture
770            .build_image_url("https://example.com", None)
771            .unwrap();
772        assert!(url.contains("https://edge.capture.page"));
773    }
774
775    #[test]
776    fn test_missing_credentials() {
777        let capture = Capture::new("".to_string(), "".to_string());
778        let result = capture.build_image_url("https://example.com", None);
779        assert!(matches!(result, Err(CaptureError::MissingCredentials)));
780    }
781
782    #[test]
783    fn test_missing_url() {
784        let capture = Capture::new("test_key".to_string(), "test_secret".to_string());
785        let result = capture.build_image_url("", None);
786        assert!(matches!(result, Err(CaptureError::MissingUrl)));
787    }
788}