1mod config;
34
35pub use config::{BrowsrClientConfig, DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL};
36
37pub use browsr_types::{BrowserStepInput, BrowserStepRequest, BrowserStepResult};
39
40use browsr_types::{
41 AutomateResponse, BrowserContext, Commands, ObserveResponse, ScrapeOptions, SearchOptions,
42 SearchResponse,
43};
44use reqwest::StatusCode;
45use serde::{Deserialize, Serialize, de::DeserializeOwned};
46use serde_json::{Value, json};
47use thiserror::Error;
48use tokio::process::Command;
49
50#[derive(Debug, Clone)]
51pub enum TransportConfig {
52 Http { base_url: String },
53 Stdout { command: String },
54}
55
56#[derive(Debug, Clone)]
74pub struct BrowsrClient {
75 transport: BrowsrTransport,
76 config: BrowsrClientConfig,
77}
78
79#[derive(Debug, Clone)]
80enum BrowsrTransport {
81 Http(HttpTransport),
82 Stdout(StdoutTransport),
83}
84
85impl BrowsrClient {
86 pub fn new(base_url: impl Into<String>) -> Self {
89 let config = BrowsrClientConfig::new(base_url);
90 Self::from_client_config(config)
91 }
92
93 pub fn from_env() -> Self {
98 let config = BrowsrClientConfig::from_env();
99 Self::from_client_config(config)
100 }
101
102 pub fn from_client_config(config: BrowsrClientConfig) -> Self {
104 let http = config
105 .build_http_client()
106 .expect("Failed to build HTTP client");
107
108 Self {
109 transport: BrowsrTransport::Http(HttpTransport::new_with_client(
110 &config.base_url,
111 http,
112 )),
113 config,
114 }
115 }
116
117 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
120 self.config = self.config.with_api_key(api_key);
121 let http = self
122 .config
123 .build_http_client()
124 .expect("Failed to build HTTP client");
125 self.transport =
126 BrowsrTransport::Http(HttpTransport::new_with_client(&self.config.base_url, http));
127 self
128 }
129
130 pub fn new_http(base_url: impl Into<String>) -> Self {
132 Self::new(base_url)
133 }
134
135 pub fn new_stdout(command: impl Into<String>) -> Self {
137 Self {
138 transport: BrowsrTransport::Stdout(StdoutTransport::new(command)),
139 config: BrowsrClientConfig::default(),
140 }
141 }
142
143 pub fn from_config(cfg: TransportConfig) -> Self {
145 match cfg {
146 TransportConfig::Http { base_url } => Self::new_http(base_url),
147 TransportConfig::Stdout { command } => Self::new_stdout(command),
148 }
149 }
150
151 pub fn base_url(&self) -> &str {
153 &self.config.base_url
154 }
155
156 pub fn config(&self) -> &BrowsrClientConfig {
158 &self.config
159 }
160
161 pub fn has_auth(&self) -> bool {
163 self.config.has_auth()
164 }
165
166 pub fn is_local(&self) -> bool {
168 self.config.is_local()
169 }
170
171 pub async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
177 match &self.transport {
178 BrowsrTransport::Http(inner) => {
179 let response: SessionList = inner.get("/sessions").await?;
180 Ok(response.sessions)
181 }
182 BrowsrTransport::Stdout(inner) => inner.list_sessions().await,
183 }
184 }
185
186 pub async fn create_session(&self) -> Result<SessionCreated, ClientError> {
189 match &self.transport {
190 BrowsrTransport::Http(inner) => {
191 inner.post("/sessions", &Value::Null).await
192 }
193 BrowsrTransport::Stdout(inner) => {
194 let session_id = inner.create_session().await?;
195 Ok(SessionCreated {
196 session_id,
197 sse_url: None,
198 frame_url: None,
199 frame_token: None,
200 })
201 }
202 }
203 }
204
205 pub async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
207 match &self.transport {
208 BrowsrTransport::Http(inner) => inner
209 .delete(&format!("/sessions/{}", session_id))
210 .await
211 .map(|_: Value| ()),
212 BrowsrTransport::Stdout(inner) => inner.destroy_session(session_id).await,
213 }
214 }
215
216 pub async fn execute_commands(
222 &self,
223 commands: Vec<Commands>,
224 session_id: Option<String>,
225 headless: Option<bool>,
226 context: Option<BrowserContext>,
227 ) -> Result<AutomateResponse, ClientError> {
228 let payload = CommandsPayload {
229 commands,
230 session_id,
231 headless: headless.or(self.config.headless),
232 context,
233 };
234
235 match &self.transport {
236 BrowsrTransport::Http(inner) => inner.post("/commands", &payload).await,
237 BrowsrTransport::Stdout(inner) => inner.execute_commands(&payload).await,
238 }
239 }
240
241 pub async fn execute_command(
243 &self,
244 command: Commands,
245 session_id: Option<String>,
246 headless: Option<bool>,
247 ) -> Result<AutomateResponse, ClientError> {
248 self.execute_commands(vec![command], session_id, headless, None)
249 .await
250 }
251
252 pub async fn navigate(
258 &self,
259 url: &str,
260 session_id: Option<String>,
261 ) -> Result<AutomateResponse, ClientError> {
262 self.execute_command(
263 Commands::NavigateTo {
264 url: url.to_string(),
265 },
266 session_id,
267 None,
268 )
269 .await
270 }
271
272 pub async fn click(
274 &self,
275 selector: &str,
276 session_id: Option<String>,
277 ) -> Result<AutomateResponse, ClientError> {
278 self.execute_command(
279 Commands::Click {
280 selector: selector.to_string(),
281 },
282 session_id,
283 None,
284 )
285 .await
286 }
287
288 pub async fn type_text(
290 &self,
291 selector: &str,
292 text: &str,
293 clear: Option<bool>,
294 session_id: Option<String>,
295 ) -> Result<AutomateResponse, ClientError> {
296 self.execute_command(
297 Commands::TypeText {
298 selector: selector.to_string(),
299 text: text.to_string(),
300 clear,
301 },
302 session_id,
303 None,
304 )
305 .await
306 }
307
308 pub async fn wait_for_element(
310 &self,
311 selector: &str,
312 timeout_ms: Option<u64>,
313 session_id: Option<String>,
314 ) -> Result<AutomateResponse, ClientError> {
315 self.execute_command(
316 Commands::WaitForElement {
317 selector: selector.to_string(),
318 timeout_ms,
319 visible_only: None,
320 },
321 session_id,
322 None,
323 )
324 .await
325 }
326
327 pub async fn screenshot(
329 &self,
330 full_page: bool,
331 session_id: Option<String>,
332 ) -> Result<AutomateResponse, ClientError> {
333 self.execute_command(
334 Commands::Screenshot {
335 full_page: Some(full_page),
336 path: None,
337 },
338 session_id,
339 None,
340 )
341 .await
342 }
343
344 pub async fn get_title(
346 &self,
347 session_id: Option<String>,
348 ) -> Result<AutomateResponse, ClientError> {
349 self.execute_command(Commands::GetTitle, session_id, None)
350 .await
351 }
352
353 pub async fn get_text(
355 &self,
356 selector: &str,
357 session_id: Option<String>,
358 ) -> Result<AutomateResponse, ClientError> {
359 self.execute_command(
360 Commands::GetText {
361 selector: selector.to_string(),
362 },
363 session_id,
364 None,
365 )
366 .await
367 }
368
369 pub async fn get_content(
371 &self,
372 selector: Option<String>,
373 session_id: Option<String>,
374 ) -> Result<AutomateResponse, ClientError> {
375 self.execute_command(
376 Commands::GetContent {
377 selector,
378 kind: None,
379 },
380 session_id,
381 None,
382 )
383 .await
384 }
385
386 pub async fn evaluate(
388 &self,
389 expression: &str,
390 session_id: Option<String>,
391 ) -> Result<AutomateResponse, ClientError> {
392 self.execute_command(
393 Commands::Evaluate {
394 expression: expression.to_string(),
395 },
396 session_id,
397 None,
398 )
399 .await
400 }
401
402 pub async fn extract_structured(
423 &self,
424 query: &str,
425 schema: Option<serde_json::Value>,
426 max_chars: Option<usize>,
427 session_id: Option<String>,
428 ) -> Result<AutomateResponse, ClientError> {
429 self.execute_command(
430 Commands::ExtractStructuredContent {
431 query: query.to_string(),
432 schema,
433 max_chars,
434 },
435 session_id,
436 None,
437 )
438 .await
439 }
440
441 pub async fn observe(
447 &self,
448 session_id: Option<String>,
449 headless: Option<bool>,
450 opts: ObserveOptions,
451 ) -> Result<ObserveResponse, ClientError> {
452 let payload = ObservePayload {
453 session_id,
454 headless: headless.or(self.config.headless),
455 use_image: opts.use_image,
456 full_page: opts.full_page,
457 wait_ms: opts.wait_ms,
458 include_content: opts.include_content,
459 };
460
461 match &self.transport {
462 BrowsrTransport::Http(inner) => {
463 let envelope: ObserveEnvelope = inner.post("/observe", &payload).await?;
464 Ok(envelope.observation)
465 }
466 BrowsrTransport::Stdout(inner) => inner.observe(&payload).await,
467 }
468 }
469
470 pub async fn scrape_v1(&self, request: ScrapeApiRequest) -> Result<ScrapeApiResponse, ClientError> {
476 match &self.transport {
477 BrowsrTransport::Http(inner) => inner.post("/v1/scrape", &request).await,
478 BrowsrTransport::Stdout(inner) => inner.request("scrape", &request).await,
479 }
480 }
481
482 pub async fn scrape_url(&self, url: &str) -> Result<ScrapeApiResponse, ClientError> {
484 self.scrape_v1(ScrapeApiRequest::new(url)).await
485 }
486
487 pub async fn crawl(&self, request: CrawlApiRequest) -> Result<CrawlApiResponse, ClientError> {
489 match &self.transport {
490 BrowsrTransport::Http(inner) => inner.post("/v1/crawl", &request).await,
491 BrowsrTransport::Stdout(inner) => inner.request("crawl", &request).await,
492 }
493 }
494
495 pub async fn crawl_url(&self, url: &str) -> Result<CrawlApiResponse, ClientError> {
497 self.crawl(CrawlApiRequest::new(url)).await
498 }
499
500 pub async fn scrape_legacy(&self, options: ScrapeOptions) -> Result<Value, ClientError> {
502 match &self.transport {
503 BrowsrTransport::Http(inner) => inner.post("/scrape", &options).await,
504 BrowsrTransport::Stdout(inner) => inner.scrape(&options).await,
505 }
506 }
507
508 pub async fn search(&self, options: SearchOptions) -> Result<SearchResponse, ClientError> {
514 match &self.transport {
515 BrowsrTransport::Http(inner) => inner.post("/search", &options).await,
516 BrowsrTransport::Stdout(inner) => inner.search(&options).await,
517 }
518 }
519
520 pub async fn search_query(&self, query: &str) -> Result<SearchResponse, ClientError> {
522 let options = SearchOptions {
523 query: query.to_string(),
524 limit: None,
525 };
526 self.search(options).await
527 }
528
529 pub async fn step(&self, request: BrowserStepRequest) -> Result<BrowserStepResult, ClientError> {
561 match &self.transport {
562 BrowsrTransport::Http(inner) => inner.post("/browser_step", &request).await,
563 BrowsrTransport::Stdout(inner) => inner.browser_step(&request).await,
564 }
565 }
566
567 pub async fn step_commands(
583 &self,
584 commands: Vec<Commands>,
585 ) -> Result<BrowserStepResult, ClientError> {
586 let input = BrowserStepInput::new(commands);
587 let request = BrowserStepRequest::new(input);
588 self.step(request).await
589 }
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
598#[serde(rename_all = "camelCase")]
599pub enum ScrapeFormat {
600 Markdown,
601 Summary,
602 Html,
603 RawHtml,
604 Screenshot,
605 Links,
606 Json,
607 Images,
608 Branding,
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize)]
613#[serde(rename_all = "camelCase")]
614pub struct JsonExtractionOptions {
615 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub prompt: Option<String>,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub schema: Option<Value>,
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize)]
623#[serde(rename_all = "camelCase")]
624pub struct ScrapeAction {
625 #[serde(rename = "type")]
626 pub action_type: String,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
628 pub selector: Option<String>,
629 #[serde(default, skip_serializing_if = "Option::is_none")]
630 pub text: Option<String>,
631 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub milliseconds: Option<u64>,
633 #[serde(default, skip_serializing_if = "Option::is_none")]
634 pub expression: Option<String>,
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize)]
639#[serde(rename_all = "camelCase")]
640pub struct ScrapeApiRequest {
641 pub url: String,
642 #[serde(default = "default_scrape_formats")]
643 pub formats: Vec<ScrapeFormat>,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub wait_for: Option<u64>,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub actions: Option<Vec<ScrapeAction>>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub json_options: Option<JsonExtractionOptions>,
650 #[serde(default = "default_true")]
651 pub only_main_content: bool,
652 #[serde(default = "default_true")]
653 pub remove_base64_images: bool,
654}
655
656fn default_scrape_formats() -> Vec<ScrapeFormat> {
657 vec![ScrapeFormat::Markdown]
658}
659
660fn default_true() -> bool {
661 true
662}
663
664impl ScrapeApiRequest {
665 pub fn new(url: impl Into<String>) -> Self {
667 Self {
668 url: url.into(),
669 formats: vec![ScrapeFormat::Markdown],
670 wait_for: None,
671 actions: None,
672 json_options: None,
673 only_main_content: true,
674 remove_base64_images: true,
675 }
676 }
677
678 pub fn with_formats(mut self, formats: Vec<ScrapeFormat>) -> Self {
680 self.formats = formats;
681 self
682 }
683
684 pub fn with_wait(mut self, ms: u64) -> Self {
686 self.wait_for = Some(ms);
687 self
688 }
689}
690
691#[derive(Debug, Clone, Serialize, Deserialize)]
693#[serde(rename_all = "camelCase")]
694pub struct PageMetadata {
695 pub title: Option<String>,
696 pub description: Option<String>,
697 #[serde(rename = "sourceURL")]
698 pub source_url: String,
699 pub status_code: Option<u16>,
700}
701
702#[derive(Debug, Clone, Serialize, Deserialize)]
704pub struct ExtractedLink {
705 pub href: String,
706 #[serde(default)]
707 pub text: String,
708}
709
710#[derive(Debug, Clone, Serialize, Deserialize)]
712pub struct ExtractedImage {
713 pub src: String,
714 #[serde(default)]
715 pub alt: Option<String>,
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize)]
720pub struct BrandingInfo {
721 #[serde(default)]
722 pub colors: Option<Vec<String>>,
723 #[serde(default)]
724 pub fonts: Option<Vec<String>>,
725 #[serde(default)]
726 pub logo: Option<String>,
727 #[serde(default)]
728 pub favicon: Option<String>,
729 #[serde(default)]
730 pub name: Option<String>,
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize)]
735#[serde(rename_all = "camelCase")]
736pub struct ScrapeData {
737 #[serde(default)]
738 pub markdown: Option<String>,
739 #[serde(default)]
740 pub summary: Option<String>,
741 #[serde(default)]
742 pub html: Option<String>,
743 #[serde(default)]
744 pub raw_html: Option<String>,
745 #[serde(default)]
746 pub screenshot: Option<String>,
747 #[serde(default)]
748 pub links: Option<Vec<ExtractedLink>>,
749 #[serde(default)]
750 pub json: Option<Value>,
751 #[serde(default)]
752 pub images: Option<Vec<ExtractedImage>>,
753 #[serde(default)]
754 pub branding: Option<BrandingInfo>,
755 pub metadata: PageMetadata,
756 #[serde(default)]
757 pub warning: Option<String>,
758}
759
760#[derive(Debug, Clone, Serialize, Deserialize)]
762pub struct ScrapeApiResponse {
763 pub success: bool,
764 pub data: ScrapeData,
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize)]
769#[serde(rename_all = "camelCase")]
770pub struct CrawlApiRequest {
771 pub url: String,
772 #[serde(default = "default_crawl_limit")]
773 pub limit: usize,
774 #[serde(default = "default_crawl_depth")]
775 pub max_depth: usize,
776 #[serde(default = "default_scrape_formats")]
777 pub formats: Vec<ScrapeFormat>,
778 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub wait_for: Option<u64>,
780 #[serde(default, skip_serializing_if = "Option::is_none")]
781 pub include_paths: Option<Vec<String>>,
782 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub exclude_paths: Option<Vec<String>>,
784 #[serde(default = "default_true")]
785 pub only_main_content: bool,
786 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub json_options: Option<JsonExtractionOptions>,
788}
789
790fn default_crawl_limit() -> usize {
791 10
792}
793
794fn default_crawl_depth() -> usize {
795 2
796}
797
798impl CrawlApiRequest {
799 pub fn new(url: impl Into<String>) -> Self {
801 Self {
802 url: url.into(),
803 limit: 10,
804 max_depth: 2,
805 formats: vec![ScrapeFormat::Markdown],
806 wait_for: None,
807 include_paths: None,
808 exclude_paths: None,
809 only_main_content: true,
810 json_options: None,
811 }
812 }
813}
814
815#[derive(Debug, Clone, Serialize, Deserialize)]
817pub struct CrawlApiResponse {
818 pub success: bool,
819 pub total: usize,
820 pub completed: usize,
821 pub data: Vec<ScrapeData>,
822}
823
824#[derive(Debug, Clone, Serialize, Deserialize)]
829struct SessionList {
830 sessions: Vec<String>,
831}
832
833#[derive(Debug, Clone, Serialize, Deserialize)]
835pub struct SessionCreated {
836 pub session_id: String,
837 #[serde(default)]
839 pub sse_url: Option<String>,
840 #[serde(default)]
842 pub frame_url: Option<String>,
843 #[serde(default)]
845 pub frame_token: Option<String>,
846}
847
848impl SessionCreated {
849 pub fn build_sse_url(&self, base_url: &str, width: Option<u32>, height: Option<u32>) -> String {
851 let mut url = self.sse_url.clone().unwrap_or_else(|| {
852 format!("{}/stream/sse?session_id={}", base_url, self.session_id)
853 });
854
855 if let Some(ref token) = self.frame_token {
857 let sep = if url.contains('?') { "&" } else { "?" };
858 url = format!("{}{}token={}", url, sep, token);
859 }
860
861 if let Some(w) = width {
863 let sep = if url.contains('?') { "&" } else { "?" };
864 url = format!("{}{}width={}", url, sep, w);
865 }
866 if let Some(h) = height {
867 url = format!("{}&height={}", url, h);
868 }
869
870 url
871 }
872}
873
874#[derive(Debug, Clone, Serialize, Deserialize)]
875struct CommandsPayload {
876 commands: Vec<Commands>,
877 #[serde(skip_serializing_if = "Option::is_none")]
878 session_id: Option<String>,
879 #[serde(skip_serializing_if = "Option::is_none")]
880 headless: Option<bool>,
881 #[serde(skip_serializing_if = "Option::is_none")]
882 context: Option<BrowserContext>,
883}
884
885#[derive(Debug, Clone, Serialize, Deserialize)]
887pub struct ObserveOptions {
888 pub use_image: Option<bool>,
889 pub full_page: Option<bool>,
890 pub wait_ms: Option<u64>,
891 pub include_content: Option<bool>,
892}
893
894impl Default for ObserveOptions {
895 fn default() -> Self {
896 Self {
897 use_image: Some(true),
898 full_page: None,
899 wait_ms: None,
900 include_content: Some(true),
901 }
902 }
903}
904
905#[derive(Debug, Clone, Serialize, Deserialize)]
906struct ObservePayload {
907 #[serde(default, skip_serializing_if = "Option::is_none")]
908 pub session_id: Option<String>,
909 #[serde(default, skip_serializing_if = "Option::is_none")]
910 pub headless: Option<bool>,
911 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub use_image: Option<bool>,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
914 pub full_page: Option<bool>,
915 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub wait_ms: Option<u64>,
917 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub include_content: Option<bool>,
919}
920
921#[derive(Debug, Clone, Serialize, Deserialize)]
922struct ObserveEnvelope {
923 pub session_id: String,
924 pub observation: ObserveResponse,
925}
926
927#[derive(Debug, Error)]
932pub enum ClientError {
933 #[error("http request failed: {0}")]
934 Http(#[from] reqwest::Error),
935 #[error("stdout transport failed: {0}")]
936 Stdout(String),
937 #[error("invalid response: {0}")]
938 InvalidResponse(String),
939 #[error("serialization error: {0}")]
940 Serialization(#[from] serde_json::Error),
941 #[error("io error: {0}")]
942 Io(#[from] std::io::Error),
943}
944
945#[derive(Debug, Clone)]
950struct HttpTransport {
951 base_url: String,
952 client: reqwest::Client,
953}
954
955impl HttpTransport {
956 fn new_with_client(base_url: impl Into<String>, client: reqwest::Client) -> Self {
957 Self {
958 base_url: base_url.into(),
959 client,
960 }
961 }
962
963 fn url(&self, path: &str) -> String {
964 let base = self.base_url.trim_end_matches('/');
965 let suffix = path.trim_start_matches('/');
966 format!("{}/{}", base, suffix)
967 }
968
969 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
970 let resp = self.client.get(self.url(path)).send().await?;
971 Self::handle_response(resp).await
972 }
973
974 async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
975 let resp = self.client.delete(self.url(path)).send().await?;
976 Self::handle_response(resp).await
977 }
978
979 async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
980 &self,
981 path: &str,
982 body: &B,
983 ) -> Result<T, ClientError> {
984 let resp = self.client.post(self.url(path)).json(body).send().await?;
985 Self::handle_response(resp).await
986 }
987
988 async fn handle_response<T: DeserializeOwned>(
989 resp: reqwest::Response,
990 ) -> Result<T, ClientError> {
991 let status = resp.status();
992 if status == StatusCode::NO_CONTENT {
993 let empty: Value = Value::Null;
994 let value: T = serde_json::from_value(empty).map_err(ClientError::Serialization)?;
995 return Ok(value);
996 }
997
998 let text = resp.text().await?;
999 if !status.is_success() {
1000 return Err(ClientError::InvalidResponse(format!(
1001 "{}: {}",
1002 status, text
1003 )));
1004 }
1005
1006 serde_json::from_str(&text).map_err(ClientError::Serialization)
1007 }
1008}
1009
1010#[derive(Debug, Clone)]
1015struct StdoutTransport {
1016 command: String,
1017}
1018
1019impl StdoutTransport {
1020 fn new(command: impl Into<String>) -> Self {
1021 Self {
1022 command: command.into(),
1023 }
1024 }
1025
1026 async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
1027 let envelope: SessionList = self.request("sessions", &Value::Null).await?;
1028 Ok(envelope.sessions)
1029 }
1030
1031 async fn create_session(&self) -> Result<String, ClientError> {
1032 let created: SessionCreated = self.request("create_session", &Value::Null).await?;
1033 Ok(created.session_id)
1034 }
1035
1036 async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
1037 let payload = json!({ "session_id": session_id });
1038 let _: Value = self.request("destroy_session", &payload).await?;
1039 Ok(())
1040 }
1041
1042 async fn execute_commands(
1043 &self,
1044 payload: &CommandsPayload,
1045 ) -> Result<AutomateResponse, ClientError> {
1046 self.request("execute", payload).await
1047 }
1048
1049 async fn observe(&self, payload: &ObservePayload) -> Result<ObserveResponse, ClientError> {
1050 let envelope: ObserveEnvelope = self.request("observe", payload).await?;
1051 Ok(envelope.observation)
1052 }
1053
1054 async fn scrape(&self, options: &ScrapeOptions) -> Result<Value, ClientError> {
1055 self.request("scrape", options).await
1056 }
1057
1058 async fn search(&self, options: &SearchOptions) -> Result<SearchResponse, ClientError> {
1059 self.request("search", options).await
1060 }
1061
1062 async fn browser_step(
1063 &self,
1064 request: &BrowserStepRequest,
1065 ) -> Result<BrowserStepResult, ClientError> {
1066 self.request("browser_step", request).await
1067 }
1068
1069 async fn request<T: DeserializeOwned, B: Serialize>(
1070 &self,
1071 operation: &str,
1072 payload: &B,
1073 ) -> Result<T, ClientError> {
1074 let mut cmd = Command::new(&self.command);
1075 cmd.arg("client").arg(operation);
1076
1077 let payload_str = serde_json::to_string(&payload).map_err(ClientError::Serialization)?;
1078 cmd.env("BROWSR_CLIENT_PAYLOAD", &payload_str);
1079
1080 let output = cmd.output().await?;
1081 if !output.status.success() {
1082 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1083 return Err(ClientError::Stdout(format!(
1084 "browsr command failed ({}): {}",
1085 output.status, stderr
1086 )));
1087 }
1088
1089 let stdout = String::from_utf8_lossy(&output.stdout);
1090 if stdout.trim().is_empty() {
1091 return Err(ClientError::InvalidResponse(
1092 "empty stdout from browsr".to_string(),
1093 ));
1094 }
1095
1096 serde_json::from_str(stdout.trim()).map_err(ClientError::Serialization)
1097 }
1098}
1099
1100pub fn default_base_url() -> Option<String> {
1106 if let Ok(url) = std::env::var("BROWSR_API_URL") {
1107 return Some(url);
1108 }
1109 if let Ok(url) = std::env::var("BROWSR_BASE_URL") {
1110 return Some(url);
1111 }
1112 if let Ok(port) = std::env::var("BROWSR_PORT") {
1113 let host = std::env::var("BROWSR_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
1114 return Some(format!("http://{}:{}", host, port));
1115 }
1116 Some(DEFAULT_BASE_URL.to_string())
1118}
1119
1120pub fn default_transport() -> TransportConfig {
1121 TransportConfig::Http {
1122 base_url: default_base_url().unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
1123 }
1124}