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