Skip to main content

stealthscraper_rs/
scraper.rs

1#![cfg(feature = "browser")]
2
3use crate::challenge::{Action, ChallengeKind, ChallengeSignal, DetectionInput, MitigationPolicy};
4use crate::events::{EventSink, NoopEventSink, ScraperEvent};
5use crate::geo::{CountryCode, GeoResolver, Locale};
6use crate::profile::BrowserProfile;
7use crate::proxy::TlsSpoofingProxy;
8use crate::proxy_pool::{ProxyPool, RotationStrategy};
9use crate::solver::GenericSolver;
10use crate::state::{DomainState, Outcome, StateStore};
11use crate::stealth::generate_stealth_js;
12use headless_chrome::{Browser, LaunchOptions};
13use std::ffi::OsString;
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use crate::Error;
18
19/// Outbound request timeout for the impersonation client.
20const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(30);
21
22/// Cooldown applied to a host after it rate-limits us.
23const RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(300);
24
25/// Current Unix time in seconds (saturating to 0 before the epoch).
26fn now_unix() -> u64 {
27    SystemTime::now()
28        .duration_since(UNIX_EPOCH)
29        .map(|d| d.as_secs())
30        .unwrap_or(0)
31}
32
33/// Builds a `wreq` impersonation client for `profile`, optionally routed through
34/// an upstream proxy. Centralised so the initial build and proxy rotation stay
35/// in sync (identical JA4 emulation, only the egress proxy changes).
36fn build_impersonation_client(
37    profile: &BrowserProfile,
38    upstream: Option<&str>,
39) -> Result<wreq::Client, Error> {
40    let mut builder = wreq::Client::builder();
41
42    if profile.user_agent.contains("Chrome/120") && profile.platform.contains("Win") {
43        builder = builder.emulation(wreq_util::Emulation::Chrome120);
44    } else if profile.user_agent.contains("Safari") && !profile.user_agent.contains("Chrome") {
45        builder = builder.emulation(wreq_util::Emulation::Safari17_2_1);
46    } else {
47        builder = builder.emulation(wreq_util::Emulation::Chrome120);
48    }
49
50    if let Some(upstream) = upstream {
51        builder = builder.proxy(wreq::Proxy::all(upstream)?);
52    }
53
54    Ok(builder.timeout(UPSTREAM_TIMEOUT).build()?)
55}
56
57/// Strip any `user:password@` userinfo from a proxy URL so credentials are never
58/// written to logs, events, or the persisted state store.
59///
60/// The real (credentialed) URL is only ever handed to the `wreq` client for the
61/// actual connection; everything observable is redacted.
62fn redact_proxy_url(url: &str) -> String {
63    if let Ok(mut parsed) = wreq::Url::parse(url) {
64        if !parsed.username().is_empty() || parsed.password().is_some() {
65            let _ = parsed.set_username("");
66            let _ = parsed.set_password(None);
67        }
68        return parsed.to_string();
69    }
70    // Unparseable: best-effort drop of anything before an '@' (possible userinfo).
71    match url.split_once('@') {
72        Some((_userinfo, rest)) => format!("***@{rest}"),
73        None => url.to_string(),
74    }
75}
76
77/// Resolve the locale for a proxy: prefer its explicit country tag, else ask the
78/// optional [`GeoResolver`], then map the country to a curated [`Locale`].
79fn resolve_locale(
80    country: Option<CountryCode>,
81    url: Option<&str>,
82    resolver: Option<&Arc<dyn GeoResolver>>,
83) -> Option<Locale> {
84    let country = country.or_else(|| resolver?.country_of(url?))?;
85    Locale::for_country(country)
86}
87
88/// Launch a headless Chrome instance for `profile`.
89///
90/// `proxy_port` points Chrome at the local MITM proxy (loopback); when absent,
91/// `direct_upstream` (if any) is used as Chrome's proxy directly. Shared by the
92/// initial build and profile rotation so both produce an identical launch.
93fn launch_browser(
94    profile: &BrowserProfile,
95    proxy_port: Option<u16>,
96    direct_upstream: Option<&str>,
97    headless: bool,
98    debug: bool,
99) -> Result<Browser, Error> {
100    let mut args = vec![
101        OsString::from("--disable-blink-features=AutomationControlled"),
102        OsString::from(format!("--user-agent={}", profile.user_agent)),
103        OsString::from(format!("--accept-lang={}", profile.accept_language)),
104        OsString::from("--disable-gpu"),
105        OsString::from("--no-sandbox"),
106        OsString::from("--disable-dev-shm-usage"),
107    ];
108
109    if let Some(port) = proxy_port {
110        args.push(OsString::from(format!(
111            "--proxy-server=http://127.0.0.1:{port}"
112        )));
113        args.push(OsString::from("--proxy-bypass-list=<-loopback>"));
114        if debug {
115            log::debug!("browser args: {args:?}");
116        }
117        args.push(OsString::from("--ignore-certificate-errors")); // accept our MITM cert
118    } else if let Some(upstream) = direct_upstream {
119        // MITM disabled but an upstream exists: bind Chrome to it directly.
120        args.push(OsString::from(format!("--proxy-server={upstream}")));
121    }
122
123    let launch_options = LaunchOptions::default_builder()
124        .headless(headless)
125        .window_size(Some((profile.viewport_width, profile.viewport_height)))
126        .idle_browser_timeout(BROWSER_IDLE_TIMEOUT)
127        .args(args.iter().map(|s| s.as_os_str()).collect())
128        .build()
129        .map_err(|e| Error::BrowserError(format!("Failed to build launch options: {e}")))?;
130
131    Browser::new(launch_options)
132        .map_err(|e| Error::BrowserError(format!("Failed to launch browser: {e}")))
133}
134
135/// `headless_chrome` idle timeout: how long the browser event loop will
136/// wait with no CDP traffic before it tears the browser down.
137///
138/// `CloudScraper` is held for the lifetime of a long-running daemon
139/// (e.g. an Arlo streaming bridge). After the initial authentication the
140/// browser is needed only sporadically — a token refresh, re-auth, or a
141/// Cloudflare challenge that may not occur for hours. The crate default
142/// (and the previous 120 s value here) kills Chrome during the first
143/// idle gap; the daemon then loses its TLS-spoofing proxy and cannot
144/// recover. We therefore keep the browser alive for the process
145/// lifetime. The value is large but well within `Instant` range on all
146/// supported platforms (10 years ≈ 3.2e17 ns ≪ i64::MAX ns), so the
147/// underlying `recv_timeout` cannot overflow.
148const BROWSER_IDLE_TIMEOUT: Duration = Duration::from_secs(60 * 60 * 24 * 365 * 10);
149
150/// The main entry point for managing a stealthy browser instance.
151///
152/// `CloudScraper` wraps a `headless_chrome::Browser` and injects stealth configurations
153/// (via `BrowserProfile` and stealth JavaScript scripts) to make scraping tasks highly
154/// undetectable by modern bot-protection systems.
155pub struct CloudScraper {
156    /// The browser profile (fingerprint) being used.
157    pub profile: BrowserProfile,
158    /// The local TLS MITM proxy instance (kept alive with the scraper)
159    pub proxy: Option<Arc<TlsSpoofingProxy>>,
160    /// The underlying headless_chrome browser instance.
161    browser: Browser,
162    /// Policy governing how detected challenges are retried.
163    policy: MitigationPolicy,
164    /// Rotatable pool of upstream egress proxies.
165    pool: Mutex<ProxyPool>,
166    /// Optional persistent per-domain state store.
167    store: Option<Arc<dyn StateStore>>,
168    /// Observability sink for scrape events.
169    events: Arc<dyn EventSink>,
170    /// Optional resolver for a proxy's exit country (used when untagged).
171    geo_resolver: Option<Arc<dyn GeoResolver>>,
172    /// Locale currently applied to new tabs, derived from the selected proxy.
173    locale: Mutex<Option<Locale>>,
174    /// Whether the browser runs headless (retained for profile-rotation relaunch).
175    headless: bool,
176    /// Whether proxy debug logging is on (retained for profile-rotation relaunch).
177    debug_mode: bool,
178}
179
180/// Builder pattern for orchestrating a new `CloudScraper` instance.
181pub struct CloudScraperBuilder {
182    profile: Option<BrowserProfile>,
183    use_tls_proxy: bool,
184    debug_mode: bool,
185    headless: bool,
186    proxies: Vec<(String, Option<CountryCode>)>,
187    rotation_strategy: RotationStrategy,
188    max_challenge_attempts: u32,
189    state_store: Option<Arc<dyn StateStore>>,
190    event_sink: Option<Arc<dyn EventSink>>,
191    geo_resolver: Option<Arc<dyn GeoResolver>>,
192}
193
194impl Default for CloudScraperBuilder {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200impl CloudScraperBuilder {
201    /// Creates a fresh CloudScraper configuration payload.
202    pub fn new() -> Self {
203        Self {
204            profile: None,
205            use_tls_proxy: true,
206            debug_mode: false,
207            headless: true,
208            proxies: Vec::new(),
209            rotation_strategy: RotationStrategy::default(),
210            max_challenge_attempts: MitigationPolicy::default().max_attempts,
211            state_store: None,
212            event_sink: None,
213            geo_resolver: None,
214        }
215    }
216
217    /// Attaches a specific hardware/browser fingerprint to be emulated.
218    pub fn profile(mut self, profile: BrowserProfile) -> Self {
219        self.profile = Some(profile);
220        self
221    }
222
223    /// Disables the bundled TLS JA4 spoofing proxy. Be warned, you will get blocked by edge firewalls.
224    pub fn disable_proxy(mut self) -> Self {
225        self.use_tls_proxy = false;
226        self
227    }
228
229    /// Hooks debug stdout tracing prints onto the bundled internal proxy.
230    pub fn with_debug(mut self, debug: bool) -> Self {
231        self.debug_mode = debug;
232        self
233    }
234
235    /// Determines whether the Chrome window should be visually hidden (default: true).
236    pub fn headless(mut self, headless: bool) -> Self {
237        self.headless = headless;
238        self
239    }
240
241    /// Funnels traffic through an upstream HTTP/SOCKS proxy (e.g., `http://username:password@proxy:port`).
242    ///
243    /// Adds a single proxy to the rotation pool; call repeatedly or use
244    /// [`Self::with_proxies`] to register several.
245    pub fn upstream_proxy(mut self, proxy: String) -> Self {
246        self.proxies.push((proxy, None));
247        self
248    }
249
250    /// Registers a pool of upstream proxies that rotation can switch between when
251    /// the current egress IP gets hard-blocked.
252    pub fn with_proxies(mut self, proxies: impl IntoIterator<Item = String>) -> Self {
253        self.proxies
254            .extend(proxies.into_iter().map(|url| (url, None)));
255        self
256    }
257
258    /// Registers upstream proxies tagged with their exit country, enabling
259    /// proxy-led locale derivation (Accept-Language, `navigator.languages`, and
260    /// timezone are matched to the selected proxy's country).
261    pub fn with_geo_proxies(
262        mut self,
263        proxies: impl IntoIterator<Item = (String, CountryCode)>,
264    ) -> Self {
265        self.proxies
266            .extend(proxies.into_iter().map(|(url, cc)| (url, Some(cc))));
267        self
268    }
269
270    /// Sets a resolver used to discover a proxy's exit country when it was not
271    /// tagged explicitly (e.g. a GeoIP-backed implementation).
272    pub fn with_geo_resolver(mut self, resolver: Arc<dyn GeoResolver>) -> Self {
273        self.geo_resolver = Some(resolver);
274        self
275    }
276
277    /// Selects how the pool picks the next proxy on rotation (default: round-robin).
278    pub fn proxy_strategy(mut self, strategy: RotationStrategy) -> Self {
279        self.rotation_strategy = strategy;
280        self
281    }
282
283    /// Sets how many times a detected challenge is waited-out/re-checked before failing.
284    pub fn with_max_challenge_attempts(mut self, attempts: u32) -> Self {
285        self.max_challenge_attempts = attempts;
286        self
287    }
288
289    /// Attaches a persistent per-domain state store (e.g. `InMemoryStateStore` or,
290    /// with the `persistence` feature, `RedbStateStore`). When set, outcomes are
291    /// recorded automatically by [`CloudScraper::solve_challenge`].
292    pub fn with_state_store(mut self, store: Arc<dyn StateStore>) -> Self {
293        self.state_store = Some(store);
294        self
295    }
296
297    /// Attaches an observability sink (e.g. `LogEventSink`, or your own) that
298    /// receives [`ScraperEvent`]s during [`CloudScraper::solve_challenge`].
299    pub fn with_event_sink(mut self, sink: Arc<dyn EventSink>) -> Self {
300        self.event_sink = Some(sink);
301        self
302    }
303
304    /// Assembles the configuration, spawns the proxy (if enabled), and launches the headless Chrome thread natively.
305    pub async fn build(self) -> Result<CloudScraper, Error> {
306        // Rustls 0.23+ requires an explicitly installed crypto provider process-wide before any TLS builder is accessed.
307        // We use .ok() to ignore the error if it was already installed safely.
308        tokio_rustls::rustls::crypto::ring::default_provider()
309            .install_default()
310            .ok();
311
312        let profile = self.profile.unwrap_or_else(BrowserProfile::random);
313
314        // Assemble the rotatable upstream-proxy pool and pick the initial egress.
315        let pool = ProxyPool::with_endpoints(self.proxies.clone(), self.rotation_strategy);
316        let initial_upstream = pool.selected().map(str::to_owned);
317
318        // Proxy-led locale: derive the browser locale from the selected proxy's
319        // country so the IP and the browser's language/timezone tell one story.
320        let locale = resolve_locale(
321            pool.selected_country(),
322            initial_upstream.as_deref(),
323            self.geo_resolver.as_ref(),
324        );
325
326        let proxy = if self.use_tls_proxy {
327            let impersonate_client =
328                build_impersonation_client(&profile, initial_upstream.as_deref())?;
329            // Start the local TLS proxy
330            Some(TlsSpoofingProxy::start(impersonate_client, self.debug_mode).await?)
331        } else {
332            None
333        };
334
335        let browser = launch_browser(
336            &profile,
337            proxy.as_ref().map(TlsSpoofingProxy::port),
338            if proxy.is_none() {
339                initial_upstream.as_deref()
340            } else {
341                None
342            },
343            self.headless,
344            self.debug_mode,
345        )?;
346
347        // Rotation needs the MITM proxy (to swap the egress client) and at least
348        // one fallback proxy to switch to.
349        let can_rotate_proxy = proxy.is_some() && pool.healthy_count() >= 2;
350
351        Ok(CloudScraper {
352            profile,
353            proxy: proxy.map(Arc::new),
354            browser,
355            policy: MitigationPolicy::new(self.max_challenge_attempts)
356                .with_proxy_rotation(can_rotate_proxy),
357            pool: Mutex::new(pool),
358            store: self.state_store,
359            events: self.event_sink.unwrap_or_else(|| Arc::new(NoopEventSink)),
360            geo_resolver: self.geo_resolver,
361            locale: Mutex::new(locale),
362            headless: self.headless,
363            debug_mode: self.debug_mode,
364        })
365    }
366}
367
368impl CloudScraper {
369    /// Start building a `CloudScraper` instance.
370    pub fn builder() -> CloudScraperBuilder {
371        CloudScraperBuilder::new()
372    }
373
374    /// Creates a new stealthy tab ready for navigation.
375    ///
376    /// Injects the stealth script (with `navigator.languages` matching the active
377    /// locale) and applies the locale's Accept-Language/timezone/locale via CDP so
378    /// the browser's geo signals stay coherent with the egress proxy's country.
379    pub fn new_stealth_tab(&self) -> Result<Arc<headless_chrome::Tab>, Error> {
380        let tab = self
381            .browser
382            .new_tab()
383            .map_err(|e| Error::BrowserError(format!("Failed to create new tab: {:?}", e)))?;
384
385        let locale = self.locale.lock().expect("locale lock poisoned").clone();
386        let languages = match &locale {
387            Some(loc) => loc.languages.clone(),
388            None => crate::geo::languages_from_accept_language(&self.profile.accept_language),
389        };
390
391        // Inject our stealth script to override navigator, WebGL, languages, etc.
392        let stealth_script = generate_stealth_js(&self.profile, &languages);
393
394        tab.call_method(
395            headless_chrome::protocol::cdp::Page::AddScriptToEvaluateOnNewDocument {
396                source: stealth_script,
397                world_name: None,
398                include_command_line_api: None,
399                run_immediately: None,
400            },
401        )
402        .map_err(|e| Error::BrowserError(format!("Failed to inject stealth script: {:?}", e)))?;
403
404        self.apply_locale_overrides(&tab, locale.as_ref())?;
405
406        Ok(tab)
407    }
408
409    /// Applies the locale's Accept-Language, timezone, and locale to `tab` via CDP.
410    ///
411    /// These `Emulation`/`Network` overrides persist for the tab's session (they
412    /// survive reloads), so this is called once at tab creation and again after a
413    /// proxy rotation that changes the egress country. A `None` locale leaves the
414    /// browser defaults untouched.
415    fn apply_locale_overrides(
416        &self,
417        tab: &Arc<headless_chrome::Tab>,
418        locale: Option<&Locale>,
419    ) -> Result<(), Error> {
420        let Some(locale) = locale else {
421            return Ok(());
422        };
423
424        tab.set_user_agent(
425            &self.profile.user_agent,
426            Some(&locale.accept_language),
427            Some(&self.profile.platform),
428        )
429        .map_err(|e| Error::BrowserError(format!("Failed to set Accept-Language: {e:?}")))?;
430
431        tab.call_method(
432            headless_chrome::protocol::cdp::Emulation::SetTimezoneOverride {
433                timezone_id: locale.timezone.clone(),
434            },
435        )
436        .map_err(|e| Error::BrowserError(format!("Failed to set timezone: {e:?}")))?;
437
438        tab.call_method(
439            headless_chrome::protocol::cdp::Emulation::SetLocaleOverride {
440                locale: Some(locale.primary_language().to_string()),
441            },
442        )
443        .map_err(|e| Error::BrowserError(format!("Failed to set locale: {e:?}")))?;
444
445        Ok(())
446    }
447
448    /// Classifies the challenge (if any) currently rendered in `tab`.
449    ///
450    /// Detection runs over the tab's rendered DOM. HTTP status/headers are not
451    /// available from the DOM, so they are left unset — body markers are
452    /// sufficient to recognise Cloudflare's interstitial and Turnstile pages.
453    pub fn detect_challenge(
454        &self,
455        tab: &Arc<headless_chrome::Tab>,
456    ) -> Result<ChallengeSignal, Error> {
457        let body = tab
458            .get_content()
459            .map_err(|e| Error::BrowserError(format!("Failed to read page content: {e:?}")))?;
460        Ok(crate::challenge::detect(&DetectionInput::from_body(&body)))
461    }
462
463    /// Detects and attempts to clear any bot-protection challenge on `tab`.
464    ///
465    /// Loops according to the configured [`MitigationPolicy`]: it waits for
466    /// non-interactive challenges to auto-resolve in the real browser, and for
467    /// an interactive Turnstile it makes a best-effort click via [`GenericSolver`]
468    /// before waiting. Returns the final [`ChallengeSignal`] once the page is
469    /// clear, or [`Error::Challenge`] if the budget is exhausted or the page is
470    /// hard-blocked.
471    ///
472    /// # Blocking
473    ///
474    /// Like the rest of this CDP-driven API, this is a **synchronous, blocking**
475    /// call: it uses `std::thread::sleep` for back-off and blocks on CDP I/O. On
476    /// an async runtime, call it from a blocking context
477    /// (`tokio::task::spawn_blocking`), and run the [`TlsSpoofingProxy`] on a
478    /// multi-threaded runtime — otherwise a back-off can starve the executor that
479    /// serves the proxy the page is loading through.
480    pub fn solve_challenge(
481        &self,
482        tab: &Arc<headless_chrome::Tab>,
483    ) -> Result<ChallengeSignal, Error> {
484        let host = Self::tab_host(tab);
485        let host_ref = host.as_deref();
486        let mut attempt = 0u32;
487        let mut saw_challenge = false;
488        loop {
489            let signal = self.detect_challenge(tab)?;
490            if signal.is_challenge() {
491                self.events.emit(&ScraperEvent::ChallengeDetected {
492                    host: host_ref,
493                    kind: signal.kind,
494                });
495            }
496            match self.policy.decide(&signal, attempt) {
497                Action::Proceed => {
498                    let outcome = if saw_challenge {
499                        Outcome::Challenged
500                    } else {
501                        Outcome::Success
502                    };
503                    self.events.emit(&ScraperEvent::SolveSucceeded {
504                        host: host_ref,
505                        attempts: attempt,
506                        challenged: saw_challenge,
507                    });
508                    self.record_for_host(host_ref, outcome)?;
509                    return Ok(signal);
510                }
511                Action::Fail { reason } => {
512                    let outcome = match signal.kind {
513                        ChallengeKind::RateLimited => Outcome::RateLimited,
514                        _ => Outcome::Blocked,
515                    };
516                    self.events.emit(&ScraperEvent::SolveFailed {
517                        host: host_ref,
518                        kind: signal.kind,
519                        reason: &reason,
520                    });
521                    // Best-effort: keep the original challenge error if recording fails.
522                    let _ = self.record_for_host(host_ref, outcome);
523                    return Err(Error::Challenge(reason));
524                }
525                Action::Wait {
526                    delay,
527                    attempt: next,
528                } => {
529                    saw_challenge = true;
530                    if signal.kind == ChallengeKind::Turnstile {
531                        // Best-effort: click the interactive widget. A failure here
532                        // is non-fatal; the browser may still resolve on its own.
533                        let _ = GenericSolver::solve_cloudflare_turnstile(tab);
534                    }
535                    self.events.emit(&ScraperEvent::Waiting {
536                        host: host_ref,
537                        kind: signal.kind,
538                        delay,
539                    });
540                    std::thread::sleep(delay);
541                    attempt = next;
542                }
543                Action::RotateProxy { attempt: next } => {
544                    saw_challenge = true;
545                    // If the pool is exhausted, rotation fails: treat it as a
546                    // terminal block and record it like the `Fail` arm (so the
547                    // SolveFailed event and Outcome are still emitted), rather
548                    // than short-circuiting with an unrecorded error.
549                    if let Err(err) = self.rotate_proxy() {
550                        let reason = match &err {
551                            Error::Challenge(r) => r.clone(),
552                            other => other.to_string(),
553                        };
554                        self.events.emit(&ScraperEvent::SolveFailed {
555                            host: host_ref,
556                            kind: signal.kind,
557                            reason: &reason,
558                        });
559                        let _ = self.record_for_host(host_ref, Outcome::Blocked);
560                        return Err(err);
561                    }
562                    // Re-apply the (possibly new-country) locale before reloading so
563                    // the refreshed request's geo signals match the new egress.
564                    let locale = self.locale.lock().expect("locale lock poisoned").clone();
565                    self.apply_locale_overrides(tab, locale.as_ref())?;
566                    // Redact credentials before the URL reaches the event sink/logs.
567                    let upstream = self
568                        .pool
569                        .lock()
570                        .expect("proxy pool lock poisoned")
571                        .selected()
572                        .map(redact_proxy_url);
573                    self.events.emit(&ScraperEvent::ProxyRotated {
574                        host: host_ref,
575                        upstream: upstream.as_deref(),
576                    });
577                    tab.reload(true, None)
578                        .map_err(|e| Error::BrowserError(format!("Reload failed: {e:?}")))?;
579                    tab.wait_until_navigated().map_err(|e| {
580                        Error::BrowserError(format!("Navigation after rotation failed: {e:?}"))
581                    })?;
582                    attempt = next;
583                }
584            }
585        }
586    }
587
588    /// Reads the persisted [`DomainState`] for `host`, if a store is configured
589    /// and a record exists.
590    pub fn domain_state(&self, host: &str) -> Result<Option<DomainState>, Error> {
591        match &self.store {
592            Some(store) => store.get(host),
593            None => Ok(None),
594        }
595    }
596
597    /// Records `outcome` for `host` in the configured state store (no-op if none).
598    ///
599    /// The current egress proxy is captured, and a [`Outcome::RateLimited`] sets a
600    /// cooldown so callers can back off via [`Self::cooldown_remaining`].
601    pub fn record_outcome(&self, host: &str, outcome: Outcome) -> Result<(), Error> {
602        let Some(store) = &self.store else {
603            return Ok(());
604        };
605        let now = now_unix();
606        // Redact credentials: the persisted `last_proxy` must never hold `user:pass@`.
607        let proxy = self
608            .pool
609            .lock()
610            .expect("proxy pool lock poisoned")
611            .selected()
612            .map(redact_proxy_url);
613        // Atomic read-modify-write so concurrent records on a shared scraper don't
614        // lose updates.
615        store.update(host, &mut |current| {
616            current.record(outcome, proxy.clone(), now, RATE_LIMIT_COOLDOWN)
617        })?;
618        Ok(())
619    }
620
621    /// Remaining rate-limit cooldown for `host`, if any.
622    pub fn cooldown_remaining(&self, host: &str) -> Result<Option<Duration>, Error> {
623        Ok(self
624            .domain_state(host)?
625            .and_then(|state| state.cooldown_remaining(now_unix())))
626    }
627
628    fn record_for_host(&self, host: Option<&str>, outcome: Outcome) -> Result<(), Error> {
629        match host {
630            Some(host) => self.record_outcome(host, outcome),
631            None => {
632                if self.store.is_some() {
633                    log::debug!(
634                        "skipping per-host state record ({outcome:?}): current tab URL has no host"
635                    );
636                }
637                Ok(())
638            }
639        }
640    }
641
642    fn tab_host(tab: &Arc<headless_chrome::Tab>) -> Option<String> {
643        wreq::Url::parse(&tab.get_url())
644            .ok()
645            .and_then(|url| url.host_str().map(str::to_owned))
646    }
647
648    /// Retires the current egress proxy and hot-swaps the MITM client to the next
649    /// healthy one in the pool. Returns [`Error::Challenge`] if no proxy remains.
650    fn rotate_proxy(&self) -> Result<(), Error> {
651        let proxy = self.proxy.as_ref().ok_or_else(|| {
652            Error::Challenge("proxy rotation requires the MITM proxy".to_string())
653        })?;
654
655        let (next, country) = {
656            let mut pool = self.pool.lock().expect("proxy pool lock poisoned");
657            let next = pool.rotate().ok_or_else(|| {
658                Error::Challenge("no healthy proxy left to rotate to".to_string())
659            })?;
660            (next, pool.selected_country())
661        };
662
663        let client = build_impersonation_client(&self.profile, Some(&next))?;
664        proxy.set_upstream_client(client);
665
666        // Proxy-led: the new egress may be in a different country, so re-derive
667        // the locale to keep the browser's geo signals coherent.
668        let new_locale = resolve_locale(country, Some(&next), self.geo_resolver.as_ref());
669        *self.locale.lock().expect("locale lock poisoned") = new_locale;
670        Ok(())
671    }
672
673    /// Rotates the browser fingerprint by relaunching Chrome under a fresh random
674    /// [`BrowserProfile`]. See [`Self::rotate_profile_with`].
675    pub fn rotate_profile(self) -> Result<CloudScraper, Error> {
676        self.rotate_profile_with(BrowserProfile::random())
677    }
678
679    /// Rotates the browser fingerprint to `profile`, **keeping the same egress IP**.
680    ///
681    /// Profile rotation cannot be done in place — the User-Agent and other launch
682    /// flags are fixed at process start — so this **relaunches Chrome** and returns
683    /// a fresh scraper, discarding the old browser and all its tabs/session state.
684    /// The MITM proxy (and its port) and the current upstream proxy are preserved;
685    /// only the impersonation client and browser are rebuilt for the new identity.
686    ///
687    /// Because it consumes `self`, it is necessarily caller-driven (it cannot run
688    /// inside the tab-scoped [`Self::solve_challenge`]). Use it when a site has
689    /// blocked the browser *identity* rather than the IP; for a burned IP the
690    /// automatic proxy rotation inside [`Self::solve_challenge`] handles it
691    /// without a relaunch.
692    pub fn rotate_profile_with(self, profile: BrowserProfile) -> Result<CloudScraper, Error> {
693        // Snapshot the current egress so the relaunched browser keeps the exit IP.
694        let (upstream, country) = {
695            let pool = self.pool.lock().expect("proxy pool lock poisoned");
696            (pool.selected().map(str::to_owned), pool.selected_country())
697        };
698
699        // Rebuild the impersonation client for the new fingerprint (same egress).
700        if let Some(proxy) = &self.proxy {
701            let client = build_impersonation_client(&profile, upstream.as_deref())?;
702            proxy.set_upstream_client(client);
703        }
704
705        // Relaunch Chrome on the same MITM port (or same direct upstream).
706        let proxy_port = self.proxy.as_ref().map(|p| p.port());
707        let direct_upstream = if self.proxy.is_none() {
708            upstream.as_deref()
709        } else {
710            None
711        };
712        let browser = launch_browser(
713            &profile,
714            proxy_port,
715            direct_upstream,
716            self.headless,
717            self.debug_mode,
718        )?;
719
720        // Egress is unchanged, but re-derive the locale defensively.
721        let locale = resolve_locale(country, upstream.as_deref(), self.geo_resolver.as_ref());
722
723        self.events.emit(&ScraperEvent::ProfileRotated {
724            user_agent: &profile.user_agent,
725        });
726
727        Ok(CloudScraper {
728            profile,
729            proxy: self.proxy,
730            browser,
731            policy: self.policy,
732            pool: self.pool,
733            store: self.store,
734            events: self.events,
735            geo_resolver: self.geo_resolver,
736            locale: Mutex::new(locale),
737            headless: self.headless,
738            debug_mode: self.debug_mode,
739        })
740    }
741
742    /// Types a string into the current focused element with human-like delays
743    pub fn human_type_str(tab: &Arc<headless_chrome::Tab>, text: &str) -> Result<(), Error> {
744        for ch in text.chars() {
745            let delay = crate::behavior::calculate_typing_delay();
746            std::thread::sleep(delay);
747            tab.type_str(&ch.to_string())
748                .map_err(|e| Error::InteractionError(format!("Failed to type char: {:?}", e)))?;
749        }
750        Ok(())
751    }
752
753    /// Moves the mouse to a target x,y using Bezier curves to evade bot detection
754    pub fn human_move_mouse(
755        tab: &Arc<headless_chrome::Tab>,
756        end_x: f64,
757        end_y: f64,
758    ) -> Result<(), Error> {
759        // Assume current mouse pos is 0,0 if unknown, or we could track it.
760        // For simplicity we just use a random nearby start point or default.
761        let start = crate::behavior::Point { x: 100.0, y: 100.0 };
762        let end = crate::behavior::Point { x: end_x, y: end_y };
763
764        // Calculate curve path (e.g., 50 intermediate points)
765        let path = crate::behavior::generate_mouse_path(start, end, 50);
766
767        for point in path {
768            tab.move_mouse_to_point(headless_chrome::browser::tab::point::Point {
769                x: point.x,
770                y: point.y,
771            })
772            .map_err(|e| Error::InteractionError(format!("Failed to move mouse: {:?}", e)))?;
773            // small sleep to simulate rendering/polling rate
774            std::thread::sleep(Duration::from_millis(5));
775        }
776
777        Ok(())
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    #[test]
786    fn test_scraper_builder_default() {
787        let builder = CloudScraper::builder();
788        assert!(builder.use_tls_proxy);
789        assert!(builder.profile.is_none());
790    }
791
792    #[test]
793    fn test_scraper_builder_disable_proxy() {
794        let builder = CloudScraper::builder().disable_proxy();
795        assert!(!builder.use_tls_proxy);
796    }
797
798    #[test]
799    fn test_scraper_builder_with_profile() {
800        let profile = BrowserProfile::random();
801        let builder = CloudScraper::builder().profile(profile.clone());
802
803        let built_profile = builder.profile.unwrap();
804        assert_eq!(built_profile.user_agent, profile.user_agent);
805    }
806
807    #[test]
808    fn test_scraper_builder_default_trait() {
809        let builder = CloudScraperBuilder::default();
810        assert!(builder.use_tls_proxy);
811    }
812
813    #[test]
814    fn test_scraper_builder_default_challenge_attempts() {
815        let builder = CloudScraper::builder();
816        assert_eq!(
817            builder.max_challenge_attempts,
818            MitigationPolicy::default().max_attempts
819        );
820    }
821
822    #[test]
823    fn test_scraper_builder_with_max_challenge_attempts() {
824        let builder = CloudScraper::builder().with_max_challenge_attempts(7);
825        assert_eq!(builder.max_challenge_attempts, 7);
826    }
827
828    #[test]
829    fn test_scraper_builder_upstream_proxy_adds_to_pool() {
830        let builder = CloudScraper::builder()
831            .upstream_proxy("http://a:1".to_string())
832            .upstream_proxy("http://b:2".to_string());
833        let urls: Vec<&str> = builder.proxies.iter().map(|(u, _)| u.as_str()).collect();
834        assert_eq!(urls, vec!["http://a:1", "http://b:2"]);
835        assert!(builder.proxies.iter().all(|(_, c)| c.is_none()));
836    }
837
838    #[test]
839    fn test_scraper_builder_geo_proxies_carry_country() {
840        let de = CountryCode::new("DE").unwrap();
841        let builder = CloudScraper::builder().with_geo_proxies([("http://de:1".to_string(), de)]);
842        assert_eq!(builder.proxies, vec![("http://de:1".to_string(), Some(de))]);
843    }
844
845    #[test]
846    fn test_scraper_builder_geo_resolver_defaults_none_and_sets() {
847        assert!(CloudScraper::builder().geo_resolver.is_none());
848
849        struct FixedResolver;
850        impl crate::geo::GeoResolver for FixedResolver {
851            fn country_of(&self, _: &str) -> Option<CountryCode> {
852                CountryCode::new("FR")
853            }
854        }
855        let builder = CloudScraper::builder().with_geo_resolver(Arc::new(FixedResolver));
856        assert!(builder.geo_resolver.is_some());
857    }
858
859    #[test]
860    fn test_scraper_builder_with_proxies_and_strategy() {
861        let builder = CloudScraper::builder()
862            .with_proxies(["http://a:1".to_string(), "http://b:2".to_string()])
863            .proxy_strategy(RotationStrategy::Random);
864        assert_eq!(builder.proxies.len(), 2);
865        assert_eq!(builder.rotation_strategy, RotationStrategy::Random);
866    }
867
868    #[test]
869    fn test_scraper_builder_default_strategy_is_round_robin() {
870        let builder = CloudScraper::builder();
871        assert_eq!(builder.rotation_strategy, RotationStrategy::RoundRobin);
872        assert!(builder.proxies.is_empty());
873    }
874
875    #[test]
876    fn test_scraper_builder_state_store_defaults_none_and_sets() {
877        assert!(CloudScraper::builder().state_store.is_none());
878
879        let store: Arc<dyn StateStore> = Arc::new(crate::state::InMemoryStateStore::new());
880        let builder = CloudScraper::builder().with_state_store(store);
881        assert!(builder.state_store.is_some());
882    }
883
884    #[test]
885    fn redact_proxy_url_strips_credentials() {
886        assert_eq!(
887            redact_proxy_url("http://user:pass@proxy.example:8080"),
888            "http://proxy.example:8080/"
889        );
890        // No credentials → unchanged host/port (modulo URL normalisation).
891        assert_eq!(
892            redact_proxy_url("http://proxy.example:8080"),
893            "http://proxy.example:8080/"
894        );
895        // SOCKS scheme credentials are also stripped.
896        assert!(!redact_proxy_url("socks5://u:secret@1.2.3.4:1080").contains("secret"));
897        // Unparseable-as-URL but with userinfo → still redacted via the fallback.
898        assert_eq!(redact_proxy_url("//u:pw@host:3128"), "***@host:3128");
899    }
900
901    #[test]
902    fn resolve_locale_prefers_tag_then_resolver_then_none() {
903        struct FrResolver;
904        impl crate::geo::GeoResolver for FrResolver {
905            fn country_of(&self, _: &str) -> Option<CountryCode> {
906                CountryCode::new("FR")
907            }
908        }
909        let resolver: Arc<dyn GeoResolver> = Arc::new(FrResolver);
910
911        // Explicit tag wins.
912        let de = CountryCode::new("DE");
913        let loc = resolve_locale(de, Some("http://p"), Some(&resolver)).unwrap();
914        assert_eq!(loc.timezone, "Europe/Berlin");
915
916        // No tag -> fall back to the resolver.
917        let loc = resolve_locale(None, Some("http://p"), Some(&resolver)).unwrap();
918        assert_eq!(loc.country, CountryCode::new("FR").unwrap());
919
920        // No tag and no resolver -> None.
921        assert!(resolve_locale(None, Some("http://p"), None).is_none());
922    }
923
924    #[test]
925    fn test_scraper_builder_event_sink_defaults_none_and_sets() {
926        assert!(CloudScraper::builder().event_sink.is_none());
927
928        let sink: Arc<dyn EventSink> = Arc::new(crate::events::LogEventSink);
929        let builder = CloudScraper::builder().with_event_sink(sink);
930        assert!(builder.event_sink.is_some());
931    }
932
933    fn profile_with_ua(user_agent: &str) -> BrowserProfile {
934        BrowserProfile {
935            user_agent: user_agent.to_string(),
936            platform: "Win32".to_string(),
937            hardware_concurrency: 8,
938            device_memory: 16,
939            webgl_vendor: "Google Inc. (NVIDIA)".to_string(),
940            webgl_renderer: "ANGLE (NVIDIA)".to_string(),
941            viewport_width: 1280,
942            viewport_height: 800,
943            accept_language: "en-US,en;q=0.9".to_string(),
944        }
945    }
946
947    fn write_temp_html(name: &str, html: &str) -> String {
948        let path = std::env::temp_dir().join(name);
949        std::fs::write(&path, html).expect("write temp html");
950        format!("file://{}", path.display())
951    }
952
953    #[cfg(feature = "browser")]
954    #[tokio::test]
955    async fn test_state_methods_record_and_read() {
956        let store: Arc<dyn StateStore> = Arc::new(crate::state::InMemoryStateStore::new());
957        let scraper = CloudScraper::builder()
958            .disable_proxy()
959            .headless(true)
960            .profile(profile_with_ua("UA-STATE"))
961            .with_state_store(Arc::clone(&store))
962            .build()
963            .await
964            .expect("Failed to build scraper");
965
966        assert!(scraper.domain_state("example.com").unwrap().is_none());
967
968        scraper
969            .record_outcome("example.com", Outcome::RateLimited)
970            .unwrap();
971        let state = scraper.domain_state("example.com").unwrap().unwrap();
972        assert_eq!(state.failures, 1);
973        assert_eq!(state.last_outcome, Some(Outcome::RateLimited));
974        assert!(scraper.cooldown_remaining("example.com").unwrap().is_some());
975
976        // A success clears the cooldown.
977        scraper
978            .record_outcome("example.com", Outcome::Success)
979            .unwrap();
980        assert!(scraper.cooldown_remaining("example.com").unwrap().is_none());
981    }
982
983    #[cfg(feature = "browser")]
984    #[tokio::test]
985    async fn test_solve_challenge_clean_page_succeeds() {
986        let scraper = CloudScraper::builder()
987            .disable_proxy()
988            .headless(true)
989            .profile(profile_with_ua("UA-CLEAN"))
990            .build()
991            .await
992            .expect("Failed to build scraper");
993
994        let tab = scraper.new_stealth_tab().expect("new tab");
995        let url = write_temp_html(
996            "rscs_clean.html",
997            "<html><body>perfectly ordinary content</body></html>",
998        );
999        tab.navigate_to(&url).expect("navigate");
1000        tab.wait_until_navigated().expect("navigated");
1001
1002        let signal = scraper.solve_challenge(&tab).expect("solve");
1003        assert_eq!(signal.kind, ChallengeKind::None);
1004    }
1005
1006    #[cfg(feature = "browser")]
1007    #[tokio::test]
1008    async fn test_solve_challenge_turnstile_exhausts_and_fails() {
1009        let scraper = CloudScraper::builder()
1010            .disable_proxy()
1011            .headless(true)
1012            .with_max_challenge_attempts(1)
1013            .profile(profile_with_ua("UA-CHAL"))
1014            .build()
1015            .await
1016            .expect("Failed to build scraper");
1017
1018        let tab = scraper.new_stealth_tab().expect("new tab");
1019        // A static Turnstile page never clears: exercises the Turnstile wait branch
1020        // (best-effort solver click) and the terminal failure.
1021        let url = write_temp_html(
1022            "rscs_turnstile.html",
1023            "<html><body><div class=\"cf-turnstile\" style=\"width:300px;height:65px;\"></div></body></html>",
1024        );
1025        tab.navigate_to(&url).expect("navigate");
1026        tab.wait_until_navigated().expect("navigated");
1027
1028        let result = scraper.solve_challenge(&tab);
1029        assert!(matches!(result, Err(Error::Challenge(_))));
1030    }
1031
1032    #[cfg(feature = "browser")]
1033    #[tokio::test]
1034    async fn test_rotate_profile_swaps_identity_and_relaunches() {
1035        let scraper = CloudScraper::builder()
1036            .disable_proxy()
1037            .headless(true)
1038            .profile(profile_with_ua("UA-BEFORE"))
1039            .build()
1040            .await
1041            .expect("Failed to build scraper");
1042        assert_eq!(scraper.profile.user_agent, "UA-BEFORE");
1043
1044        let scraper = scraper
1045            .rotate_profile_with(profile_with_ua("UA-AFTER"))
1046            .expect("Failed to rotate profile");
1047        assert_eq!(scraper.profile.user_agent, "UA-AFTER");
1048
1049        // The relaunched browser must be usable.
1050        let _tab = scraper
1051            .new_stealth_tab()
1052            .expect("new tab after rotation failed");
1053    }
1054
1055    #[cfg(feature = "browser")]
1056    #[test]
1057    fn test_human_interactions() {
1058        let browser = headless_chrome::Browser::default().expect("Failed to launch");
1059        let tab = browser.new_tab().expect("Failed to create tab");
1060
1061        let html_content = "<html><body><input id='test_input' type='text' /></body></html>";
1062        let file_path = std::env::temp_dir().join("test_interactions.html");
1063        std::fs::write(&file_path, html_content).expect("Failed to write mock HTML");
1064        let file_url = format!("file://{}", file_path.display());
1065
1066        tab.navigate_to(&file_url).expect("Failed to navigate");
1067        tab.wait_until_navigated().expect("Failed to wait");
1068
1069        let input = tab
1070            .wait_for_element("#test_input")
1071            .expect("Failed to find input");
1072        input.click().expect("Failed to click input");
1073
1074        // Test typing
1075        let type_res = CloudScraper::human_type_str(&tab, "test1234");
1076        assert!(type_res.is_ok());
1077
1078        // Test mouse move
1079        let move_res = CloudScraper::human_move_mouse(&tab, 50.0, 50.0);
1080        assert!(move_res.is_ok());
1081    }
1082}