1use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
4use reqwest::{Method, Url};
5use serde::{Deserialize, Serialize};
6
7use crate::client::Client;
8use crate::error::{from_response, ScrapflyError};
9
10#[derive(Debug, Clone, Default, Serialize)]
13pub struct BrowserConfig {
14 #[serde(skip_serializing_if = "Option::is_none")]
16 pub proxy_pool: Option<String>,
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub os: Option<String>,
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub country: Option<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub session: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub timeout: Option<u32>,
29 #[serde(skip_serializing_if = "is_false")]
31 pub block_images: bool,
32 #[serde(skip_serializing_if = "is_false")]
34 pub block_styles: bool,
35 #[serde(skip_serializing_if = "is_false")]
37 pub block_fonts: bool,
38 #[serde(skip_serializing_if = "is_false")]
40 pub block_media: bool,
41 #[serde(skip_serializing_if = "is_false")]
43 pub screenshot: bool,
44 #[serde(skip_serializing_if = "is_false")]
46 pub cache: bool,
47 #[serde(skip_serializing_if = "is_false")]
49 pub blacklist: bool,
50 #[serde(skip_serializing_if = "is_false")]
52 pub debug: bool,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub resolution: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub browser_brand: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub byop_proxy: Option<String>,
62 #[serde(skip_serializing_if = "is_false")]
64 pub enable_mcp: bool,
65 #[serde(skip_serializing_if = "is_false")]
70 pub solve_captcha: bool,
71 #[serde(skip_serializing_if = "Option::is_none")]
78 pub vault: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
84 pub vault_key: Option<String>,
85
86 #[serde(skip_serializing_if = "is_false")]
88 pub enable_vnc: bool,
89 #[serde(skip_serializing_if = "Option::is_none")]
92 pub vnc_password: Option<String>,
93 #[serde(skip_serializing_if = "is_false")]
95 pub enable_rtc: bool,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub rtc_username: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
102 pub rtc_password: Option<String>,
103 #[serde(skip_serializing_if = "Vec::is_empty")]
107 pub hitl_allowed_networks: Vec<String>,
108}
109
110pub fn project_salt(api_key: &str) -> String {
114 use sha2::{Digest, Sha256};
115 let digest = Sha256::digest(api_key.as_bytes());
116 hex::encode(&digest[..4])
117}
118
119fn is_false(v: &bool) -> bool {
120 !*v
121}
122
123fn ws_base(host: &str) -> String {
128 if let Some(rest) = host.strip_prefix("wss://") {
129 format!("wss://{}", rest)
130 } else if let Some(rest) = host.strip_prefix("ws://") {
131 format!("ws://{}", rest)
132 } else if let Some(rest) = host.strip_prefix("https://") {
133 format!("wss://{}", rest)
134 } else if let Some(rest) = host.strip_prefix("http://") {
135 format!("ws://{}", rest)
136 } else {
137 format!("wss://{}", host)
138 }
139}
140
141fn rest_base(host: &str) -> String {
146 if let Some(rest) = host.strip_prefix("wss://") {
147 format!("https://{}", rest)
148 } else if let Some(rest) = host.strip_prefix("ws://") {
149 format!("http://{}", rest)
150 } else if host.starts_with("https://") || host.starts_with("http://") {
151 host.to_string()
152 } else {
153 format!("https://{}", host)
154 }
155}
156
157#[derive(Debug, Clone, Default, Serialize)]
159pub struct UnblockConfig {
160 pub url: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub country: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub os: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub browser_brand: Option<String>,
171 #[serde(skip_serializing_if = "Option::is_none")]
174 pub session: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub timeout: Option<u32>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub browser_timeout: Option<u32>,
181 #[serde(skip_serializing_if = "is_false")]
183 pub enable_mcp: bool,
184 #[serde(skip_serializing_if = "is_false")]
187 pub debug: bool,
188}
189
190#[derive(Debug, Clone, Deserialize, Default)]
192pub struct UnblockResult {
193 #[serde(default)]
195 pub ws_url: String,
196 #[serde(default)]
198 pub session_id: String,
199 #[serde(default)]
201 pub run_id: String,
202 #[serde(default)]
204 pub mcp_endpoint: String,
205}
206
207impl Client {
208 pub fn cloud_browser_url(&self, config: &BrowserConfig) -> String {
214 let ws_host = ws_base(self.cloud_browser_host());
215 let mut pairs: Vec<(String, String)> = vec![("api_key".into(), self.api_key().into())];
216 if let Some(v) = &config.proxy_pool {
217 pairs.push(("proxy_pool".into(), v.clone()));
218 }
219 if let Some(v) = &config.os {
220 pairs.push(("os".into(), v.clone()));
221 }
222 if let Some(v) = &config.country {
223 pairs.push(("country".into(), v.clone()));
224 }
225 if let Some(v) = &config.session {
226 pairs.push(("session".into(), v.clone()));
227 }
228 if let Some(v) = config.timeout {
229 pairs.push(("timeout".into(), v.to_string()));
230 }
231 if config.block_images {
232 pairs.push(("block_images".into(), "true".into()));
233 }
234 if config.block_styles {
235 pairs.push(("block_styles".into(), "true".into()));
236 }
237 if config.block_fonts {
238 pairs.push(("block_fonts".into(), "true".into()));
239 }
240 if config.block_media {
241 pairs.push(("block_media".into(), "true".into()));
242 }
243 if config.screenshot {
244 pairs.push(("screenshot".into(), "true".into()));
245 }
246 if config.cache {
247 pairs.push(("cache".into(), "true".into()));
248 }
249 if config.blacklist {
250 pairs.push(("blacklist".into(), "true".into()));
251 }
252 if config.debug {
253 pairs.push(("debug".into(), "true".into()));
254 }
255 if let Some(v) = &config.resolution {
256 pairs.push(("resolution".into(), v.clone()));
257 }
258 if let Some(v) = &config.browser_brand {
259 pairs.push(("browser_brand".into(), v.clone()));
260 }
261 if let Some(v) = &config.byop_proxy {
262 pairs.push(("byop_proxy".into(), v.clone()));
263 }
264 if config.enable_mcp {
265 pairs.push(("enable_mcp".into(), "true".into()));
266 }
267 if config.solve_captcha {
268 pairs.push(("solve_captcha".into(), "true".into()));
269 }
270 if let Some(v) = &config.vault {
271 pairs.push(("vault".into(), v.clone()));
272 }
273 if let Some(v) = &config.vault_key {
274 pairs.push(("vault_key".into(), v.clone()));
275 }
276 if config.enable_vnc {
277 pairs.push(("enable_vnc".into(), "true".into()));
278 }
279 if let Some(v) = &config.vnc_password {
280 pairs.push(("vnc_password".into(), v.clone()));
281 }
282 if config.enable_rtc {
283 pairs.push(("enable_rtc".into(), "true".into()));
284 }
285 if let Some(v) = &config.rtc_username {
286 pairs.push(("rtc_username".into(), v.clone()));
287 }
288 if let Some(v) = &config.rtc_password {
289 pairs.push(("rtc_password".into(), v.clone()));
290 }
291 if !config.hitl_allowed_networks.is_empty() {
292 pairs.push((
293 "hitl_allowed_networks".into(),
294 config.hitl_allowed_networks.join(","),
295 ));
296 }
297 let qs = serde_urlencoded::to_string(&pairs).unwrap_or_default();
298 format!("{}?{}", ws_host, qs)
299 }
300
301 pub fn cloud_browser_project_salt(&self) -> String {
304 project_salt(self.api_key())
305 }
306
307 pub async fn cloud_browser_unblock(
309 &self,
310 config: &UnblockConfig,
311 ) -> Result<UnblockResult, ScrapflyError> {
312 let url = format!(
313 "{}/unblock?key={}",
314 rest_base(self.cloud_browser_host()),
315 self.api_key()
316 );
317 let url = Url::parse(&url)
318 .map_err(|e| ScrapflyError::Config(format!("invalid unblock url: {}", e)))?;
319 let body = serde_json::to_vec(config)?;
320 let mut headers = HeaderMap::new();
321 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
322 let resp = self
323 .send_with_retry(Method::POST, url, Some(headers), Some(body))
324 .await?;
325 let status = resp.status().as_u16();
326 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
327 if status != 200 {
328 return Err(from_response(status, &body, 0, false));
329 }
330 Ok(serde_json::from_slice(&body)?)
331 }
332
333 pub async fn cloud_browser_extension_list(&self) -> Result<serde_json::Value, ScrapflyError> {
335 let url = format!(
336 "{}/extension?key={}",
337 rest_base(self.cloud_browser_host()),
338 self.api_key()
339 );
340 let url = Url::parse(&url)
341 .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
342 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
343 let status = resp.status().as_u16();
344 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
345 if status != 200 {
346 return Err(from_response(status, &body, 0, false));
347 }
348 Ok(serde_json::from_slice(&body)?)
349 }
350
351 pub async fn cloud_browser_extension_get(
353 &self,
354 extension_id: &str,
355 ) -> Result<serde_json::Value, ScrapflyError> {
356 let url = format!(
357 "{}/extension/{}?key={}",
358 rest_base(self.cloud_browser_host()),
359 extension_id,
360 self.api_key()
361 );
362 let url = Url::parse(&url)
363 .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
364 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
365 let status = resp.status().as_u16();
366 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
367 if status != 200 {
368 return Err(from_response(status, &body, 0, false));
369 }
370 Ok(serde_json::from_slice(&body)?)
371 }
372
373 pub async fn cloud_browser_extension_upload(
375 &self,
376 file_path: &std::path::Path,
377 ) -> Result<serde_json::Value, ScrapflyError> {
378 let url = format!(
379 "{}/extension?key={}",
380 rest_base(self.cloud_browser_host()),
381 self.api_key()
382 );
383 let url = Url::parse(&url)
384 .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
385 let file_bytes = std::fs::read(file_path)
386 .map_err(|e| ScrapflyError::Config(format!("failed to read extension file: {}", e)))?;
387 let file_name = file_path
388 .file_name()
389 .and_then(|n| n.to_str())
390 .unwrap_or("extension.zip")
391 .to_string();
392 let boundary = format!(
394 "----ScrapflyBoundary{}",
395 std::time::SystemTime::now()
396 .duration_since(std::time::UNIX_EPOCH)
397 .unwrap_or_default()
398 .as_millis()
399 );
400 let mut body = Vec::new();
401 body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
402 body.extend_from_slice(
403 format!(
404 "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n\
405 Content-Type: application/octet-stream\r\n\r\n",
406 file_name
407 )
408 .as_bytes(),
409 );
410 body.extend_from_slice(&file_bytes);
411 body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
412 let mut headers = HeaderMap::new();
413 headers.insert(
414 CONTENT_TYPE,
415 HeaderValue::from_str(&format!("multipart/form-data; boundary={}", boundary))
416 .map_err(|e| ScrapflyError::Config(format!("invalid content-type: {}", e)))?,
417 );
418 let resp = self
419 .send_with_retry(Method::POST, url, Some(headers), Some(body))
420 .await?;
421 let status = resp.status().as_u16();
422 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
423 if status != 200 && status != 201 {
424 return Err(from_response(status, &body, 0, false));
425 }
426 Ok(serde_json::from_slice(&body)?)
427 }
428
429 pub async fn cloud_browser_extension_delete(
431 &self,
432 extension_id: &str,
433 ) -> Result<serde_json::Value, ScrapflyError> {
434 let url = format!(
435 "{}/extension/{}?key={}",
436 rest_base(self.cloud_browser_host()),
437 extension_id,
438 self.api_key()
439 );
440 let url = Url::parse(&url)
441 .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
442 let resp = self
443 .send_with_retry(Method::DELETE, url, None, None)
444 .await?;
445 let status = resp.status().as_u16();
446 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
447 if status != 200 {
448 return Err(from_response(status, &body, 0, false));
449 }
450 Ok(serde_json::from_slice(&body)?)
451 }
452
453 pub async fn cloud_browser_vault_create(
470 &self,
471 name: &str,
472 description: Option<&str>,
473 ) -> Result<serde_json::Value, ScrapflyError> {
474 let url = format!(
475 "{}/vault?key={}",
476 rest_base(self.cloud_browser_host()),
477 self.api_key()
478 );
479 let url = Url::parse(&url)
480 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
481 let body = serde_json::json!({
482 "name": name,
483 "description": description.unwrap_or(""),
484 });
485 let body_bytes = serde_json::to_vec(&body)?;
486 let mut headers = HeaderMap::new();
487 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
488 let resp = self
489 .send_with_retry(Method::POST, url, Some(headers), Some(body_bytes))
490 .await?;
491 let status = resp.status().as_u16();
492 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
493 if status != 200 && status != 201 {
494 return Err(from_response(status, &body, 0, false));
495 }
496 Ok(serde_json::from_slice(&body)?)
497 }
498
499 pub async fn cloud_browser_vault_list(&self) -> Result<serde_json::Value, ScrapflyError> {
501 let url = format!(
502 "{}/vault?key={}",
503 rest_base(self.cloud_browser_host()),
504 self.api_key()
505 );
506 let url = Url::parse(&url)
507 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
508 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
509 let status = resp.status().as_u16();
510 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
511 if status != 200 {
512 return Err(from_response(status, &body, 0, false));
513 }
514 Ok(serde_json::from_slice(&body)?)
515 }
516
517 pub async fn cloud_browser_vault_get(
519 &self,
520 vault_id: &str,
521 ) -> Result<serde_json::Value, ScrapflyError> {
522 let url = format!(
523 "{}/vault/{}?key={}",
524 rest_base(self.cloud_browser_host()),
525 vault_id,
526 self.api_key()
527 );
528 let url = Url::parse(&url)
529 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
530 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
531 let status = resp.status().as_u16();
532 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
533 if status != 200 {
534 return Err(from_response(status, &body, 0, false));
535 }
536 Ok(serde_json::from_slice(&body)?)
537 }
538
539 pub async fn cloud_browser_vault_update(
542 &self,
543 vault_id: &str,
544 name: Option<&str>,
545 description: Option<&str>,
546 ) -> Result<serde_json::Value, ScrapflyError> {
547 let url = format!(
548 "{}/vault/{}?key={}",
549 rest_base(self.cloud_browser_host()),
550 vault_id,
551 self.api_key()
552 );
553 let url = Url::parse(&url)
554 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
555 let mut body_map = serde_json::Map::new();
556 if let Some(v) = name {
557 body_map.insert("name".into(), serde_json::Value::String(v.into()));
558 }
559 if let Some(v) = description {
560 body_map.insert("description".into(), serde_json::Value::String(v.into()));
561 }
562 let body_bytes = serde_json::to_vec(&serde_json::Value::Object(body_map))?;
563 let mut headers = HeaderMap::new();
564 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
565 let resp = self
566 .send_with_retry(Method::PATCH, url, Some(headers), Some(body_bytes))
567 .await?;
568 let status = resp.status().as_u16();
569 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
570 if status != 200 {
571 return Err(from_response(status, &body, 0, false));
572 }
573 Ok(serde_json::from_slice(&body)?)
574 }
575
576 pub async fn cloud_browser_vault_delete(
578 &self,
579 vault_id: &str,
580 ) -> Result<serde_json::Value, ScrapflyError> {
581 let url = format!(
582 "{}/vault/{}?key={}",
583 rest_base(self.cloud_browser_host()),
584 vault_id,
585 self.api_key()
586 );
587 let url = Url::parse(&url)
588 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
589 let resp = self
590 .send_with_retry(Method::DELETE, url, None, None)
591 .await?;
592 let status = resp.status().as_u16();
593 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
594 if status != 200 {
595 return Err(from_response(status, &body, 0, false));
596 }
597 Ok(serde_json::from_slice(&body)?)
598 }
599
600 pub async fn cloud_browser_vault_rotate(
606 &self,
607 vault_id: &str,
608 current_vault_key: &str,
609 ) -> Result<serde_json::Value, ScrapflyError> {
610 let url = format!(
611 "{}/vault/{}/rotate?key={}",
612 rest_base(self.cloud_browser_host()),
613 vault_id,
614 self.api_key()
615 );
616 let url = Url::parse(&url)
617 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
618 let mut headers = HeaderMap::new();
619 headers.insert(
620 "X-Vault-Key",
621 HeaderValue::from_str(current_vault_key)
622 .map_err(|_| ScrapflyError::Config("X-Vault-Key contained invalid bytes".into()))?,
624 );
625 let resp = self
626 .send_with_retry(Method::POST, url, Some(headers), None)
627 .await?;
628 let status = resp.status().as_u16();
629 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
630 if status != 200 {
631 return Err(from_response(status, &body, 0, false));
632 }
633 Ok(serde_json::from_slice(&body)?)
634 }
635
636 pub async fn cloud_browser_vault_item_list(
640 &self,
641 vault_id: &str,
642 ) -> Result<serde_json::Value, ScrapflyError> {
643 let url = format!(
644 "{}/vault/{}/item?key={}",
645 rest_base(self.cloud_browser_host()),
646 vault_id,
647 self.api_key()
648 );
649 let url = Url::parse(&url)
650 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
651 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
652 let status = resp.status().as_u16();
653 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
654 if status != 200 {
655 return Err(from_response(status, &body, 0, false));
656 }
657 Ok(serde_json::from_slice(&body)?)
658 }
659
660 pub async fn cloud_browser_vault_item_create(
676 &self,
677 vault_id: &str,
678 vault_key: &str,
679 item: serde_json::Value,
680 ) -> Result<serde_json::Value, ScrapflyError> {
681 let url = format!(
682 "{}/vault/{}/item?key={}",
683 rest_base(self.cloud_browser_host()),
684 vault_id,
685 self.api_key()
686 );
687 let url = Url::parse(&url)
688 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
689 let body_bytes = serde_json::to_vec(&item)?;
690 let mut headers = HeaderMap::new();
691 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
692 headers.insert(
693 "X-Vault-Key",
694 HeaderValue::from_str(vault_key)
695 .map_err(|_| ScrapflyError::Config("X-Vault-Key contained invalid bytes".into()))?,
696 );
697 let resp = self
698 .send_with_retry(Method::POST, url, Some(headers), Some(body_bytes))
699 .await?;
700 let status = resp.status().as_u16();
701 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
702 if status != 200 && status != 201 {
703 return Err(from_response(status, &body, 0, false));
704 }
705 Ok(serde_json::from_slice(&body)?)
706 }
707
708 pub async fn cloud_browser_vault_item_update(
715 &self,
716 vault_id: &str,
717 item_id: &str,
718 vault_key: Option<&str>,
719 patch: serde_json::Value,
720 ) -> Result<serde_json::Value, ScrapflyError> {
721 let url = format!(
722 "{}/vault/{}/item/{}?key={}",
723 rest_base(self.cloud_browser_host()),
724 vault_id,
725 item_id,
726 self.api_key()
727 );
728 let url = Url::parse(&url)
729 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
730 let body_bytes = serde_json::to_vec(&patch)?;
731 let mut headers = HeaderMap::new();
732 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
733 if let Some(k) = vault_key {
734 headers.insert(
735 "X-Vault-Key",
736 HeaderValue::from_str(k).map_err(|_| {
737 ScrapflyError::Config("X-Vault-Key contained invalid bytes".into())
738 })?,
739 );
740 }
741 let resp = self
742 .send_with_retry(Method::PATCH, url, Some(headers), Some(body_bytes))
743 .await?;
744 let status = resp.status().as_u16();
745 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
746 if status != 200 {
747 return Err(from_response(status, &body, 0, false));
748 }
749 Ok(serde_json::from_slice(&body)?)
750 }
751
752 pub async fn cloud_browser_vault_item_delete(
754 &self,
755 vault_id: &str,
756 item_id: &str,
757 ) -> Result<serde_json::Value, ScrapflyError> {
758 let url = format!(
759 "{}/vault/{}/item/{}?key={}",
760 rest_base(self.cloud_browser_host()),
761 vault_id,
762 item_id,
763 self.api_key()
764 );
765 let url = Url::parse(&url)
766 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
767 let resp = self
768 .send_with_retry(Method::DELETE, url, None, None)
769 .await?;
770 let status = resp.status().as_u16();
771 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
772 if status != 200 {
773 return Err(from_response(status, &body, 0, false));
774 }
775 Ok(serde_json::from_slice(&body)?)
776 }
777
778 pub async fn cloud_browser_playback(
782 &self,
783 run_id: &str,
784 ) -> Result<serde_json::Value, ScrapflyError> {
785 let url = format!(
786 "{}/run/{}/playback?key={}",
787 rest_base(self.cloud_browser_host()),
788 run_id,
789 self.api_key()
790 );
791 let url = Url::parse(&url)
792 .map_err(|e| ScrapflyError::Config(format!("invalid playback url: {}", e)))?;
793 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
794 let status = resp.status().as_u16();
795 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
796 if status != 200 {
797 return Err(from_response(status, &body, 0, false));
798 }
799 Ok(serde_json::from_slice(&body)?)
800 }
801
802 pub async fn cloud_browser_wait_for_playback(
806 &self,
807 run_id: &str,
808 timeout: std::time::Duration,
809 fallback_interval: std::time::Duration,
810 ) -> Result<serde_json::Value, ScrapflyError> {
811 let deadline = std::time::Instant::now() + timeout;
812 loop {
813 let playback = self.cloud_browser_playback(run_id).await?;
814 let status = playback
815 .get("status")
816 .and_then(|v| v.as_str())
817 .unwrap_or("");
818 if status != "uploading" {
819 return Ok(playback);
820 }
821 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
822 if remaining.is_zero() {
823 return Ok(playback);
824 }
825 let retry_after_ms = playback
826 .get("retry_after_ms")
827 .and_then(|v| v.as_u64())
828 .unwrap_or(0);
829 let next = if retry_after_ms > 0 {
830 std::time::Duration::from_millis(retry_after_ms)
831 } else {
832 fallback_interval
833 };
834 tokio::time::sleep(next.min(remaining)).await;
835 }
836 }
837
838 pub async fn cloud_browser_session_stop(&self, session_id: &str) -> Result<(), ScrapflyError> {
840 if session_id.is_empty() {
841 return Err(ScrapflyError::Config("session_id is required".into()));
842 }
843 let url = format!(
844 "{}/session/{}/stop?key={}",
845 rest_base(self.cloud_browser_host()),
846 session_id,
847 self.api_key()
848 );
849 let url = Url::parse(&url)
850 .map_err(|e| ScrapflyError::Config(format!("invalid session url: {}", e)))?;
851 let resp = self.send_with_retry(Method::POST, url, None, None).await?;
852 let status = resp.status().as_u16();
853 if status != 200 {
854 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
855 return Err(from_response(status, &body, 0, false));
856 }
857 Ok(())
858 }
859
860 pub async fn cloud_browser_sessions(&self) -> Result<serde_json::Value, ScrapflyError> {
862 let url = format!(
863 "{}/sessions?key={}",
864 rest_base(self.cloud_browser_host()),
865 self.api_key()
866 );
867 let url = Url::parse(&url)
868 .map_err(|e| ScrapflyError::Config(format!("invalid sessions url: {}", e)))?;
869 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
870 let status = resp.status().as_u16();
871 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
872 if status != 200 {
873 return Err(from_response(status, &body, 0, false));
874 }
875 Ok(serde_json::from_slice(&body)?)
876 }
877
878 pub async fn cloud_browser_video(&self, run_id: &str) -> Result<Vec<u8>, ScrapflyError> {
880 let url = format!(
881 "{}/run/{}/video?key={}",
882 rest_base(self.cloud_browser_host()),
883 run_id,
884 self.api_key()
885 );
886 let url = Url::parse(&url)
887 .map_err(|e| ScrapflyError::Config(format!("invalid video url: {}", e)))?;
888 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
889 let status = resp.status().as_u16();
890 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
891 if status != 200 {
892 return Err(from_response(status, &body, 0, false));
893 }
894 Ok(body.to_vec())
895 }
896}