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")]
173 pub timeout: Option<u32>,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub browser_timeout: Option<u32>,
177 #[serde(skip_serializing_if = "is_false")]
179 pub enable_mcp: bool,
180 #[serde(skip_serializing_if = "is_false")]
183 pub debug: bool,
184}
185
186#[derive(Debug, Clone, Deserialize, Default)]
188pub struct UnblockResult {
189 #[serde(default)]
191 pub ws_url: String,
192 #[serde(default)]
194 pub session_id: String,
195 #[serde(default)]
197 pub run_id: String,
198 #[serde(default)]
200 pub mcp_endpoint: String,
201}
202
203impl Client {
204 pub fn cloud_browser_url(&self, config: &BrowserConfig) -> String {
210 let ws_host = ws_base(self.cloud_browser_host());
211 let mut pairs: Vec<(String, String)> = vec![("api_key".into(), self.api_key().into())];
212 if let Some(v) = &config.proxy_pool {
213 pairs.push(("proxy_pool".into(), v.clone()));
214 }
215 if let Some(v) = &config.os {
216 pairs.push(("os".into(), v.clone()));
217 }
218 if let Some(v) = &config.country {
219 pairs.push(("country".into(), v.clone()));
220 }
221 if let Some(v) = &config.session {
222 pairs.push(("session".into(), v.clone()));
223 }
224 if let Some(v) = config.timeout {
225 pairs.push(("timeout".into(), v.to_string()));
226 }
227 if config.block_images {
228 pairs.push(("block_images".into(), "true".into()));
229 }
230 if config.block_styles {
231 pairs.push(("block_styles".into(), "true".into()));
232 }
233 if config.block_fonts {
234 pairs.push(("block_fonts".into(), "true".into()));
235 }
236 if config.block_media {
237 pairs.push(("block_media".into(), "true".into()));
238 }
239 if config.screenshot {
240 pairs.push(("screenshot".into(), "true".into()));
241 }
242 if config.cache {
243 pairs.push(("cache".into(), "true".into()));
244 }
245 if config.blacklist {
246 pairs.push(("blacklist".into(), "true".into()));
247 }
248 if config.debug {
249 pairs.push(("debug".into(), "true".into()));
250 }
251 if let Some(v) = &config.resolution {
252 pairs.push(("resolution".into(), v.clone()));
253 }
254 if let Some(v) = &config.browser_brand {
255 pairs.push(("browser_brand".into(), v.clone()));
256 }
257 if let Some(v) = &config.byop_proxy {
258 pairs.push(("byop_proxy".into(), v.clone()));
259 }
260 if config.enable_mcp {
261 pairs.push(("enable_mcp".into(), "true".into()));
262 }
263 if config.solve_captcha {
264 pairs.push(("solve_captcha".into(), "true".into()));
265 }
266 if let Some(v) = &config.vault {
267 pairs.push(("vault".into(), v.clone()));
268 }
269 if let Some(v) = &config.vault_key {
270 pairs.push(("vault_key".into(), v.clone()));
271 }
272 if config.enable_vnc {
273 pairs.push(("enable_vnc".into(), "true".into()));
274 }
275 if let Some(v) = &config.vnc_password {
276 pairs.push(("vnc_password".into(), v.clone()));
277 }
278 if config.enable_rtc {
279 pairs.push(("enable_rtc".into(), "true".into()));
280 }
281 if let Some(v) = &config.rtc_username {
282 pairs.push(("rtc_username".into(), v.clone()));
283 }
284 if let Some(v) = &config.rtc_password {
285 pairs.push(("rtc_password".into(), v.clone()));
286 }
287 if !config.hitl_allowed_networks.is_empty() {
288 pairs.push((
289 "hitl_allowed_networks".into(),
290 config.hitl_allowed_networks.join(","),
291 ));
292 }
293 let qs = serde_urlencoded::to_string(&pairs).unwrap_or_default();
294 format!("{}?{}", ws_host, qs)
295 }
296
297 pub fn cloud_browser_project_salt(&self) -> String {
300 project_salt(self.api_key())
301 }
302
303 pub async fn cloud_browser_unblock(
305 &self,
306 config: &UnblockConfig,
307 ) -> Result<UnblockResult, ScrapflyError> {
308 let url = format!(
309 "{}/unblock?key={}",
310 rest_base(self.cloud_browser_host()),
311 self.api_key()
312 );
313 let url = Url::parse(&url)
314 .map_err(|e| ScrapflyError::Config(format!("invalid unblock url: {}", e)))?;
315 let body = serde_json::to_vec(config)?;
316 let mut headers = HeaderMap::new();
317 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
318 let resp = self
319 .send_with_retry(Method::POST, url, Some(headers), Some(body))
320 .await?;
321 let status = resp.status().as_u16();
322 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
323 if status != 200 {
324 return Err(from_response(status, &body, 0, false));
325 }
326 Ok(serde_json::from_slice(&body)?)
327 }
328
329 pub async fn cloud_browser_extension_list(&self) -> Result<serde_json::Value, ScrapflyError> {
331 let url = format!(
332 "{}/extension?key={}",
333 rest_base(self.cloud_browser_host()),
334 self.api_key()
335 );
336 let url = Url::parse(&url)
337 .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
338 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
339 let status = resp.status().as_u16();
340 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
341 if status != 200 {
342 return Err(from_response(status, &body, 0, false));
343 }
344 Ok(serde_json::from_slice(&body)?)
345 }
346
347 pub async fn cloud_browser_extension_get(
349 &self,
350 extension_id: &str,
351 ) -> Result<serde_json::Value, ScrapflyError> {
352 let url = format!(
353 "{}/extension/{}?key={}",
354 rest_base(self.cloud_browser_host()),
355 extension_id,
356 self.api_key()
357 );
358 let url = Url::parse(&url)
359 .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
360 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
361 let status = resp.status().as_u16();
362 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
363 if status != 200 {
364 return Err(from_response(status, &body, 0, false));
365 }
366 Ok(serde_json::from_slice(&body)?)
367 }
368
369 pub async fn cloud_browser_extension_upload(
371 &self,
372 file_path: &std::path::Path,
373 ) -> Result<serde_json::Value, ScrapflyError> {
374 let url = format!(
375 "{}/extension?key={}",
376 rest_base(self.cloud_browser_host()),
377 self.api_key()
378 );
379 let url = Url::parse(&url)
380 .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
381 let file_bytes = std::fs::read(file_path)
382 .map_err(|e| ScrapflyError::Config(format!("failed to read extension file: {}", e)))?;
383 let file_name = file_path
384 .file_name()
385 .and_then(|n| n.to_str())
386 .unwrap_or("extension.zip")
387 .to_string();
388 let boundary = format!(
390 "----ScrapflyBoundary{}",
391 std::time::SystemTime::now()
392 .duration_since(std::time::UNIX_EPOCH)
393 .unwrap_or_default()
394 .as_millis()
395 );
396 let mut body = Vec::new();
397 body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
398 body.extend_from_slice(
399 format!(
400 "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n\
401 Content-Type: application/octet-stream\r\n\r\n",
402 file_name
403 )
404 .as_bytes(),
405 );
406 body.extend_from_slice(&file_bytes);
407 body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
408 let mut headers = HeaderMap::new();
409 headers.insert(
410 CONTENT_TYPE,
411 HeaderValue::from_str(&format!("multipart/form-data; boundary={}", boundary))
412 .map_err(|e| ScrapflyError::Config(format!("invalid content-type: {}", e)))?,
413 );
414 let resp = self
415 .send_with_retry(Method::POST, url, Some(headers), Some(body))
416 .await?;
417 let status = resp.status().as_u16();
418 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
419 if status != 200 && status != 201 {
420 return Err(from_response(status, &body, 0, false));
421 }
422 Ok(serde_json::from_slice(&body)?)
423 }
424
425 pub async fn cloud_browser_extension_delete(
427 &self,
428 extension_id: &str,
429 ) -> Result<serde_json::Value, ScrapflyError> {
430 let url = format!(
431 "{}/extension/{}?key={}",
432 rest_base(self.cloud_browser_host()),
433 extension_id,
434 self.api_key()
435 );
436 let url = Url::parse(&url)
437 .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
438 let resp = self
439 .send_with_retry(Method::DELETE, url, None, None)
440 .await?;
441 let status = resp.status().as_u16();
442 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
443 if status != 200 {
444 return Err(from_response(status, &body, 0, false));
445 }
446 Ok(serde_json::from_slice(&body)?)
447 }
448
449 pub async fn cloud_browser_vault_create(
466 &self,
467 name: &str,
468 description: Option<&str>,
469 ) -> Result<serde_json::Value, ScrapflyError> {
470 let url = format!(
471 "{}/vault?key={}",
472 rest_base(self.cloud_browser_host()),
473 self.api_key()
474 );
475 let url = Url::parse(&url)
476 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
477 let body = serde_json::json!({
478 "name": name,
479 "description": description.unwrap_or(""),
480 });
481 let body_bytes = serde_json::to_vec(&body)?;
482 let mut headers = HeaderMap::new();
483 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
484 let resp = self
485 .send_with_retry(Method::POST, url, Some(headers), Some(body_bytes))
486 .await?;
487 let status = resp.status().as_u16();
488 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
489 if status != 200 && status != 201 {
490 return Err(from_response(status, &body, 0, false));
491 }
492 Ok(serde_json::from_slice(&body)?)
493 }
494
495 pub async fn cloud_browser_vault_list(&self) -> Result<serde_json::Value, ScrapflyError> {
497 let url = format!(
498 "{}/vault?key={}",
499 rest_base(self.cloud_browser_host()),
500 self.api_key()
501 );
502 let url = Url::parse(&url)
503 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
504 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
505 let status = resp.status().as_u16();
506 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
507 if status != 200 {
508 return Err(from_response(status, &body, 0, false));
509 }
510 Ok(serde_json::from_slice(&body)?)
511 }
512
513 pub async fn cloud_browser_vault_get(
515 &self,
516 vault_id: &str,
517 ) -> Result<serde_json::Value, ScrapflyError> {
518 let url = format!(
519 "{}/vault/{}?key={}",
520 rest_base(self.cloud_browser_host()),
521 vault_id,
522 self.api_key()
523 );
524 let url = Url::parse(&url)
525 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
526 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
527 let status = resp.status().as_u16();
528 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
529 if status != 200 {
530 return Err(from_response(status, &body, 0, false));
531 }
532 Ok(serde_json::from_slice(&body)?)
533 }
534
535 pub async fn cloud_browser_vault_update(
538 &self,
539 vault_id: &str,
540 name: Option<&str>,
541 description: Option<&str>,
542 ) -> Result<serde_json::Value, ScrapflyError> {
543 let url = format!(
544 "{}/vault/{}?key={}",
545 rest_base(self.cloud_browser_host()),
546 vault_id,
547 self.api_key()
548 );
549 let url = Url::parse(&url)
550 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
551 let mut body_map = serde_json::Map::new();
552 if let Some(v) = name {
553 body_map.insert("name".into(), serde_json::Value::String(v.into()));
554 }
555 if let Some(v) = description {
556 body_map.insert("description".into(), serde_json::Value::String(v.into()));
557 }
558 let body_bytes = serde_json::to_vec(&serde_json::Value::Object(body_map))?;
559 let mut headers = HeaderMap::new();
560 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
561 let resp = self
562 .send_with_retry(Method::PATCH, url, Some(headers), Some(body_bytes))
563 .await?;
564 let status = resp.status().as_u16();
565 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
566 if status != 200 {
567 return Err(from_response(status, &body, 0, false));
568 }
569 Ok(serde_json::from_slice(&body)?)
570 }
571
572 pub async fn cloud_browser_vault_delete(
574 &self,
575 vault_id: &str,
576 ) -> Result<serde_json::Value, ScrapflyError> {
577 let url = format!(
578 "{}/vault/{}?key={}",
579 rest_base(self.cloud_browser_host()),
580 vault_id,
581 self.api_key()
582 );
583 let url = Url::parse(&url)
584 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
585 let resp = self
586 .send_with_retry(Method::DELETE, url, None, None)
587 .await?;
588 let status = resp.status().as_u16();
589 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
590 if status != 200 {
591 return Err(from_response(status, &body, 0, false));
592 }
593 Ok(serde_json::from_slice(&body)?)
594 }
595
596 pub async fn cloud_browser_vault_rotate(
602 &self,
603 vault_id: &str,
604 current_vault_key: &str,
605 ) -> Result<serde_json::Value, ScrapflyError> {
606 let url = format!(
607 "{}/vault/{}/rotate?key={}",
608 rest_base(self.cloud_browser_host()),
609 vault_id,
610 self.api_key()
611 );
612 let url = Url::parse(&url)
613 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
614 let mut headers = HeaderMap::new();
615 headers.insert(
616 "X-Vault-Key",
617 HeaderValue::from_str(current_vault_key)
618 .map_err(|_| ScrapflyError::Config("X-Vault-Key contained invalid bytes".into()))?,
620 );
621 let resp = self
622 .send_with_retry(Method::POST, url, Some(headers), None)
623 .await?;
624 let status = resp.status().as_u16();
625 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
626 if status != 200 {
627 return Err(from_response(status, &body, 0, false));
628 }
629 Ok(serde_json::from_slice(&body)?)
630 }
631
632 pub async fn cloud_browser_vault_item_list(
636 &self,
637 vault_id: &str,
638 ) -> Result<serde_json::Value, ScrapflyError> {
639 let url = format!(
640 "{}/vault/{}/item?key={}",
641 rest_base(self.cloud_browser_host()),
642 vault_id,
643 self.api_key()
644 );
645 let url = Url::parse(&url)
646 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
647 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
648 let status = resp.status().as_u16();
649 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
650 if status != 200 {
651 return Err(from_response(status, &body, 0, false));
652 }
653 Ok(serde_json::from_slice(&body)?)
654 }
655
656 pub async fn cloud_browser_vault_item_create(
672 &self,
673 vault_id: &str,
674 vault_key: &str,
675 item: serde_json::Value,
676 ) -> Result<serde_json::Value, ScrapflyError> {
677 let url = format!(
678 "{}/vault/{}/item?key={}",
679 rest_base(self.cloud_browser_host()),
680 vault_id,
681 self.api_key()
682 );
683 let url = Url::parse(&url)
684 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
685 let body_bytes = serde_json::to_vec(&item)?;
686 let mut headers = HeaderMap::new();
687 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
688 headers.insert(
689 "X-Vault-Key",
690 HeaderValue::from_str(vault_key)
691 .map_err(|_| ScrapflyError::Config("X-Vault-Key contained invalid bytes".into()))?,
692 );
693 let resp = self
694 .send_with_retry(Method::POST, url, Some(headers), Some(body_bytes))
695 .await?;
696 let status = resp.status().as_u16();
697 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
698 if status != 200 && status != 201 {
699 return Err(from_response(status, &body, 0, false));
700 }
701 Ok(serde_json::from_slice(&body)?)
702 }
703
704 pub async fn cloud_browser_vault_item_update(
711 &self,
712 vault_id: &str,
713 item_id: &str,
714 vault_key: Option<&str>,
715 patch: serde_json::Value,
716 ) -> Result<serde_json::Value, ScrapflyError> {
717 let url = format!(
718 "{}/vault/{}/item/{}?key={}",
719 rest_base(self.cloud_browser_host()),
720 vault_id,
721 item_id,
722 self.api_key()
723 );
724 let url = Url::parse(&url)
725 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
726 let body_bytes = serde_json::to_vec(&patch)?;
727 let mut headers = HeaderMap::new();
728 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
729 if let Some(k) = vault_key {
730 headers.insert(
731 "X-Vault-Key",
732 HeaderValue::from_str(k).map_err(|_| {
733 ScrapflyError::Config("X-Vault-Key contained invalid bytes".into())
734 })?,
735 );
736 }
737 let resp = self
738 .send_with_retry(Method::PATCH, url, Some(headers), Some(body_bytes))
739 .await?;
740 let status = resp.status().as_u16();
741 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
742 if status != 200 {
743 return Err(from_response(status, &body, 0, false));
744 }
745 Ok(serde_json::from_slice(&body)?)
746 }
747
748 pub async fn cloud_browser_vault_item_delete(
750 &self,
751 vault_id: &str,
752 item_id: &str,
753 ) -> Result<serde_json::Value, ScrapflyError> {
754 let url = format!(
755 "{}/vault/{}/item/{}?key={}",
756 rest_base(self.cloud_browser_host()),
757 vault_id,
758 item_id,
759 self.api_key()
760 );
761 let url = Url::parse(&url)
762 .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
763 let resp = self
764 .send_with_retry(Method::DELETE, url, None, None)
765 .await?;
766 let status = resp.status().as_u16();
767 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
768 if status != 200 {
769 return Err(from_response(status, &body, 0, false));
770 }
771 Ok(serde_json::from_slice(&body)?)
772 }
773
774 pub async fn cloud_browser_playback(
778 &self,
779 run_id: &str,
780 ) -> Result<serde_json::Value, ScrapflyError> {
781 let url = format!(
782 "{}/run/{}/playback?key={}",
783 rest_base(self.cloud_browser_host()),
784 run_id,
785 self.api_key()
786 );
787 let url = Url::parse(&url)
788 .map_err(|e| ScrapflyError::Config(format!("invalid playback url: {}", e)))?;
789 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
790 let status = resp.status().as_u16();
791 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
792 if status != 200 {
793 return Err(from_response(status, &body, 0, false));
794 }
795 Ok(serde_json::from_slice(&body)?)
796 }
797
798 pub async fn cloud_browser_wait_for_playback(
802 &self,
803 run_id: &str,
804 timeout: std::time::Duration,
805 fallback_interval: std::time::Duration,
806 ) -> Result<serde_json::Value, ScrapflyError> {
807 let deadline = std::time::Instant::now() + timeout;
808 loop {
809 let playback = self.cloud_browser_playback(run_id).await?;
810 let status = playback
811 .get("status")
812 .and_then(|v| v.as_str())
813 .unwrap_or("");
814 if status != "uploading" {
815 return Ok(playback);
816 }
817 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
818 if remaining.is_zero() {
819 return Ok(playback);
820 }
821 let retry_after_ms = playback
822 .get("retry_after_ms")
823 .and_then(|v| v.as_u64())
824 .unwrap_or(0);
825 let next = if retry_after_ms > 0 {
826 std::time::Duration::from_millis(retry_after_ms)
827 } else {
828 fallback_interval
829 };
830 tokio::time::sleep(next.min(remaining)).await;
831 }
832 }
833
834 pub async fn cloud_browser_session_stop(&self, session_id: &str) -> Result<(), ScrapflyError> {
836 if session_id.is_empty() {
837 return Err(ScrapflyError::Config("session_id is required".into()));
838 }
839 let url = format!(
840 "{}/session/{}/stop?key={}",
841 rest_base(self.cloud_browser_host()),
842 session_id,
843 self.api_key()
844 );
845 let url = Url::parse(&url)
846 .map_err(|e| ScrapflyError::Config(format!("invalid session url: {}", e)))?;
847 let resp = self.send_with_retry(Method::POST, url, None, None).await?;
848 let status = resp.status().as_u16();
849 if status != 200 {
850 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
851 return Err(from_response(status, &body, 0, false));
852 }
853 Ok(())
854 }
855
856 pub async fn cloud_browser_sessions(&self) -> Result<serde_json::Value, ScrapflyError> {
858 let url = format!(
859 "{}/sessions?key={}",
860 rest_base(self.cloud_browser_host()),
861 self.api_key()
862 );
863 let url = Url::parse(&url)
864 .map_err(|e| ScrapflyError::Config(format!("invalid sessions url: {}", e)))?;
865 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
866 let status = resp.status().as_u16();
867 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
868 if status != 200 {
869 return Err(from_response(status, &body, 0, false));
870 }
871 Ok(serde_json::from_slice(&body)?)
872 }
873
874 pub async fn cloud_browser_video(&self, run_id: &str) -> Result<Vec<u8>, ScrapflyError> {
876 let url = format!(
877 "{}/run/{}/video?key={}",
878 rest_base(self.cloud_browser_host()),
879 run_id,
880 self.api_key()
881 );
882 let url = Url::parse(&url)
883 .map_err(|e| ScrapflyError::Config(format!("invalid video url: {}", e)))?;
884 let resp = self.send_with_retry(Method::GET, url, None, None).await?;
885 let status = resp.status().as_u16();
886 let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
887 if status != 200 {
888 return Err(from_response(status, &body, 0, false));
889 }
890 Ok(body.to_vec())
891 }
892}