orb_browse/
client.rs

1//! WebDriver client with bot detection bypass
2
3use crate::{find_chrome, injections::COMPREHENSIVE_BOOTSTRAP, launch_patched_chromedriver};
4use color_eyre::Result;
5use fantoccini::ClientBuilder;
6use serde_json::json;
7use std::process::Child;
8use base64::Engine;
9
10/// A browser client that bypasses bot detection
11///
12/// This wraps fantoccini's WebDriver client with:
13/// - Patched ChromeDriver (no $cdc_ markers)
14/// - Automation bypass flags
15/// - JavaScript injection to hide automation markers
16///
17/// ## Example
18///
19/// ```no_run
20/// use orb_browse::OrbBrowser;
21///
22/// #[tokio::main]
23/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
24///     let browser = OrbBrowser::new().await?;
25///     let screenshot = browser.capture("https://google.com", 1920, 1080).await?;
26///     std::fs::write("google.png", &screenshot)?;
27///     Ok(())
28/// }
29/// ```
30pub struct OrbBrowser {
31    client: fantoccini::Client,
32    _chromedriver: Child,
33}
34
35impl OrbBrowser {
36    /// Create a new browser instance
37    ///
38    /// This will:
39    /// 1. Download and patch ChromeDriver (if not already cached)
40    /// 2. Launch ChromeDriver on a random port
41    /// 3. Connect a WebDriver client with bot detection bypass
42    pub async fn new() -> Result<Self> {
43        Self::with_size(1920, 1080).await
44    }
45
46    /// Create a new browser with custom window size
47    pub async fn with_size(width: u32, height: u32) -> Result<Self> {
48        let chrome_path = find_chrome()
49            .ok_or_else(|| color_eyre::eyre::eyre!("Chrome/Chromium not found"))?;
50
51        // Launch patched ChromeDriver
52        let (webdriver_url, chromedriver_process) = launch_patched_chromedriver()?;
53
54        // Build Chrome capabilities with automation bypass flags
55        let mut caps = serde_json::Map::new();
56        caps.insert("browserName".to_string(), json!("chrome"));
57
58        let mut chrome_options = serde_json::Map::new();
59        chrome_options.insert("binary".to_string(), json!(chrome_path.to_str().unwrap()));
60
61        let window_size_arg = format!("--window-size={},{}", width, height);
62        let args = vec![
63            "--disable-blink-features=AutomationControlled",
64            "--disable-web-security",
65            "--disable-dev-shm-usage",
66            "--no-first-run",
67            "--disable-infobars",
68            "--disable-extensions",
69            "--disable-gpu",
70            "--no-sandbox",
71            "--disable-setuid-sandbox",
72            window_size_arg.as_str(),
73            "--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
74        ];
75        chrome_options.insert("args".to_string(), json!(args));
76
77        // Exclude automation switches
78        let excluded_switches = vec!["enable-automation", "enable-logging"];
79        chrome_options.insert("excludeSwitches".to_string(), json!(excluded_switches));
80
81        // Add prefs to hide automation
82        let prefs = json!({
83            "credentials_enable_service": false,
84            "profile.password_manager_enabled": false,
85        });
86        chrome_options.insert("prefs".to_string(), prefs);
87
88        caps.insert("goog:chromeOptions".to_string(), json!(chrome_options));
89
90        // Connect to ChromeDriver
91        let client = ClientBuilder::native()
92            .capabilities(caps)
93            .connect(&webdriver_url)
94            .await
95            .map_err(|e| color_eyre::eyre::eyre!("Failed to connect to ChromeDriver: {}", e))?;
96
97        Ok(Self {
98            client,
99            _chromedriver: chromedriver_process,
100        })
101    }
102
103    /// Navigate to a URL
104    pub async fn goto(&self, url: &str) -> Result<()> {
105        self.client
106            .goto(url)
107            .await
108            .map_err(|e| color_eyre::eyre::eyre!("Failed to navigate: {}", e))?;
109
110        // Inject bypass script after navigation
111        if !url.starts_with("file://") {
112            let _ = self
113                .client
114                .execute(COMPREHENSIVE_BOOTSTRAP, vec![])
115                .await;
116        }
117
118        Ok(())
119    }
120
121    /// Capture a screenshot of a URL
122    ///
123    /// Returns PNG bytes
124    pub async fn capture(&self, url: &str, _width: u32, _height: u32) -> Result<Vec<u8>> {
125        // Inject bypass script before navigation
126        if !url.starts_with("file://") {
127            let _ = self
128                .client
129                .execute(COMPREHENSIVE_BOOTSTRAP, vec![])
130                .await;
131        }
132
133        self.goto(url).await?;
134
135        // Wait for page load
136        tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
137
138        // Inject again after page load (some sites re-check)
139        if !url.starts_with("file://") {
140            let _ = self
141                .client
142                .execute(COMPREHENSIVE_BOOTSTRAP, vec![])
143                .await;
144        }
145
146        // Capture screenshot
147        let screenshot_b64 = self
148            .client
149            .screenshot()
150            .await
151            .map_err(|e| color_eyre::eyre::eyre!("Failed to capture screenshot: {}", e))?;
152
153        // Decode base64
154        let screenshot_bytes = base64::prelude::BASE64_STANDARD
155            .decode(&screenshot_b64)
156            .map_err(|e| color_eyre::eyre::eyre!("Failed to decode screenshot: {}", e))?;
157
158        Ok(screenshot_bytes)
159    }
160
161    /// Get the underlying fantoccini client for advanced usage
162    pub fn client(&self) -> &fantoccini::Client {
163        &self.client
164    }
165
166    /// Close the browser
167    pub async fn close(mut self) -> Result<()> {
168        let _ = self.client.close().await;
169        let _ = self._chromedriver.kill();
170        Ok(())
171    }
172}