#![cfg(feature = "browser")]
use crate::challenge::{Action, ChallengeKind, ChallengeSignal, DetectionInput, MitigationPolicy};
use crate::events::{EventSink, NoopEventSink, ScraperEvent};
use crate::geo::{CountryCode, GeoResolver, Locale};
use crate::profile::BrowserProfile;
use crate::proxy::TlsSpoofingProxy;
use crate::proxy_pool::{ProxyPool, RotationStrategy};
use crate::solver::GenericSolver;
use crate::state::{DomainState, Outcome, StateStore};
use crate::stealth::generate_stealth_js;
use headless_chrome::{Browser, LaunchOptions};
use std::ffi::OsString;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::Error;
const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(30);
const RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(300);
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn build_impersonation_client(
profile: &BrowserProfile,
upstream: Option<&str>,
) -> Result<wreq::Client, Error> {
let mut builder = wreq::Client::builder();
if profile.user_agent.contains("Chrome/120") && profile.platform.contains("Win") {
builder = builder.emulation(wreq_util::Emulation::Chrome120);
} else if profile.user_agent.contains("Safari") && !profile.user_agent.contains("Chrome") {
builder = builder.emulation(wreq_util::Emulation::Safari17_2_1);
} else {
builder = builder.emulation(wreq_util::Emulation::Chrome120);
}
if let Some(upstream) = upstream {
builder = builder.proxy(wreq::Proxy::all(upstream)?);
}
Ok(builder.timeout(UPSTREAM_TIMEOUT).build()?)
}
fn redact_proxy_url(url: &str) -> String {
if let Ok(mut parsed) = wreq::Url::parse(url) {
if !parsed.username().is_empty() || parsed.password().is_some() {
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
}
return parsed.to_string();
}
match url.split_once('@') {
Some((_userinfo, rest)) => format!("***@{rest}"),
None => url.to_string(),
}
}
fn resolve_locale(
country: Option<CountryCode>,
url: Option<&str>,
resolver: Option<&Arc<dyn GeoResolver>>,
) -> Option<Locale> {
let country = country.or_else(|| resolver?.country_of(url?))?;
Locale::for_country(country)
}
fn launch_browser(
profile: &BrowserProfile,
proxy_port: Option<u16>,
direct_upstream: Option<&str>,
headless: bool,
debug: bool,
) -> Result<Browser, Error> {
let mut args = vec![
OsString::from("--disable-blink-features=AutomationControlled"),
OsString::from(format!("--user-agent={}", profile.user_agent)),
OsString::from(format!("--accept-lang={}", profile.accept_language)),
OsString::from("--disable-gpu"),
OsString::from("--no-sandbox"),
OsString::from("--disable-dev-shm-usage"),
];
if let Some(port) = proxy_port {
args.push(OsString::from(format!(
"--proxy-server=http://127.0.0.1:{port}"
)));
args.push(OsString::from("--proxy-bypass-list=<-loopback>"));
if debug {
log::debug!("browser args: {args:?}");
}
args.push(OsString::from("--ignore-certificate-errors")); } else if let Some(upstream) = direct_upstream {
args.push(OsString::from(format!("--proxy-server={upstream}")));
}
let launch_options = LaunchOptions::default_builder()
.headless(headless)
.window_size(Some((profile.viewport_width, profile.viewport_height)))
.idle_browser_timeout(BROWSER_IDLE_TIMEOUT)
.args(args.iter().map(|s| s.as_os_str()).collect())
.build()
.map_err(|e| Error::BrowserError(format!("Failed to build launch options: {e}")))?;
Browser::new(launch_options)
.map_err(|e| Error::BrowserError(format!("Failed to launch browser: {e}")))
}
const BROWSER_IDLE_TIMEOUT: Duration = Duration::from_secs(60 * 60 * 24 * 365 * 10);
pub struct CloudScraper {
pub profile: BrowserProfile,
pub proxy: Option<Arc<TlsSpoofingProxy>>,
browser: Browser,
policy: MitigationPolicy,
pool: Mutex<ProxyPool>,
store: Option<Arc<dyn StateStore>>,
events: Arc<dyn EventSink>,
geo_resolver: Option<Arc<dyn GeoResolver>>,
locale: Mutex<Option<Locale>>,
headless: bool,
debug_mode: bool,
}
pub struct CloudScraperBuilder {
profile: Option<BrowserProfile>,
use_tls_proxy: bool,
debug_mode: bool,
headless: bool,
proxies: Vec<(String, Option<CountryCode>)>,
rotation_strategy: RotationStrategy,
max_challenge_attempts: u32,
state_store: Option<Arc<dyn StateStore>>,
event_sink: Option<Arc<dyn EventSink>>,
geo_resolver: Option<Arc<dyn GeoResolver>>,
}
impl Default for CloudScraperBuilder {
fn default() -> Self {
Self::new()
}
}
impl CloudScraperBuilder {
pub fn new() -> Self {
Self {
profile: None,
use_tls_proxy: true,
debug_mode: false,
headless: true,
proxies: Vec::new(),
rotation_strategy: RotationStrategy::default(),
max_challenge_attempts: MitigationPolicy::default().max_attempts,
state_store: None,
event_sink: None,
geo_resolver: None,
}
}
pub fn profile(mut self, profile: BrowserProfile) -> Self {
self.profile = Some(profile);
self
}
pub fn disable_proxy(mut self) -> Self {
self.use_tls_proxy = false;
self
}
pub fn with_debug(mut self, debug: bool) -> Self {
self.debug_mode = debug;
self
}
pub fn headless(mut self, headless: bool) -> Self {
self.headless = headless;
self
}
pub fn upstream_proxy(mut self, proxy: String) -> Self {
self.proxies.push((proxy, None));
self
}
pub fn with_proxies(mut self, proxies: impl IntoIterator<Item = String>) -> Self {
self.proxies
.extend(proxies.into_iter().map(|url| (url, None)));
self
}
pub fn with_geo_proxies(
mut self,
proxies: impl IntoIterator<Item = (String, CountryCode)>,
) -> Self {
self.proxies
.extend(proxies.into_iter().map(|(url, cc)| (url, Some(cc))));
self
}
pub fn with_geo_resolver(mut self, resolver: Arc<dyn GeoResolver>) -> Self {
self.geo_resolver = Some(resolver);
self
}
pub fn proxy_strategy(mut self, strategy: RotationStrategy) -> Self {
self.rotation_strategy = strategy;
self
}
pub fn with_max_challenge_attempts(mut self, attempts: u32) -> Self {
self.max_challenge_attempts = attempts;
self
}
pub fn with_state_store(mut self, store: Arc<dyn StateStore>) -> Self {
self.state_store = Some(store);
self
}
pub fn with_event_sink(mut self, sink: Arc<dyn EventSink>) -> Self {
self.event_sink = Some(sink);
self
}
pub async fn build(self) -> Result<CloudScraper, Error> {
tokio_rustls::rustls::crypto::ring::default_provider()
.install_default()
.ok();
let profile = self.profile.unwrap_or_else(BrowserProfile::random);
let pool = ProxyPool::with_endpoints(self.proxies.clone(), self.rotation_strategy);
let initial_upstream = pool.selected().map(str::to_owned);
let locale = resolve_locale(
pool.selected_country(),
initial_upstream.as_deref(),
self.geo_resolver.as_ref(),
);
let proxy = if self.use_tls_proxy {
let impersonate_client =
build_impersonation_client(&profile, initial_upstream.as_deref())?;
Some(TlsSpoofingProxy::start(impersonate_client, self.debug_mode).await?)
} else {
None
};
let browser = launch_browser(
&profile,
proxy.as_ref().map(TlsSpoofingProxy::port),
if proxy.is_none() {
initial_upstream.as_deref()
} else {
None
},
self.headless,
self.debug_mode,
)?;
let can_rotate_proxy = proxy.is_some() && pool.healthy_count() >= 2;
Ok(CloudScraper {
profile,
proxy: proxy.map(Arc::new),
browser,
policy: MitigationPolicy::new(self.max_challenge_attempts)
.with_proxy_rotation(can_rotate_proxy),
pool: Mutex::new(pool),
store: self.state_store,
events: self.event_sink.unwrap_or_else(|| Arc::new(NoopEventSink)),
geo_resolver: self.geo_resolver,
locale: Mutex::new(locale),
headless: self.headless,
debug_mode: self.debug_mode,
})
}
}
impl CloudScraper {
pub fn builder() -> CloudScraperBuilder {
CloudScraperBuilder::new()
}
pub fn new_stealth_tab(&self) -> Result<Arc<headless_chrome::Tab>, Error> {
let tab = self
.browser
.new_tab()
.map_err(|e| Error::BrowserError(format!("Failed to create new tab: {:?}", e)))?;
let locale = self.locale.lock().expect("locale lock poisoned").clone();
let languages = match &locale {
Some(loc) => loc.languages.clone(),
None => crate::geo::languages_from_accept_language(&self.profile.accept_language),
};
let stealth_script = generate_stealth_js(&self.profile, &languages);
tab.call_method(
headless_chrome::protocol::cdp::Page::AddScriptToEvaluateOnNewDocument {
source: stealth_script,
world_name: None,
include_command_line_api: None,
run_immediately: None,
},
)
.map_err(|e| Error::BrowserError(format!("Failed to inject stealth script: {:?}", e)))?;
self.apply_locale_overrides(&tab, locale.as_ref())?;
Ok(tab)
}
fn apply_locale_overrides(
&self,
tab: &Arc<headless_chrome::Tab>,
locale: Option<&Locale>,
) -> Result<(), Error> {
let Some(locale) = locale else {
return Ok(());
};
tab.set_user_agent(
&self.profile.user_agent,
Some(&locale.accept_language),
Some(&self.profile.platform),
)
.map_err(|e| Error::BrowserError(format!("Failed to set Accept-Language: {e:?}")))?;
tab.call_method(
headless_chrome::protocol::cdp::Emulation::SetTimezoneOverride {
timezone_id: locale.timezone.clone(),
},
)
.map_err(|e| Error::BrowserError(format!("Failed to set timezone: {e:?}")))?;
tab.call_method(
headless_chrome::protocol::cdp::Emulation::SetLocaleOverride {
locale: Some(locale.primary_language().to_string()),
},
)
.map_err(|e| Error::BrowserError(format!("Failed to set locale: {e:?}")))?;
Ok(())
}
pub fn detect_challenge(
&self,
tab: &Arc<headless_chrome::Tab>,
) -> Result<ChallengeSignal, Error> {
let body = tab
.get_content()
.map_err(|e| Error::BrowserError(format!("Failed to read page content: {e:?}")))?;
Ok(crate::challenge::detect(&DetectionInput::from_body(&body)))
}
pub fn solve_challenge(
&self,
tab: &Arc<headless_chrome::Tab>,
) -> Result<ChallengeSignal, Error> {
let host = Self::tab_host(tab);
let host_ref = host.as_deref();
let mut attempt = 0u32;
let mut saw_challenge = false;
loop {
let signal = self.detect_challenge(tab)?;
if signal.is_challenge() {
self.events.emit(&ScraperEvent::ChallengeDetected {
host: host_ref,
kind: signal.kind,
});
}
match self.policy.decide(&signal, attempt) {
Action::Proceed => {
let outcome = if saw_challenge {
Outcome::Challenged
} else {
Outcome::Success
};
self.events.emit(&ScraperEvent::SolveSucceeded {
host: host_ref,
attempts: attempt,
challenged: saw_challenge,
});
self.record_for_host(host_ref, outcome)?;
return Ok(signal);
}
Action::Fail { reason } => {
let outcome = match signal.kind {
ChallengeKind::RateLimited => Outcome::RateLimited,
_ => Outcome::Blocked,
};
self.events.emit(&ScraperEvent::SolveFailed {
host: host_ref,
kind: signal.kind,
reason: &reason,
});
let _ = self.record_for_host(host_ref, outcome);
return Err(Error::Challenge(reason));
}
Action::Wait {
delay,
attempt: next,
} => {
saw_challenge = true;
if signal.kind == ChallengeKind::Turnstile {
let _ = GenericSolver::solve_cloudflare_turnstile(tab);
}
self.events.emit(&ScraperEvent::Waiting {
host: host_ref,
kind: signal.kind,
delay,
});
std::thread::sleep(delay);
attempt = next;
}
Action::RotateProxy { attempt: next } => {
saw_challenge = true;
if let Err(err) = self.rotate_proxy() {
let reason = match &err {
Error::Challenge(r) => r.clone(),
other => other.to_string(),
};
self.events.emit(&ScraperEvent::SolveFailed {
host: host_ref,
kind: signal.kind,
reason: &reason,
});
let _ = self.record_for_host(host_ref, Outcome::Blocked);
return Err(err);
}
let locale = self.locale.lock().expect("locale lock poisoned").clone();
self.apply_locale_overrides(tab, locale.as_ref())?;
let upstream = self
.pool
.lock()
.expect("proxy pool lock poisoned")
.selected()
.map(redact_proxy_url);
self.events.emit(&ScraperEvent::ProxyRotated {
host: host_ref,
upstream: upstream.as_deref(),
});
tab.reload(true, None)
.map_err(|e| Error::BrowserError(format!("Reload failed: {e:?}")))?;
tab.wait_until_navigated().map_err(|e| {
Error::BrowserError(format!("Navigation after rotation failed: {e:?}"))
})?;
attempt = next;
}
}
}
}
pub fn domain_state(&self, host: &str) -> Result<Option<DomainState>, Error> {
match &self.store {
Some(store) => store.get(host),
None => Ok(None),
}
}
pub fn record_outcome(&self, host: &str, outcome: Outcome) -> Result<(), Error> {
let Some(store) = &self.store else {
return Ok(());
};
let now = now_unix();
let proxy = self
.pool
.lock()
.expect("proxy pool lock poisoned")
.selected()
.map(redact_proxy_url);
store.update(host, &mut |current| {
current.record(outcome, proxy.clone(), now, RATE_LIMIT_COOLDOWN)
})?;
Ok(())
}
pub fn cooldown_remaining(&self, host: &str) -> Result<Option<Duration>, Error> {
Ok(self
.domain_state(host)?
.and_then(|state| state.cooldown_remaining(now_unix())))
}
fn record_for_host(&self, host: Option<&str>, outcome: Outcome) -> Result<(), Error> {
match host {
Some(host) => self.record_outcome(host, outcome),
None => {
if self.store.is_some() {
log::debug!(
"skipping per-host state record ({outcome:?}): current tab URL has no host"
);
}
Ok(())
}
}
}
fn tab_host(tab: &Arc<headless_chrome::Tab>) -> Option<String> {
wreq::Url::parse(&tab.get_url())
.ok()
.and_then(|url| url.host_str().map(str::to_owned))
}
fn rotate_proxy(&self) -> Result<(), Error> {
let proxy = self.proxy.as_ref().ok_or_else(|| {
Error::Challenge("proxy rotation requires the MITM proxy".to_string())
})?;
let (next, country) = {
let mut pool = self.pool.lock().expect("proxy pool lock poisoned");
let next = pool.rotate().ok_or_else(|| {
Error::Challenge("no healthy proxy left to rotate to".to_string())
})?;
(next, pool.selected_country())
};
let client = build_impersonation_client(&self.profile, Some(&next))?;
proxy.set_upstream_client(client);
let new_locale = resolve_locale(country, Some(&next), self.geo_resolver.as_ref());
*self.locale.lock().expect("locale lock poisoned") = new_locale;
Ok(())
}
pub fn rotate_profile(self) -> Result<CloudScraper, Error> {
self.rotate_profile_with(BrowserProfile::random())
}
pub fn rotate_profile_with(self, profile: BrowserProfile) -> Result<CloudScraper, Error> {
let (upstream, country) = {
let pool = self.pool.lock().expect("proxy pool lock poisoned");
(pool.selected().map(str::to_owned), pool.selected_country())
};
if let Some(proxy) = &self.proxy {
let client = build_impersonation_client(&profile, upstream.as_deref())?;
proxy.set_upstream_client(client);
}
let proxy_port = self.proxy.as_ref().map(|p| p.port());
let direct_upstream = if self.proxy.is_none() {
upstream.as_deref()
} else {
None
};
let browser = launch_browser(
&profile,
proxy_port,
direct_upstream,
self.headless,
self.debug_mode,
)?;
let locale = resolve_locale(country, upstream.as_deref(), self.geo_resolver.as_ref());
self.events.emit(&ScraperEvent::ProfileRotated {
user_agent: &profile.user_agent,
});
Ok(CloudScraper {
profile,
proxy: self.proxy,
browser,
policy: self.policy,
pool: self.pool,
store: self.store,
events: self.events,
geo_resolver: self.geo_resolver,
locale: Mutex::new(locale),
headless: self.headless,
debug_mode: self.debug_mode,
})
}
pub fn human_type_str(tab: &Arc<headless_chrome::Tab>, text: &str) -> Result<(), Error> {
for ch in text.chars() {
let delay = crate::behavior::calculate_typing_delay();
std::thread::sleep(delay);
tab.type_str(&ch.to_string())
.map_err(|e| Error::InteractionError(format!("Failed to type char: {:?}", e)))?;
}
Ok(())
}
pub fn human_move_mouse(
tab: &Arc<headless_chrome::Tab>,
end_x: f64,
end_y: f64,
) -> Result<(), Error> {
let start = crate::behavior::Point { x: 100.0, y: 100.0 };
let end = crate::behavior::Point { x: end_x, y: end_y };
let path = crate::behavior::generate_mouse_path(start, end, 50);
for point in path {
tab.move_mouse_to_point(headless_chrome::browser::tab::point::Point {
x: point.x,
y: point.y,
})
.map_err(|e| Error::InteractionError(format!("Failed to move mouse: {:?}", e)))?;
std::thread::sleep(Duration::from_millis(5));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scraper_builder_default() {
let builder = CloudScraper::builder();
assert!(builder.use_tls_proxy);
assert!(builder.profile.is_none());
}
#[test]
fn test_scraper_builder_disable_proxy() {
let builder = CloudScraper::builder().disable_proxy();
assert!(!builder.use_tls_proxy);
}
#[test]
fn test_scraper_builder_with_profile() {
let profile = BrowserProfile::random();
let builder = CloudScraper::builder().profile(profile.clone());
let built_profile = builder.profile.unwrap();
assert_eq!(built_profile.user_agent, profile.user_agent);
}
#[test]
fn test_scraper_builder_default_trait() {
let builder = CloudScraperBuilder::default();
assert!(builder.use_tls_proxy);
}
#[test]
fn test_scraper_builder_default_challenge_attempts() {
let builder = CloudScraper::builder();
assert_eq!(
builder.max_challenge_attempts,
MitigationPolicy::default().max_attempts
);
}
#[test]
fn test_scraper_builder_with_max_challenge_attempts() {
let builder = CloudScraper::builder().with_max_challenge_attempts(7);
assert_eq!(builder.max_challenge_attempts, 7);
}
#[test]
fn test_scraper_builder_upstream_proxy_adds_to_pool() {
let builder = CloudScraper::builder()
.upstream_proxy("http://a:1".to_string())
.upstream_proxy("http://b:2".to_string());
let urls: Vec<&str> = builder.proxies.iter().map(|(u, _)| u.as_str()).collect();
assert_eq!(urls, vec!["http://a:1", "http://b:2"]);
assert!(builder.proxies.iter().all(|(_, c)| c.is_none()));
}
#[test]
fn test_scraper_builder_geo_proxies_carry_country() {
let de = CountryCode::new("DE").unwrap();
let builder = CloudScraper::builder().with_geo_proxies([("http://de:1".to_string(), de)]);
assert_eq!(builder.proxies, vec![("http://de:1".to_string(), Some(de))]);
}
#[test]
fn test_scraper_builder_geo_resolver_defaults_none_and_sets() {
assert!(CloudScraper::builder().geo_resolver.is_none());
struct FixedResolver;
impl crate::geo::GeoResolver for FixedResolver {
fn country_of(&self, _: &str) -> Option<CountryCode> {
CountryCode::new("FR")
}
}
let builder = CloudScraper::builder().with_geo_resolver(Arc::new(FixedResolver));
assert!(builder.geo_resolver.is_some());
}
#[test]
fn test_scraper_builder_with_proxies_and_strategy() {
let builder = CloudScraper::builder()
.with_proxies(["http://a:1".to_string(), "http://b:2".to_string()])
.proxy_strategy(RotationStrategy::Random);
assert_eq!(builder.proxies.len(), 2);
assert_eq!(builder.rotation_strategy, RotationStrategy::Random);
}
#[test]
fn test_scraper_builder_default_strategy_is_round_robin() {
let builder = CloudScraper::builder();
assert_eq!(builder.rotation_strategy, RotationStrategy::RoundRobin);
assert!(builder.proxies.is_empty());
}
#[test]
fn test_scraper_builder_state_store_defaults_none_and_sets() {
assert!(CloudScraper::builder().state_store.is_none());
let store: Arc<dyn StateStore> = Arc::new(crate::state::InMemoryStateStore::new());
let builder = CloudScraper::builder().with_state_store(store);
assert!(builder.state_store.is_some());
}
#[test]
fn redact_proxy_url_strips_credentials() {
assert_eq!(
redact_proxy_url("http://user:pass@proxy.example:8080"),
"http://proxy.example:8080/"
);
assert_eq!(
redact_proxy_url("http://proxy.example:8080"),
"http://proxy.example:8080/"
);
assert!(!redact_proxy_url("socks5://u:secret@1.2.3.4:1080").contains("secret"));
assert_eq!(redact_proxy_url("//u:pw@host:3128"), "***@host:3128");
}
#[test]
fn resolve_locale_prefers_tag_then_resolver_then_none() {
struct FrResolver;
impl crate::geo::GeoResolver for FrResolver {
fn country_of(&self, _: &str) -> Option<CountryCode> {
CountryCode::new("FR")
}
}
let resolver: Arc<dyn GeoResolver> = Arc::new(FrResolver);
let de = CountryCode::new("DE");
let loc = resolve_locale(de, Some("http://p"), Some(&resolver)).unwrap();
assert_eq!(loc.timezone, "Europe/Berlin");
let loc = resolve_locale(None, Some("http://p"), Some(&resolver)).unwrap();
assert_eq!(loc.country, CountryCode::new("FR").unwrap());
assert!(resolve_locale(None, Some("http://p"), None).is_none());
}
#[test]
fn test_scraper_builder_event_sink_defaults_none_and_sets() {
assert!(CloudScraper::builder().event_sink.is_none());
let sink: Arc<dyn EventSink> = Arc::new(crate::events::LogEventSink);
let builder = CloudScraper::builder().with_event_sink(sink);
assert!(builder.event_sink.is_some());
}
fn profile_with_ua(user_agent: &str) -> BrowserProfile {
BrowserProfile {
user_agent: user_agent.to_string(),
platform: "Win32".to_string(),
hardware_concurrency: 8,
device_memory: 16,
webgl_vendor: "Google Inc. (NVIDIA)".to_string(),
webgl_renderer: "ANGLE (NVIDIA)".to_string(),
viewport_width: 1280,
viewport_height: 800,
accept_language: "en-US,en;q=0.9".to_string(),
}
}
fn write_temp_html(name: &str, html: &str) -> String {
let path = std::env::temp_dir().join(name);
std::fs::write(&path, html).expect("write temp html");
format!("file://{}", path.display())
}
#[cfg(feature = "browser")]
#[tokio::test]
async fn test_state_methods_record_and_read() {
let store: Arc<dyn StateStore> = Arc::new(crate::state::InMemoryStateStore::new());
let scraper = CloudScraper::builder()
.disable_proxy()
.headless(true)
.profile(profile_with_ua("UA-STATE"))
.with_state_store(Arc::clone(&store))
.build()
.await
.expect("Failed to build scraper");
assert!(scraper.domain_state("example.com").unwrap().is_none());
scraper
.record_outcome("example.com", Outcome::RateLimited)
.unwrap();
let state = scraper.domain_state("example.com").unwrap().unwrap();
assert_eq!(state.failures, 1);
assert_eq!(state.last_outcome, Some(Outcome::RateLimited));
assert!(scraper.cooldown_remaining("example.com").unwrap().is_some());
scraper
.record_outcome("example.com", Outcome::Success)
.unwrap();
assert!(scraper.cooldown_remaining("example.com").unwrap().is_none());
}
#[cfg(feature = "browser")]
#[tokio::test]
async fn test_solve_challenge_clean_page_succeeds() {
let scraper = CloudScraper::builder()
.disable_proxy()
.headless(true)
.profile(profile_with_ua("UA-CLEAN"))
.build()
.await
.expect("Failed to build scraper");
let tab = scraper.new_stealth_tab().expect("new tab");
let url = write_temp_html(
"rscs_clean.html",
"<html><body>perfectly ordinary content</body></html>",
);
tab.navigate_to(&url).expect("navigate");
tab.wait_until_navigated().expect("navigated");
let signal = scraper.solve_challenge(&tab).expect("solve");
assert_eq!(signal.kind, ChallengeKind::None);
}
#[cfg(feature = "browser")]
#[tokio::test]
async fn test_solve_challenge_turnstile_exhausts_and_fails() {
let scraper = CloudScraper::builder()
.disable_proxy()
.headless(true)
.with_max_challenge_attempts(1)
.profile(profile_with_ua("UA-CHAL"))
.build()
.await
.expect("Failed to build scraper");
let tab = scraper.new_stealth_tab().expect("new tab");
let url = write_temp_html(
"rscs_turnstile.html",
"<html><body><div class=\"cf-turnstile\" style=\"width:300px;height:65px;\"></div></body></html>",
);
tab.navigate_to(&url).expect("navigate");
tab.wait_until_navigated().expect("navigated");
let result = scraper.solve_challenge(&tab);
assert!(matches!(result, Err(Error::Challenge(_))));
}
#[cfg(feature = "browser")]
#[tokio::test]
async fn test_rotate_profile_swaps_identity_and_relaunches() {
let scraper = CloudScraper::builder()
.disable_proxy()
.headless(true)
.profile(profile_with_ua("UA-BEFORE"))
.build()
.await
.expect("Failed to build scraper");
assert_eq!(scraper.profile.user_agent, "UA-BEFORE");
let scraper = scraper
.rotate_profile_with(profile_with_ua("UA-AFTER"))
.expect("Failed to rotate profile");
assert_eq!(scraper.profile.user_agent, "UA-AFTER");
let _tab = scraper
.new_stealth_tab()
.expect("new tab after rotation failed");
}
#[cfg(feature = "browser")]
#[test]
fn test_human_interactions() {
let browser = headless_chrome::Browser::default().expect("Failed to launch");
let tab = browser.new_tab().expect("Failed to create tab");
let html_content = "<html><body><input id='test_input' type='text' /></body></html>";
let file_path = std::env::temp_dir().join("test_interactions.html");
std::fs::write(&file_path, html_content).expect("Failed to write mock HTML");
let file_url = format!("file://{}", file_path.display());
tab.navigate_to(&file_url).expect("Failed to navigate");
tab.wait_until_navigated().expect("Failed to wait");
let input = tab
.wait_for_element("#test_input")
.expect("Failed to find input");
input.click().expect("Failed to click input");
let type_res = CloudScraper::human_type_str(&tab, "test1234");
assert!(type_res.is_ok());
let move_res = CloudScraper::human_move_mouse(&tab, 50.0, 50.0);
assert!(move_res.is_ok());
}
}