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
19const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(30);
21
22const RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(300);
24
25fn now_unix() -> u64 {
27 SystemTime::now()
28 .duration_since(UNIX_EPOCH)
29 .map(|d| d.as_secs())
30 .unwrap_or(0)
31}
32
33fn 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
57fn 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 match url.split_once('@') {
72 Some((_userinfo, rest)) => format!("***@{rest}"),
73 None => url.to_string(),
74 }
75}
76
77fn 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
88fn 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")); } else if let Some(upstream) = direct_upstream {
119 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
135const BROWSER_IDLE_TIMEOUT: Duration = Duration::from_secs(60 * 60 * 24 * 365 * 10);
149
150pub struct CloudScraper {
156 pub profile: BrowserProfile,
158 pub proxy: Option<Arc<TlsSpoofingProxy>>,
160 browser: Browser,
162 policy: MitigationPolicy,
164 pool: Mutex<ProxyPool>,
166 store: Option<Arc<dyn StateStore>>,
168 events: Arc<dyn EventSink>,
170 geo_resolver: Option<Arc<dyn GeoResolver>>,
172 locale: Mutex<Option<Locale>>,
174 headless: bool,
176 debug_mode: bool,
178}
179
180pub 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 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 pub fn profile(mut self, profile: BrowserProfile) -> Self {
219 self.profile = Some(profile);
220 self
221 }
222
223 pub fn disable_proxy(mut self) -> Self {
225 self.use_tls_proxy = false;
226 self
227 }
228
229 pub fn with_debug(mut self, debug: bool) -> Self {
231 self.debug_mode = debug;
232 self
233 }
234
235 pub fn headless(mut self, headless: bool) -> Self {
237 self.headless = headless;
238 self
239 }
240
241 pub fn upstream_proxy(mut self, proxy: String) -> Self {
246 self.proxies.push((proxy, None));
247 self
248 }
249
250 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 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 pub fn with_geo_resolver(mut self, resolver: Arc<dyn GeoResolver>) -> Self {
273 self.geo_resolver = Some(resolver);
274 self
275 }
276
277 pub fn proxy_strategy(mut self, strategy: RotationStrategy) -> Self {
279 self.rotation_strategy = strategy;
280 self
281 }
282
283 pub fn with_max_challenge_attempts(mut self, attempts: u32) -> Self {
285 self.max_challenge_attempts = attempts;
286 self
287 }
288
289 pub fn with_state_store(mut self, store: Arc<dyn StateStore>) -> Self {
293 self.state_store = Some(store);
294 self
295 }
296
297 pub fn with_event_sink(mut self, sink: Arc<dyn EventSink>) -> Self {
300 self.event_sink = Some(sink);
301 self
302 }
303
304 pub async fn build(self) -> Result<CloudScraper, Error> {
306 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 let pool = ProxyPool::with_endpoints(self.proxies.clone(), self.rotation_strategy);
316 let initial_upstream = pool.selected().map(str::to_owned);
317
318 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 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 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 pub fn builder() -> CloudScraperBuilder {
371 CloudScraperBuilder::new()
372 }
373
374 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 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 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 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 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 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 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 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 let locale = self.locale.lock().expect("locale lock poisoned").clone();
565 self.apply_locale_overrides(tab, locale.as_ref())?;
566 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 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 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 let proxy = self
608 .pool
609 .lock()
610 .expect("proxy pool lock poisoned")
611 .selected()
612 .map(redact_proxy_url);
613 store.update(host, &mut |current| {
616 current.record(outcome, proxy.clone(), now, RATE_LIMIT_COOLDOWN)
617 })?;
618 Ok(())
619 }
620
621 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 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 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 pub fn rotate_profile(self) -> Result<CloudScraper, Error> {
676 self.rotate_profile_with(BrowserProfile::random())
677 }
678
679 pub fn rotate_profile_with(self, profile: BrowserProfile) -> Result<CloudScraper, Error> {
693 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 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 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 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 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 pub fn human_move_mouse(
755 tab: &Arc<headless_chrome::Tab>,
756 end_x: f64,
757 end_y: f64,
758 ) -> Result<(), Error> {
759 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 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 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 assert_eq!(
892 redact_proxy_url("http://proxy.example:8080"),
893 "http://proxy.example:8080/"
894 );
895 assert!(!redact_proxy_url("socks5://u:secret@1.2.3.4:1080").contains("secret"));
897 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 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 let loc = resolve_locale(None, Some("http://p"), Some(&resolver)).unwrap();
918 assert_eq!(loc.country, CountryCode::new("FR").unwrap());
919
920 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 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 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 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 let type_res = CloudScraper::human_type_str(&tab, "test1234");
1076 assert!(type_res.is_ok());
1077
1078 let move_res = CloudScraper::human_move_mouse(&tab, 50.0, 50.0);
1080 assert!(move_res.is_ok());
1081 }
1082}