Skip to main content

spider/
configuration.rs

1use crate::compact_str::CompactString;
2use crate::features::chrome_common::RequestInterceptConfiguration;
3pub use crate::features::chrome_common::{
4    AuthChallengeResponse, AuthChallengeResponseResponse, AutomationScripts, AutomationScriptsMap,
5    CaptureScreenshotFormat, CaptureScreenshotParams, ClipViewport, ExecutionScripts,
6    ExecutionScriptsMap, ScreenShotConfig, ScreenshotParams, Viewport, WaitFor, WaitForDelay,
7    WaitForIdleNetwork, WaitForSelector, WebAutomation,
8};
9pub use crate::features::gemini_common::GeminiConfigs;
10pub use crate::features::openai_common::GPTConfigs;
11#[cfg(feature = "search")]
12pub use crate::features::search::{
13    SearchError, SearchOptions, SearchResult, SearchResults, TimeRange,
14};
15pub use crate::features::webdriver_common::{WebDriverBrowser, WebDriverConfig};
16use crate::utils::get_domain_from_url;
17use crate::utils::BasicCachePolicy;
18use crate::website::CronType;
19use reqwest::header::{AsHeaderName, HeaderMap, HeaderName, HeaderValue, IntoHeaderName};
20use std::net::IpAddr;
21use std::sync::Arc;
22use std::time::Duration;
23
24#[cfg(feature = "chrome")]
25pub use spider_fingerprint::Fingerprint;
26
27/// Check if an API key is a placeholder or empty.
28pub fn is_placeholder_api_key(key: &str) -> bool {
29    let trimmed = key.trim();
30    trimmed.is_empty()
31        || trimmed.eq_ignore_ascii_case("YOUR_API_KEY")
32        || trimmed.eq_ignore_ascii_case("YOUR-API-KEY")
33        || trimmed.eq_ignore_ascii_case("API_KEY")
34        || trimmed.eq_ignore_ascii_case("API-KEY")
35}
36
37/// Redirect policy configuration for request
38#[derive(Debug, Default, Clone, PartialEq)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40pub enum RedirectPolicy {
41    #[default]
42    #[cfg_attr(
43        feature = "serde",
44        serde(alias = "Loose", alias = "loose", alias = "LOOSE",)
45    )]
46    /// A loose policy that allows all request up to the redirect limit.
47    Loose,
48    #[cfg_attr(
49        feature = "serde",
50        serde(alias = "Strict", alias = "strict", alias = "STRICT",)
51    )]
52    /// A strict policy only allowing request that match the domain set for crawling.
53    Strict,
54    #[cfg_attr(
55        feature = "serde",
56        serde(alias = "None", alias = "none", alias = "NONE",)
57    )]
58    /// Prevent all redirects.
59    None,
60}
61
62#[cfg(not(feature = "regex"))]
63/// Allow list normal matching paths.
64pub type AllowList = Vec<CompactString>;
65
66#[cfg(feature = "regex")]
67/// Allow list regex.
68pub type AllowList = Box<regex::RegexSet>;
69
70/// Whitelist or Blacklist
71#[derive(Debug, Default, Clone)]
72#[cfg_attr(not(feature = "regex"), derive(PartialEq, Eq))]
73pub struct AllowListSet(pub AllowList);
74
75#[cfg(feature = "chrome")]
76/// Track the events made via chrome.
77#[derive(Debug, PartialEq, Eq, Clone, Default)]
78#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
79pub struct ChromeEventTracker {
80    /// Track the responses.
81    pub responses: bool,
82    /// Track the requests.
83    pub requests: bool,
84    /// Track the changes between web automation.
85    pub automation: bool,
86}
87
88#[cfg(feature = "chrome")]
89impl ChromeEventTracker {
90    /// Create a new chrome event tracker
91    pub fn new(requests: bool, responses: bool) -> Self {
92        ChromeEventTracker {
93            requests,
94            responses,
95            automation: true,
96        }
97    }
98}
99
100#[cfg(feature = "sitemap")]
101#[derive(Debug, Default)]
102/// Determine if the sitemap modified to the whitelist.
103pub struct SitemapWhitelistChanges {
104    /// Added the default sitemap.xml whitelist.
105    pub added_default: bool,
106    /// Added the custom whitelist path.
107    pub added_custom: bool,
108}
109
110#[cfg(feature = "sitemap")]
111impl SitemapWhitelistChanges {
112    /// Was the whitelist modified?
113    pub(crate) fn modified(&self) -> bool {
114        self.added_default || self.added_custom
115    }
116}
117
118/// Determine allow proxy
119#[derive(Debug, Default, Clone, PartialEq)]
120#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
121pub enum ProxyIgnore {
122    /// Chrome proxy.
123    Chrome,
124    /// HTTP proxy.
125    Http,
126    #[default]
127    /// Do not ignore
128    No,
129}
130
131/// The networking proxy to use.
132#[derive(Debug, Default, Clone, PartialEq)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub struct RequestProxy {
135    /// The proxy address.
136    pub addr: String,
137    /// Ignore the proxy when running a request type.
138    pub ignore: ProxyIgnore,
139}
140
141/// Categorical "kind" a request can be routed under.
142///
143/// Carries no policy and no business semantics — what each kind *means*
144/// (when to route there, what proxies it should use) is entirely up to
145/// the consumer. Spider only stores the mapping and uses the kind as a
146/// lookup key.
147///
148/// Used in two places:
149/// * [`Configuration::proxies_by_kind`] — the optional sidecar map of
150///   `kind → Vec<RequestProxy>`, attached to the configuration without
151///   touching `RequestProxy` itself.
152/// * [`crate::proxy_strategy::ProxyStrategy::route`] — the per-request
153///   decision a strategy returns to pick which proxy list to use.
154///
155/// Returning [`ProxyKind::Default`] (or any kind not present in the
156/// sidecar map) keeps the existing fast path — no secondary client is
157/// built, no allocation, no behavior change.
158#[derive(Debug, Clone, PartialEq, Eq, Hash)]
159#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
160pub enum ProxyKind {
161    /// The default kind. Routes through the primary proxy list
162    /// ([`Configuration::proxies`]).
163    Default,
164    /// Media-asset request (image, video, audio, font, archive,
165    /// document). Pure technical classification — see
166    /// [`crate::utils::media_asset::is_media_asset_url`] for the helper
167    /// most strategies will pair with this kind.
168    MediaAsset,
169    /// Free-form, consumer-defined kind. Opaque to spider.
170    Custom(CompactString),
171}
172
173impl Default for ProxyKind {
174    #[inline]
175    fn default() -> Self {
176        ProxyKind::Default
177    }
178}
179
180/// The protocol used to communicate with a backend.
181#[cfg(feature = "parallel_backends")]
182#[derive(Debug, Clone, PartialEq)]
183#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
184pub enum BackendProtocol {
185    /// Chrome DevTools Protocol over WebSocket.
186    Cdp,
187    /// WebDriver (W3C) over HTTP.
188    WebDriver,
189}
190
191/// The engine type for a parallel crawl backend.
192#[cfg(feature = "parallel_backends")]
193#[derive(Debug, Default, Clone, PartialEq)]
194#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
195pub enum BackendEngine {
196    #[default]
197    /// CDP backend — communicates via the Chrome DevTools Protocol.
198    Cdp,
199    /// Servo — communicates via WebDriver protocol.
200    Servo,
201    /// A custom backend. Set `protocol` on [`BackendEndpoint`] to tell
202    /// spider whether to use CDP or WebDriver to communicate with it.
203    Custom,
204}
205
206/// A parallel crawl backend endpoint.
207///
208/// Each backend can run either **remotely** (connect to a running instance via
209/// `endpoint`) or **locally** (spider manages the engine process via
210/// `binary_path`). Set `endpoint` for remote mode, `binary_path` for local.
211#[cfg(feature = "parallel_backends")]
212#[derive(Debug, Default, Clone, PartialEq)]
213#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
214#[cfg_attr(feature = "serde", serde(default))]
215pub struct BackendEndpoint {
216    /// The browser engine to use.
217    pub engine: BackendEngine,
218    /// Remote endpoint URL. For CDP backends: a WebSocket URL
219    /// (e.g. `"ws://127.0.0.1:9222"`). For Servo: a WebDriver HTTP URL
220    /// (e.g. `"http://localhost:4444"`). When set, the engine is assumed to
221    /// be already running at this address.
222    pub endpoint: Option<String>,
223    /// Path to the engine binary for local mode. When set (and `endpoint` is
224    /// `None`), spider will spawn and manage the engine process. Uses PATH
225    /// lookup if empty string.
226    pub binary_path: Option<String>,
227    /// Explicit protocol override. When `None`, inferred from `engine`:
228    /// `Cdp` → CDP, `Servo` → WebDriver, `Custom` → **required**.
229    /// For custom backends, set this to tell spider how to communicate.
230    pub protocol: Option<BackendProtocol>,
231    /// Per-backend proxy address. When set, this backend routes its outbound
232    /// requests through the given proxy (e.g. `"socks5://proxy1:1080"`,
233    /// `"http://proxy2:8080"`). Overrides the global `ProxyRotator` for this
234    /// backend. For CDP backends, creates an isolated browser context with
235    /// the proxy. For WebDriver backends, sets the proxy capability.
236    pub proxy: Option<String>,
237}
238
239/// Configuration for parallel crawl backends.
240///
241/// When enabled, races alternative browser engines (CDP, Servo) alongside
242/// the primary crawl path. The best HTML response wins.
243#[cfg(feature = "parallel_backends")]
244#[derive(Debug, Clone, PartialEq)]
245#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
246#[cfg_attr(feature = "serde", serde(default))]
247pub struct ParallelBackendsConfig {
248    /// Alternative backends to race against the primary crawl.
249    pub backends: Vec<BackendEndpoint>,
250    /// Grace period (ms) after first response to wait for better results.
251    /// Allows slower backends to finish if they produce higher quality HTML.
252    /// Default: 500.
253    pub grace_period_ms: u64,
254    /// Master switch. Default: `true` (enabled when config is present).
255    pub enabled: bool,
256    /// Quality score threshold (0–100). If the first response scores at or
257    /// above this value, accept it immediately without waiting for the grace
258    /// period. Default: 80.
259    pub fast_accept_threshold: u16,
260    /// Maximum consecutive errors before auto-disabling a backend for
261    /// the remainder of the crawl. Default: 10.
262    pub max_consecutive_errors: u16,
263    /// Timeout (ms) for the initial TCP/WebSocket connection to a backend.
264    /// Separate from `request_timeout` so that down backends fail fast
265    /// without affecting navigation/fetch timeouts. Default: 5000 (5s).
266    pub connect_timeout_ms: u64,
267    /// Skip backend racing when the primary response has a binary
268    /// `Content-Type` (image/*, audio/*, video/*, font/*, application/pdf,
269    /// etc.). There is no HTML quality variance for binary resources.
270    /// Default: `true`.
271    pub skip_binary_content_types: bool,
272    /// Maximum concurrent backend sessions across all URLs. Prevents memory
273    /// spikes on large crawls. `0` means unlimited. Default: 8.
274    pub max_concurrent_sessions: usize,
275    /// Additional URL extensions to skip backend racing for, on top of the
276    /// built-in asset list (images, fonts, videos, etc.). Case-insensitive.
277    /// Example: `["xml", "rss"]`.
278    pub skip_extensions: Vec<CompactString>,
279    /// Maximum aggregate HTML bytes held by in-flight backend responses across
280    /// all concurrent races. When this cap is reached, new backend fetches are
281    /// skipped (primary-only) until existing responses are consumed or dropped.
282    /// Works without the `balance` feature. `0` means unlimited.
283    /// Default: 256 MiB (268_435_456).
284    pub max_backend_bytes_in_flight: usize,
285    /// Hard deadline (ms) for an entire backend fetch (connect + navigate +
286    /// extract). If a backend exceeds this, the task is cancelled and returns
287    /// `None`. Prevents a single stalled backend from blocking the primary
288    /// Chrome result during the grace window. `0` means no outer timeout
289    /// (individual phase timeouts still apply). Default: 30_000 (30s).
290    pub backend_timeout_ms: u64,
291}
292
293#[cfg(feature = "parallel_backends")]
294impl Default for ParallelBackendsConfig {
295    fn default() -> Self {
296        Self {
297            backends: Vec::new(),
298            grace_period_ms: 500,
299            enabled: true,
300            fast_accept_threshold: 80,
301            max_consecutive_errors: 10,
302            connect_timeout_ms: 5000,
303            skip_binary_content_types: true,
304            max_concurrent_sessions: 8,
305            skip_extensions: Vec::new(),
306            max_backend_bytes_in_flight: 256 * 1024 * 1024, // 256 MiB
307            backend_timeout_ms: 30_000,
308        }
309    }
310}
311
312/// User-configurable antibot detection patterns. Any match triggers `AntiBotTech::Custom`.
313#[derive(Debug, Default, Clone, PartialEq, Eq)]
314#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
315#[cfg_attr(feature = "serde", serde(default))]
316pub struct CustomAntibotPatterns {
317    /// Body substring patterns (matched against response bodies < 30KB).
318    pub body: Vec<CompactString>,
319    /// URL substring patterns.
320    pub url: Vec<CompactString>,
321    /// Header keys whose presence triggers antibot detection.
322    pub header_keys: Vec<CompactString>,
323}
324
325/// Structure to configure `Website` crawler
326/// ```rust
327/// use spider::website::Website;
328/// let mut website: Website = Website::new("https://choosealicense.com");
329/// website.configuration.blacklist_url.insert(Default::default()).push("https://choosealicense.com/licenses/".to_string().into());
330/// website.configuration.respect_robots_txt = true;
331/// website.configuration.subdomains = true;
332/// website.configuration.tld = true;
333/// ```
334#[derive(Debug, Default, Clone)]
335#[cfg_attr(
336    all(
337        not(feature = "regex"),
338        not(feature = "openai"),
339        not(feature = "cache_openai"),
340        not(feature = "gemini"),
341        not(feature = "cache_gemini")
342    ),
343    derive(PartialEq)
344)]
345#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
346#[cfg_attr(feature = "serde", serde(default))]
347pub struct Configuration {
348    /// Respect robots.txt file and not scrape not allowed files. This may slow down crawls if robots.txt file has a delay included.
349    pub respect_robots_txt: bool,
350    /// Allow sub-domains.
351    pub subdomains: bool,
352    /// Allow all tlds for domain.
353    pub tld: bool,
354    /// The max timeout for the crawl.
355    pub crawl_timeout: Option<Duration>,
356    /// Preserve the HTTP host header from being included.
357    pub preserve_host_header: bool,
358    /// List of pages to not crawl. [optional: regex pattern matching]
359    pub blacklist_url: Option<Vec<CompactString>>,
360    /// List of pages to only crawl. [optional: regex pattern matching]
361    pub whitelist_url: Option<Vec<CompactString>>,
362    /// User-Agent for request.
363    pub user_agent: Option<Box<CompactString>>,
364    /// Polite crawling delay in milli seconds.
365    pub delay: u64,
366    /// Request max timeout per page. By default the request times out in 15s. Set to None to disable.
367    pub request_timeout: Option<Duration>,
368    /// Use HTTP2 for connection. Enable if you know the website has http2 support.
369    pub http2_prior_knowledge: bool,
370    /// Use proxy list for performing network request.
371    pub proxies: Option<Vec<RequestProxy>>,
372    /// Optional sidecar map of alternative proxy lists keyed by
373    /// [`ProxyKind`].
374    ///
375    /// Lets a [`crate::proxy_strategy::ProxyStrategy`] route a request
376    /// through a non-default proxy set without touching `proxies` or
377    /// `RequestProxy` itself. When `None` (the default) or when the
378    /// strategy returns a kind that has no entry here, requests fall
379    /// through to `proxies` and the existing fast path — no behavior
380    /// change.
381    ///
382    /// Lookup is by enum equality / hash; the [`ProxyKind::Custom`]
383    /// variant lets consumers introduce their own kinds without an
384    /// upstream change. Spider never writes to this map after
385    /// configuration; runtime lazy state lives on the `Website`.
386    pub proxies_by_kind: Option<hashbrown::HashMap<ProxyKind, Vec<RequestProxy>>>,
387    /// Headers to include with request.
388    pub headers: Option<Box<SerializableHeaderMap>>,
389    #[cfg(feature = "sitemap")]
390    /// Include a sitemap in response of the crawl.
391    pub sitemap_url: Option<Box<CompactString>>,
392    #[cfg(feature = "sitemap")]
393    /// Prevent including the sitemap links with the crawl.
394    pub ignore_sitemap: bool,
395    /// The max redirections allowed for request.
396    pub redirect_limit: usize,
397    /// The redirect policy type to use.
398    pub redirect_policy: RedirectPolicy,
399    /// Whether `redirect_limit` was explicitly set by the caller.
400    ///
401    /// Set to `true` by `with_redirect_limit()` and by the external-config loader
402    /// when `redirect_limit` is provided. Chrome-path enforcement reads this flag
403    /// so it only caps redirects when the user opted in — preserving prior
404    /// behavior on pages whose navigation chains exceed the HTTP default of 7.
405    #[cfg_attr(feature = "serde", serde(skip))]
406    pub redirect_limit_set: bool,
407    /// Cap on main-frame cross-document navigations during a single Chrome
408    /// `goto` (requires the `chrome` feature — no effect on the HTTP path).
409    ///
410    /// Defends against JS / meta-refresh / HTTP-Refresh-header loops that
411    /// bypass the HTTP redirect cap because each hop is a fresh document
412    /// rather than a 3xx redirect. `None` disables the guard (default) so
413    /// prior behavior is preserved; `Some(n)` aborts the navigation with a
414    /// `net::ERR_TOO_MANY_NAVIGATIONS` error once the main frame has
415    /// navigated more than `n` times since `goto`.
416    pub max_main_frame_navigations: Option<u32>,
417    #[cfg(feature = "cookies")]
418    /// Cookie string to use for network requests ex: "foo=bar; Domain=blog.spider"
419    pub cookie_str: String,
420    #[cfg(feature = "wreq")]
421    /// The type of request emulation. This does nothing without the flag `sync` enabled.
422    pub emulation: Option<wreq_util::Emulation>,
423    #[cfg(feature = "cron")]
424    /// Cron string to perform crawls - use <https://crontab.guru/> to help generate a valid cron for needs.
425    pub cron_str: String,
426    #[cfg(feature = "cron")]
427    /// The type of cron to run either crawl or scrape.
428    pub cron_type: CronType,
429    /// The max depth to crawl for a website. Defaults to 25 to help prevent infinite recursion.
430    pub depth: usize,
431    /// The depth to crawl pertaining to the root.
432    pub depth_distance: usize,
433    /// Use stealth mode for requests.
434    pub stealth_mode: spider_fingerprint::configs::Tier,
435    /// Configure the viewport for chrome and viewport headers.
436    pub viewport: Option<Viewport>,
437    /// Crawl budget for the paths. This helps prevent crawling extra pages and limiting the amount.
438    pub budget: Option<hashbrown::HashMap<case_insensitive_string::CaseInsensitiveString, u32>>,
439    /// If wild card budgeting is found for the website.
440    pub wild_card_budgeting: bool,
441    /// External domains to include case-insensitive.
442    pub external_domains_caseless:
443        Arc<hashbrown::HashSet<case_insensitive_string::CaseInsensitiveString>>,
444    /// Collect all the resources found on the page.
445    pub full_resources: bool,
446    /// Dangerously accept invalid certficates.
447    pub accept_invalid_certs: bool,
448    /// The auth challenge response. The 'chrome_intercept' flag is also required in order to intercept the response.
449    pub auth_challenge_response: Option<AuthChallengeResponse>,
450    /// The OpenAI configs to use to help drive the chrome browser. This does nothing without the 'openai' flag.
451    pub openai_config: Option<Box<GPTConfigs>>,
452    /// The Gemini configs to use to help drive the chrome browser. This does nothing without the 'gemini' flag.
453    pub gemini_config: Option<Box<GeminiConfigs>>,
454    /// Remote multimodal automation config (vision + LLM-driven steps).
455    /// Requires the `agent` feature for full functionality, uses stub type otherwise.
456    pub remote_multimodal: Option<Box<crate::features::automation::RemoteMultimodalConfigs>>,
457    /// Use a shared queue strategy when crawling. This can scale workloads evenly that do not need priority.
458    pub shared_queue: bool,
459    /// Return the page links in the subscription channels. This does nothing without the flag `sync` enabled.
460    pub return_page_links: bool,
461    /// Retry count to attempt to swap proxies etc.
462    pub retry: u8,
463    /// Custom antibot detection patterns. When set, these are matched in addition
464    /// to the built-in patterns. Any match triggers `AntiBotTech::Custom`.
465    pub custom_antibot: Option<CustomAntibotPatterns>,
466    /// Skip spawning a control thread that can pause, start, and shutdown the crawl.
467    pub no_control_thread: bool,
468    /// The blacklist urls.
469    blacklist: AllowListSet,
470    /// The whitelist urls.
471    whitelist: AllowListSet,
472    /// Crawl budget for the paths. This helps prevent crawling extra pages and limiting the amount.
473    pub(crate) inner_budget:
474        Option<hashbrown::HashMap<case_insensitive_string::CaseInsensitiveString, u32>>,
475    /// Expect only to handle HTML to save on resources. This mainly only blocks the crawling and returning of resources from the server.
476    pub only_html: bool,
477    /// The concurrency limits to apply.
478    pub concurrency_limit: Option<usize>,
479    /// Normalize the html de-deplucating the content.
480    pub normalize: bool,
481    /// Share the state of the crawl requires the 'disk' feature flag.
482    pub shared: bool,
483    /// Modify the headers to act like a real-browser
484    pub modify_headers: bool,
485    /// Modify the HTTP client headers only to act like a real-browser
486    pub modify_http_client_headers: bool,
487    /// Cache the page following HTTP caching rules.
488    #[cfg(any(
489        feature = "cache_request",
490        feature = "chrome",
491        feature = "chrome_remote_cache"
492    ))]
493    pub cache: bool,
494    /// Skip browser rendering entirely if cached response exists.
495    /// When enabled, returns cached HTML directly without launching Chrome.
496    #[cfg(any(
497        feature = "cache_request",
498        feature = "chrome",
499        feature = "chrome_remote_cache"
500    ))]
501    pub cache_skip_browser: bool,
502    /// Namespace mixed into every cache key so logically distinct variants
503    /// (country, proxy pool, tenant, A/B bucket, device profile, …) never
504    /// collide on the same cached bytes. Free-form — spider treats it as an
505    /// opaque partition string. `None` uses the default (empty) namespace.
506    /// Always present (zero cost when unset); its effect is gated by whichever
507    /// cache feature is active.
508    pub cache_namespace: Option<Box<String>>,
509    /// Read-only mode for the remote Chrome cache. When enabled the local
510    /// cache + per-session cache still serve hits, but no responses are ever
511    /// uploaded to the remote `hybrid_cache_server` (neither via
512    /// `spider_remote_cache` enqueue nor via chromey's CDP listener). Intended
513    /// for deployments where an upstream proxy is the sole writer and spider
514    /// should only consume the cache. Default `false` preserves the existing
515    /// write-through behavior.
516    #[cfg(feature = "chrome_remote_cache")]
517    pub chrome_remote_cache_read_only: bool,
518    /// Publish fresh HTTP (skip_browser) responses to the shared remote
519    /// cache worker. When enabled, successful HTTP fetches made through
520    /// the skip_browser path are enqueued into `spider_remote_cache` so
521    /// they become available for later cache lookups. Independent of
522    /// chrome-path dumps — you can have chrome dumps off (via
523    /// `chrome_remote_cache_read_only = true`) while still publishing
524    /// from the HTTP path. Default `false` is a no-op.
525    #[cfg(feature = "chrome_remote_cache")]
526    pub remote_cache_skip_browser: bool,
527    /// Restrict chrome remote-cache dumps to the **main (initial)
528    /// document only**. When enabled, `cache_chrome_response` continues
529    /// to publish the navigated document body for each request
530    /// (whatever MIME type — HTML, JSON, XML, plain text, …), but the
531    /// per-response CDP listener (`spawn_cache_listener`) runs in
532    /// `dump_readonly` mode — populating the local + per-session cache
533    /// for sub-resources (CSS/JS/manifests) without uploading them to
534    /// the remote server. Orthogonal to `chrome_remote_cache_read_only`:
535    /// read-only suppresses *all* chrome dumps, this knob only
536    /// suppresses the asset/sub-resource path. Default `false`
537    /// preserves the existing dump-everything behavior.
538    #[cfg(feature = "chrome_remote_cache")]
539    pub chrome_remote_cache_main_doc_only: bool,
540    #[cfg(feature = "chrome")]
541    /// Enable or disable service workers. Enabled by default.
542    pub service_worker_enabled: bool,
543    #[cfg(feature = "chrome")]
544    /// Overrides default host system timezone with the specified one.
545    #[cfg(feature = "chrome")]
546    pub timezone_id: Option<Box<String>>,
547    /// Overrides default host system locale with the specified one.
548    #[cfg(feature = "chrome")]
549    pub locale: Option<Box<String>>,
550    /// Set a custom script to eval on each new document.
551    #[cfg(feature = "chrome")]
552    pub evaluate_on_new_document: Option<Box<String>>,
553    #[cfg(feature = "chrome")]
554    /// Dismiss dialogs.
555    pub dismiss_dialogs: Option<bool>,
556    #[cfg(feature = "chrome")]
557    /// Prefer fetching the rendered page directly as **Markdown** from the
558    /// connected browser engine when it supports native server-side
559    /// conversion (vendor `Content.getMarkdown` CDP method). Engines
560    /// without the capability are detected gracefully and the fetch falls
561    /// back to the standard HTML extraction path — byte-identical to this
562    /// flag being off. Pages fetched this way set
563    /// [`Page::content_is_markdown`](crate::page::Page::content_is_markdown)
564    /// so downstream consumers can skip local HTML→Markdown conversion.
565    /// Default `false`.
566    pub prefer_native_markdown: bool,
567    #[cfg(feature = "chrome")]
568    /// Wait for options for the page.
569    pub wait_for: Option<WaitFor>,
570    #[cfg(feature = "chrome")]
571    /// Take a screenshot of the page.
572    pub screenshot: Option<ScreenShotConfig>,
573    #[cfg(feature = "chrome")]
574    /// Track the events made via chrome.
575    pub track_events: Option<ChromeEventTracker>,
576    #[cfg(feature = "chrome")]
577    /// Setup fingerprint ID on each document. This does nothing without the flag `chrome` enabled.
578    pub fingerprint: Fingerprint,
579    #[cfg(feature = "chrome")]
580    /// The chrome connection url. Useful for targeting different headless instances. Defaults to using the env CHROME_URL.
581    pub chrome_connection_url: Option<String>,
582    #[cfg(feature = "chrome")]
583    /// Multiple remote Chrome connection URLs for failover. When a connection
584    /// fails after retries, the next URL is tried automatically. Requires the
585    /// `chrome` feature. When set, takes priority over `chrome_connection_url`.
586    pub chrome_connection_urls: Option<Vec<String>>,
587    #[cfg(feature = "chrome")]
588    #[cfg_attr(feature = "serde", serde(skip))]
589    /// Lazy, lock-free chrome failover instance reused across every
590    /// `setup_browser_configuration` call. Built on first use, reset by
591    /// `with_chrome_connections`. Internal cache; not part of public API.
592    pub(crate) chrome_failover: crate::features::chrome::LazyChromeFailover,
593    #[cfg(feature = "chrome")]
594    /// First-byte watchdog for Chrome navigations. When set, fires if no
595    /// `Network.dataReceived` (or `Network.responseReceived`) event arrives
596    /// within this duration after the listener attaches. On fire the page
597    /// is force-stopped and (when a `browser_dead` flag is plumbed through
598    /// `ChromeFetchParams`) it is flipped so the website-level retry loop
599    /// can rotate the backend. `None` (default) disables the watchdog and
600    /// the legacy chunk-idle timeout (`SPIDER_CHUNK_IDLE_TIMEOUT_SECS`,
601    /// default 30s) is the only stall guard.
602    pub chrome_first_byte_timeout: Option<Duration>,
603    #[cfg(feature = "chrome")]
604    /// Per-fetch jitter window applied on top of `chrome_first_byte_timeout`.
605    /// When `Some(j)`, each fetch picks `actual_timeout = base + rand(0..j)`
606    /// so concurrent fetches don't all expire at exactly the same moment
607    /// (avoids thundering-herd backend rotation when a backend goes dark).
608    /// `None` (default) means no jitter — every fetch uses the configured
609    /// base timeout exactly. Ignored when `chrome_first_byte_timeout` is
610    /// `None` (no watchdog to jitter).
611    pub chrome_first_byte_timeout_jitter: Option<Duration>,
612    /// First-byte watchdog for HTTP fetches. When `Some(d)`, each
613    /// `client.get(url).send().await` is wrapped in
614    /// `tokio::time::timeout(base + rand(0..jitter))`. On timeout the
615    /// in-flight connect / TLS / header future is dropped (cancels the
616    /// request) and a synthetic `524 GATEWAY_TIMEOUT` response is built
617    /// so the existing retry path rotates to the next proxy. Covers the
618    /// gap between `connect_timeout` (TCP/TLS handshake) and
619    /// `chunk_idle_timeout` (per-chunk idle while streaming) where a
620    /// proxy can accept the connection but never produce headers.
621    /// `None` (default) disables the watchdog — `request_timeout` and
622    /// `chunk_idle_timeout` remain the only stall guards.
623    pub http_first_byte_timeout: Option<Duration>,
624    /// Per-fetch jitter window applied on top of
625    /// `http_first_byte_timeout`. Same semantics as
626    /// `chrome_first_byte_timeout_jitter`. `None` (default) means no
627    /// jitter; ignored when the base is `None`.
628    pub http_first_byte_timeout_jitter: Option<Duration>,
629    /// Scripts to execute for individual pages, the full path of the url is required for an exact match. This is useful for running one off JS on pages like performing custom login actions.
630    #[cfg(feature = "chrome")]
631    pub execution_scripts: Option<ExecutionScripts>,
632    /// Web automation scripts to run up to a duration of 60 seconds.
633    #[cfg(feature = "chrome")]
634    pub automation_scripts: Option<AutomationScripts>,
635    /// Setup network interception for request. This does nothing without the flag `chrome_intercept` enabled.
636    #[cfg(feature = "chrome")]
637    pub chrome_intercept: RequestInterceptConfiguration,
638    /// The referer to use.
639    pub referer: Option<String>,
640    /// Determine the max bytes per page.
641    pub max_page_bytes: Option<f64>,
642    /// Determine the max bytes per browser context.
643    pub max_bytes_allowed: Option<u64>,
644    #[cfg(feature = "chrome")]
645    /// Disables log domain, prevents further log entries from being reported to the client. This does nothing without the flag `chrome` enabled.
646    pub disable_log: bool,
647    #[cfg(feature = "chrome")]
648    /// Automatic locale and timezone handling via third party. This does nothing without the flag `chrome` enabled.
649    pub auto_geolocation: bool,
650    /// The cache policy to use.
651    pub cache_policy: Option<BasicCachePolicy>,
652    #[cfg(feature = "chrome")]
653    /// Enables bypassing CSP. This does nothing without the flag `chrome` enabled.
654    pub bypass_csp: bool,
655    #[cfg(feature = "chrome")]
656    /// Disables JavaScript execution on the page. This does nothing without the flag `chrome` enabled.
657    pub disable_javascript: bool,
658    /// Bind the connections only on the network interface.
659    pub network_interface: Option<String>,
660    /// Bind to a local IP Address.
661    pub local_address: Option<IpAddr>,
662    /// The default http connect timeout
663    pub default_http_connect_timeout: Option<Duration>,
664    /// The default http read timeout
665    pub default_http_read_timeout: Option<Duration>,
666    #[cfg(feature = "webdriver")]
667    /// WebDriver configuration for browser automation. This does nothing without the `webdriver` flag enabled.
668    pub webdriver_config: Option<Box<WebDriverConfig>>,
669    #[cfg(feature = "search")]
670    /// Search provider configuration for web search integration. This does nothing without the `search` flag enabled.
671    pub search_config: Option<Box<SearchConfig>>,
672    #[cfg(feature = "spider_cloud")]
673    /// Spider Cloud config. See <https://spider.cloud>.
674    pub spider_cloud: Option<Box<SpiderCloudConfig>>,
675    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
676    /// Spider Browser Cloud config for remote CDP via `wss://browser.spider.cloud`.
677    pub spider_browser: Option<Box<SpiderBrowserConfig>>,
678    #[cfg(feature = "hedge")]
679    /// Hedged request configuration for work-stealing on slow requests.
680    /// When enabled, fires a duplicate request on a different proxy after a delay.
681    pub hedge: Option<crate::utils::hedge::HedgeConfig>,
682    #[cfg(feature = "auto_throttle")]
683    /// Latency-based auto-throttle configuration. When enabled, dynamically
684    /// adjusts per-domain crawl delay based on measured server response time.
685    pub auto_throttle: Option<crate::utils::auto_throttle::AutoThrottleConfig>,
686    #[cfg(feature = "etag_cache")]
687    /// Enable ETag / conditional request caching. When true, stores ETag and
688    /// Last-Modified headers from responses and sends If-None-Match /
689    /// If-Modified-Since on subsequent requests to the same URL, allowing
690    /// servers to respond with lightweight 304 Not Modified.
691    pub etag_cache: bool,
692    #[cfg(feature = "warc")]
693    /// WARC output configuration. When set, the crawl writes a WARC 1.1 file
694    /// containing all fetched pages as `response` records.
695    pub warc: Option<crate::utils::warc::WarcConfig>,
696    #[cfg(feature = "parallel_backends")]
697    /// Parallel crawl backend configuration. Race CDP / Servo backends alongside
698    /// the primary crawl path. Requires the `parallel_backends` feature.
699    pub parallel_backends: Option<ParallelBackendsConfig>,
700    /// Per-crawl control over the optional crawl enhancements (see
701    /// [`CrawlEnhancement`]). Empty by default, so every section follows its
702    /// `SPIDER_CHROME_*` env default — byte-identical to prior releases. Use
703    /// [`Configuration::with_enhancement`] / [`Configuration::with_all_enhancements`]
704    /// / [`Configuration::for_builtin_browser`] to override per section or
705    /// wholesale.
706    pub enhancements: EnhancementSettings,
707    #[cfg(feature = "decentralized")]
708    /// Per-`Website` remote Spider worker URLs used for crawl requests. When
709    /// `None`, falls back to the process-wide `SPIDER_WORKER` env var (or its
710    /// default), preserving pre-2.51.x behavior. When `Some`, overrides the
711    /// global pool for this `Website` only.
712    pub worker_connection_urls: Option<Vec<String>>,
713    #[cfg(feature = "decentralized")]
714    /// Per-`Website` remote Spider worker URLs used for scrape requests. When
715    /// `None`, falls back to the process-wide `SPIDER_WORKER_SCRAPER` env var
716    /// (or its default), preserving pre-2.51.x behavior. When `Some`,
717    /// overrides the global pool for this `Website` only.
718    pub scraper_worker_connection_urls: Option<Vec<String>>,
719}
720
721/// The optional, on-by-default crawl enhancements. Each is behaviour a browser
722/// with the same capability built in would otherwise duplicate, so each can be
723/// turned off per-`Website` (via [`Configuration::with_enhancement`]) or
724/// fleet-wide (via its `SPIDER_CHROME_*` env var) when the crawl is fronted by
725/// such a browser.
726///
727/// The discriminant doubles as a dense index into [`EnhancementSettings`], so
728/// keep the variants contiguous and in sync with [`CrawlEnhancement::ALL`].
729#[derive(Debug, Clone, Copy, PartialEq, Eq)]
730#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
731pub enum CrawlEnhancement {
732    /// Re-fetch a thin HTTP response through a full browser render once the
733    /// SPA-shell upgrade score crosses its threshold. Env:
734    /// `SPIDER_CHROME_RENDER_UPGRADE`.
735    RenderUpgrade = 0,
736    /// Clear a small interactive interstitial with a synthetic in-page pointer
737    /// interaction. Env: `SPIDER_CHROME_POINTER_ASSIST`.
738    PointerAssist = 1,
739    /// Short-circuit chrome navigation for a host already cached as
740    /// unresolvable. Env: `SPIDER_CHROME_DNS_GUARD`.
741    DnsGuard = 2,
742    /// Re-acquire a browser/gateway connection within the same request when the
743    /// active one dies mid-flight. Env: `SPIDER_CHROME_REACQUIRE`.
744    GatewayReacquire = 3,
745    /// Race a local-DNS probe against chrome navigation to fail unresolvable
746    /// hosts fast. Env: `SPIDER_CHROME_HEDGE_DNS_DELAY_MS` (`0` disables).
747    DnsHedge = 4,
748}
749
750impl CrawlEnhancement {
751    /// Number of sections.
752    pub const COUNT: usize = 5;
753
754    /// Every section, in discriminant order. Whole-map operations iterate this.
755    pub const ALL: [CrawlEnhancement; Self::COUNT] = [
756        CrawlEnhancement::RenderUpgrade,
757        CrawlEnhancement::PointerAssist,
758        CrawlEnhancement::DnsGuard,
759        CrawlEnhancement::GatewayReacquire,
760        CrawlEnhancement::DnsHedge,
761    ];
762
763    /// Process-global defaults for every section, read from the matching
764    /// `SPIDER_CHROME_*` env var exactly once and cached lock-free. Absent or
765    /// unrecognised value ⇒ enabled (default-ON opt-out convention). The hedge
766    /// slot is always `true` here — its `SPIDER_CHROME_HEDGE_DNS_DELAY_MS`
767    /// handling stays inside the hedge itself, so a per-crawl override is the
768    /// only thing this layer forces for it.
769    #[cfg(feature = "chrome")]
770    #[inline]
771    fn env_defaults() -> [bool; Self::COUNT] {
772        static CELL: std::sync::OnceLock<[bool; CrawlEnhancement::COUNT]> =
773            std::sync::OnceLock::new();
774        *CELL.get_or_init(|| {
775            let env = |k: &str| crate::utils::opt_out_flag(std::env::var(k).ok().as_deref());
776            [
777                env("SPIDER_CHROME_RENDER_UPGRADE"),
778                env("SPIDER_CHROME_POINTER_ASSIST"),
779                env("SPIDER_CHROME_DNS_GUARD"),
780                env("SPIDER_CHROME_REACQUIRE"),
781                true,
782            ]
783        })
784    }
785
786    /// Process-global default for this section (see [`Self::env_defaults`]).
787    #[cfg(feature = "chrome")]
788    #[inline]
789    pub(crate) fn env_default(self) -> bool {
790        Self::env_defaults()[self as usize]
791    }
792}
793
794/// Per-`Website` overrides for the [`CrawlEnhancement`] sections.
795///
796/// Map-like but allocation-free: a fixed `[Option<bool>; N]` indexed by each
797/// section's discriminant. `None` ⇒ fall back to the section's process-global
798/// env default; `Some(v)` ⇒ force it on/off for this crawl. `Copy`, no heap, no
799/// `Drop` — cheap to copy into every fetch and impossible to leak.
800///
801/// Resolution precedence: per-crawl override → `SPIDER_CHROME_*` env → built-in
802/// default (ON). An untouched value (all `None`) is byte-identical to prior
803/// releases.
804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
806pub struct EnhancementSettings {
807    overrides: [Option<bool>; CrawlEnhancement::COUNT],
808}
809
810impl Default for EnhancementSettings {
811    #[inline]
812    fn default() -> Self {
813        Self::new()
814    }
815}
816
817impl EnhancementSettings {
818    /// All sections at their env/global default (no per-crawl override).
819    #[inline]
820    pub const fn new() -> Self {
821        Self {
822            overrides: [None; CrawlEnhancement::COUNT],
823        }
824    }
825
826    /// Every section forced off — the map a crawl fronted by a browser with
827    /// these behaviours built in should use so the two layers don't both act.
828    #[inline]
829    pub const fn all_off() -> Self {
830        Self {
831            overrides: [Some(false); CrawlEnhancement::COUNT],
832        }
833    }
834
835    /// Force a single section on/off for this crawl, overriding its env default.
836    #[inline]
837    pub fn set(&mut self, section: CrawlEnhancement, enabled: bool) -> &mut Self {
838        self.overrides[section as usize] = Some(enabled);
839        self
840    }
841
842    /// Force every section to the same state (off-complete / on-complete).
843    #[inline]
844    pub fn set_all(&mut self, enabled: bool) -> &mut Self {
845        self.overrides = [Some(enabled); CrawlEnhancement::COUNT];
846        self
847    }
848
849    /// Drop a section's override so it reverts to the env/global default.
850    #[inline]
851    pub fn clear(&mut self, section: CrawlEnhancement) -> &mut Self {
852        self.overrides[section as usize] = None;
853        self
854    }
855
856    /// The explicit per-crawl override for a section, if any.
857    #[inline]
858    pub fn get(&self, section: CrawlEnhancement) -> Option<bool> {
859        self.overrides[section as usize]
860    }
861
862    /// `true` if any section carries a per-crawl override.
863    #[inline]
864    pub fn is_customized(&self) -> bool {
865        self.overrides.iter().any(Option::is_some)
866    }
867
868    /// Resolve a section: the per-crawl override if set, else its env/global
869    /// default. The hot-path check every gated site calls — one array index
870    /// plus, on the `None` path, a lock-free cached env read.
871    #[cfg(feature = "chrome")]
872    #[inline]
873    pub(crate) fn enabled(&self, section: CrawlEnhancement) -> bool {
874        match self.overrides[section as usize] {
875            Some(v) => v,
876            None => section.env_default(),
877        }
878    }
879}
880
881#[derive(Default, Debug, Clone, PartialEq, Eq)]
882/// Serializable HTTP headers.
883pub struct SerializableHeaderMap(pub HeaderMap);
884
885impl SerializableHeaderMap {
886    /// Innter HeaderMap.
887    pub fn inner(&self) -> &HeaderMap {
888        &self.0
889    }
890    /// Returns true if the map contains a value for the specified key.
891    pub fn contains_key<K>(&self, key: K) -> bool
892    where
893        K: AsHeaderName,
894    {
895        self.0.contains_key(key)
896    }
897    /// Inserts a key-value pair into the map.
898    pub fn insert<K>(
899        &mut self,
900        key: K,
901        val: reqwest::header::HeaderValue,
902    ) -> Option<reqwest::header::HeaderValue>
903    where
904        K: IntoHeaderName,
905    {
906        self.0.insert(key, val)
907    }
908    /// Extend a `HeaderMap` with the contents of another `HeaderMap`.
909    pub fn extend<I>(&mut self, iter: I)
910    where
911        I: IntoIterator<Item = (Option<HeaderName>, HeaderValue)>,
912    {
913        self.0.extend(iter);
914    }
915}
916
917/// Get a cloned copy of the `Referer` header as a `String` (if it exists and is valid UTF-8).
918pub fn get_referer(header_map: &Option<Box<SerializableHeaderMap>>) -> Option<String> {
919    match header_map {
920        Some(header_map) => {
921            header_map
922                .0
923                .get(crate::client::header::REFERER) // Retrieves the "Referer" HeaderValue if it exists
924                .and_then(|value| value.to_str().ok()) // &str from HeaderValue
925                .map(String::from) // Convert &str to String (owned)
926        }
927        _ => None,
928    }
929}
930
931impl From<HeaderMap> for SerializableHeaderMap {
932    fn from(header_map: HeaderMap) -> Self {
933        SerializableHeaderMap(header_map)
934    }
935}
936
937#[cfg(feature = "serde")]
938impl serde::Serialize for SerializableHeaderMap {
939    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
940    where
941        S: serde::Serializer,
942    {
943        let map: std::collections::BTreeMap<String, String> = self
944            .0
945            .iter()
946            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
947            .collect();
948        map.serialize(serializer)
949    }
950}
951
952#[cfg(feature = "serde")]
953impl<'de> serde::Deserialize<'de> for SerializableHeaderMap {
954    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
955    where
956        D: serde::Deserializer<'de>,
957    {
958        use reqwest::header::{HeaderName, HeaderValue};
959        use std::collections::BTreeMap;
960        let map: BTreeMap<String, String> = BTreeMap::deserialize(deserializer)?;
961        let mut headers = HeaderMap::with_capacity(map.len());
962        for (k, v) in map {
963            let key = HeaderName::from_bytes(k.as_bytes()).map_err(serde::de::Error::custom)?;
964            let value = HeaderValue::from_str(&v).map_err(serde::de::Error::custom)?;
965            headers.insert(key, value);
966        }
967        Ok(SerializableHeaderMap(headers))
968    }
969}
970
971#[cfg(feature = "serde")]
972impl serde::Serialize for AllowListSet {
973    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
974    where
975        S: serde::Serializer,
976    {
977        #[cfg(not(feature = "regex"))]
978        {
979            self.0.serialize(serializer)
980        }
981
982        #[cfg(feature = "regex")]
983        {
984            self.0
985                .patterns()
986                .iter()
987                .collect::<Vec<&String>>()
988                .serialize(serializer)
989        }
990    }
991}
992
993#[cfg(feature = "serde")]
994impl<'de> serde::Deserialize<'de> for AllowListSet {
995    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
996    where
997        D: serde::Deserializer<'de>,
998    {
999        #[cfg(not(feature = "regex"))]
1000        {
1001            let vec = Vec::<CompactString>::deserialize(deserializer)?;
1002            Ok(AllowListSet(vec))
1003        }
1004
1005        #[cfg(feature = "regex")]
1006        {
1007            let patterns = Vec::<String>::deserialize(deserializer)?;
1008            let regex_set = regex::RegexSet::new(&patterns).map_err(serde::de::Error::custom)?;
1009            Ok(AllowListSet(regex_set.into()))
1010        }
1011    }
1012}
1013
1014/// Get the user agent from the top agent list randomly.
1015#[cfg(feature = "ua_generator")]
1016pub fn get_ua(chrome: bool) -> &'static str {
1017    if chrome {
1018        ua_generator::ua::spoof_chrome_ua()
1019    } else {
1020        ua_generator::ua::spoof_ua()
1021    }
1022}
1023
1024/// Get the user agent via cargo package + version.
1025#[cfg(not(feature = "ua_generator"))]
1026pub fn get_ua(_chrome: bool) -> &'static str {
1027    use std::env;
1028
1029    lazy_static! {
1030        static ref AGENT: &'static str =
1031            concat!(env!("CARGO_PKG_NAME"), '/', env!("CARGO_PKG_VERSION"));
1032    };
1033
1034    AGENT.as_ref()
1035}
1036
1037impl Configuration {
1038    /// Represents crawl configuration for a website.
1039    #[cfg(not(feature = "chrome"))]
1040    pub fn new() -> Self {
1041        Self {
1042            delay: 0,
1043            depth: 25,
1044            redirect_limit: 7,
1045            request_timeout: Some(Duration::from_secs(120)),
1046            only_html: true,
1047            modify_headers: true,
1048            ..Default::default()
1049        }
1050    }
1051
1052    /// Represents crawl configuration for a website.
1053    #[cfg(feature = "chrome")]
1054    pub fn new() -> Self {
1055        Self {
1056            delay: 0,
1057            depth: 25,
1058            redirect_limit: 7,
1059            request_timeout: Some(Duration::from_secs(120)),
1060            chrome_intercept: RequestInterceptConfiguration::new(cfg!(
1061                feature = "chrome_intercept"
1062            )),
1063            user_agent: Some(Box::new(get_ua(true).into())),
1064            only_html: true,
1065            cache: true,
1066            modify_headers: true,
1067            service_worker_enabled: true,
1068            fingerprint: Fingerprint::Basic,
1069            auto_geolocation: false,
1070            ..Default::default()
1071        }
1072    }
1073
1074    /// Build a `RemoteMultimodalEngine` from `RemoteMultimodalConfigs`.
1075    /// Requires the `agent` feature.
1076    #[cfg(feature = "agent")]
1077    pub fn build_remote_multimodal_engine(
1078        &self,
1079    ) -> Option<crate::features::automation::RemoteMultimodalEngine> {
1080        let cfgs = self.remote_multimodal.as_ref()?;
1081        let sem = cfgs
1082            .concurrency_limit
1083            .filter(|&n| n > 0)
1084            .map(|n| std::sync::Arc::new(tokio::sync::Semaphore::new(n)));
1085
1086        #[allow(unused_mut)]
1087        let mut engine = crate::features::automation::RemoteMultimodalEngine::new(
1088            cfgs.api_url.clone(),
1089            cfgs.model_name.clone(),
1090            cfgs.system_prompt.clone(),
1091        )
1092        .with_api_key(cfgs.api_key.as_deref())
1093        .with_system_prompt_extra(cfgs.system_prompt_extra.as_deref())
1094        .with_user_message_extra(cfgs.user_message_extra.as_deref())
1095        .with_remote_multimodal_config(cfgs.cfg.clone())
1096        .with_prompt_url_gate(cfgs.prompt_url_gate.clone())
1097        .with_vision_model(cfgs.vision_model.clone())
1098        .with_text_model(cfgs.text_model.clone())
1099        .with_vision_route_mode(cfgs.vision_route_mode)
1100        .with_chrome_ai(cfgs.use_chrome_ai)
1101        .with_semaphore(sem)
1102        .to_owned();
1103
1104        #[cfg(feature = "agent_skills")]
1105        if let Some(ref registry) = cfgs.skill_registry {
1106            engine.with_skill_registry(Some(registry.clone()));
1107        }
1108
1109        // Build per-round complexity router from model pool (3+ models required)
1110        let model_pool = cfgs.model_pool.clone();
1111        if model_pool.len() >= 3 {
1112            let model_names: Vec<&str> =
1113                model_pool.iter().map(|ep| ep.model_name.as_str()).collect();
1114            let policy = crate::features::automation::auto_policy(&model_names);
1115            engine.model_router = Some(crate::features::automation::ModelRouter::with_policy(
1116                policy,
1117            ));
1118        }
1119        engine.model_pool = model_pool;
1120
1121        Some(engine)
1122    }
1123
1124    /// Determine if the agent should be set to a Chrome Agent.
1125    #[cfg(not(feature = "chrome"))]
1126    pub(crate) fn only_chrome_agent(&self) -> bool {
1127        false
1128    }
1129
1130    /// Determine if the agent should be set to a Chrome Agent.
1131    #[cfg(feature = "chrome")]
1132    pub(crate) fn only_chrome_agent(&self) -> bool {
1133        self.chrome_connection_url.is_some()
1134            || self.wait_for.is_some()
1135            || self.chrome_intercept.enabled
1136            || self.stealth_mode.stealth()
1137            || self.fingerprint.valid()
1138    }
1139
1140    #[cfg(feature = "regex")]
1141    /// Compile the regex for the blacklist.
1142    pub fn get_blacklist(&self) -> Box<regex::RegexSet> {
1143        match &self.blacklist_url {
1144            Some(blacklist) => match regex::RegexSet::new(&**blacklist) {
1145                Ok(s) => Box::new(s),
1146                _ => Default::default(),
1147            },
1148            _ => Default::default(),
1149        }
1150    }
1151
1152    #[cfg(not(feature = "regex"))]
1153    /// Handle the blacklist options.
1154    pub fn get_blacklist(&self) -> AllowList {
1155        match &self.blacklist_url {
1156            Some(blacklist) => blacklist.to_owned(),
1157            _ => Default::default(),
1158        }
1159    }
1160
1161    /// Set the blacklist
1162    pub(crate) fn set_blacklist(&mut self) {
1163        self.blacklist = AllowListSet(self.get_blacklist());
1164    }
1165
1166    /// Set the whitelist
1167    pub fn set_whitelist(&mut self) {
1168        self.whitelist = AllowListSet(self.get_whitelist());
1169    }
1170
1171    /// Configure the allow list.
1172    pub fn configure_allowlist(&mut self) {
1173        self.set_whitelist();
1174        self.set_blacklist();
1175    }
1176
1177    /// Get the blacklist compiled.
1178    pub fn get_blacklist_compiled(&self) -> &AllowList {
1179        &self.blacklist.0
1180    }
1181
1182    /// Setup the budget for crawling.
1183    pub fn configure_budget(&mut self) {
1184        self.inner_budget.clone_from(&self.budget);
1185    }
1186
1187    /// Get the whitelist compiled.
1188    pub fn get_whitelist_compiled(&self) -> &AllowList {
1189        &self.whitelist.0
1190    }
1191
1192    #[cfg(feature = "regex")]
1193    /// Compile the regex for the whitelist.
1194    pub fn get_whitelist(&self) -> Box<regex::RegexSet> {
1195        match &self.whitelist_url {
1196            Some(whitelist) => match regex::RegexSet::new(&**whitelist) {
1197                Ok(s) => Box::new(s),
1198                _ => Default::default(),
1199            },
1200            _ => Default::default(),
1201        }
1202    }
1203
1204    #[cfg(not(feature = "regex"))]
1205    /// Handle the whitelist options.
1206    pub fn get_whitelist(&self) -> AllowList {
1207        match &self.whitelist_url {
1208            Some(whitelist) => whitelist.to_owned(),
1209            _ => Default::default(),
1210        }
1211    }
1212
1213    #[cfg(feature = "sitemap")]
1214    /// Add sitemap paths to the whitelist and track what was added.
1215    pub fn add_sitemap_to_whitelist(&mut self) -> SitemapWhitelistChanges {
1216        let mut changes = SitemapWhitelistChanges::default();
1217
1218        if self.ignore_sitemap && self.whitelist_url.is_none() {
1219            return changes;
1220        }
1221
1222        if let Some(list) = self.whitelist_url.as_mut() {
1223            if list.is_empty() {
1224                return changes;
1225            }
1226
1227            let default = CompactString::from("sitemap.xml");
1228
1229            if !list.contains(&default) {
1230                list.push(default);
1231                changes.added_default = true;
1232            }
1233
1234            if let Some(custom) = &self.sitemap_url {
1235                if !list.contains(custom) {
1236                    // Clone the inner CompactString directly; `*custom.clone()`
1237                    // would allocate a new Box only to deref-move out of it.
1238                    list.push((**custom).clone());
1239                    changes.added_custom = true;
1240                }
1241            }
1242        }
1243
1244        changes
1245    }
1246
1247    #[cfg(feature = "sitemap")]
1248    /// Revert any changes made to the whitelist by `add_sitemap_to_whitelist`.
1249    pub fn remove_sitemap_from_whitelist(&mut self, changes: SitemapWhitelistChanges) {
1250        if let Some(list) = self.whitelist_url.as_mut() {
1251            if changes.added_default {
1252                let default = CompactString::from("sitemap.xml");
1253                if let Some(pos) = list.iter().position(|s| s == default) {
1254                    list.remove(pos);
1255                }
1256            }
1257            if changes.added_custom {
1258                if let Some(custom) = &self.sitemap_url {
1259                    if let Some(pos) = list.iter().position(|s| *s == **custom) {
1260                        list.remove(pos);
1261                    }
1262                }
1263            }
1264            if list.is_empty() {
1265                self.whitelist_url = None;
1266            }
1267        }
1268    }
1269
1270    /// Respect robots.txt file.
1271    pub fn with_respect_robots_txt(&mut self, respect_robots_txt: bool) -> &mut Self {
1272        self.respect_robots_txt = respect_robots_txt;
1273        self
1274    }
1275
1276    /// Include subdomains detection.
1277    pub fn with_subdomains(&mut self, subdomains: bool) -> &mut Self {
1278        self.subdomains = subdomains;
1279        self
1280    }
1281
1282    /// Force a single crawl enhancement on or off for this crawl, overriding its
1283    /// process-global `SPIDER_CHROME_*` env default. See [`CrawlEnhancement`].
1284    pub fn with_enhancement(&mut self, section: CrawlEnhancement, enabled: bool) -> &mut Self {
1285        self.enhancements.set(section, enabled);
1286        self
1287    }
1288
1289    /// Force every crawl enhancement on or off at once (off-complete /
1290    /// on-complete).
1291    pub fn with_all_enhancements(&mut self, enabled: bool) -> &mut Self {
1292        self.enhancements.set_all(enabled);
1293        self
1294    }
1295
1296    /// Replace the full per-crawl enhancement map.
1297    pub fn with_enhancements(&mut self, enhancements: EnhancementSettings) -> &mut Self {
1298        self.enhancements = enhancements;
1299        self
1300    }
1301
1302    /// Disable every enhancement a browser with these behaviours built in would
1303    /// duplicate. Use when fronting the crawl with such a browser so the two
1304    /// layers don't both act. Equivalent to `with_all_enhancements(false)`.
1305    pub fn for_builtin_browser(&mut self) -> &mut Self {
1306        self.enhancements.set_all(false);
1307        self
1308    }
1309
1310    /// Bypass CSP protection detection. This does nothing without the feat flag `chrome` enabled.
1311    #[cfg(feature = "chrome")]
1312    pub fn with_csp_bypass(&mut self, enabled: bool) -> &mut Self {
1313        self.bypass_csp = enabled;
1314        self
1315    }
1316
1317    /// Bypass CSP protection detection. This does nothing without the feat flag `chrome` enabled.
1318    #[cfg(not(feature = "chrome"))]
1319    pub fn with_csp_bypass(&mut self, _enabled: bool) -> &mut Self {
1320        self
1321    }
1322
1323    /// Disable JavaScript execution on the page. This does nothing without the feat flag `chrome` enabled.
1324    #[cfg(feature = "chrome")]
1325    pub fn with_disable_javascript(&mut self, disabled: bool) -> &mut Self {
1326        self.disable_javascript = disabled;
1327        self
1328    }
1329
1330    /// Disable JavaScript execution on the page. This does nothing without the feat flag `chrome` enabled.
1331    #[cfg(not(feature = "chrome"))]
1332    pub fn with_disable_javascript(&mut self, _disabled: bool) -> &mut Self {
1333        self
1334    }
1335
1336    /// Bind the connections only on the network interface.
1337    pub fn with_network_interface(&mut self, network_interface: Option<String>) -> &mut Self {
1338        self.network_interface = network_interface;
1339        self
1340    }
1341
1342    /// Bind to a local IP Address.
1343    pub fn with_local_address(&mut self, local_address: Option<IpAddr>) -> &mut Self {
1344        self.local_address = local_address;
1345        self
1346    }
1347
1348    /// Include tld detection.
1349    pub fn with_tld(&mut self, tld: bool) -> &mut Self {
1350        self.tld = tld;
1351        self
1352    }
1353
1354    /// The max duration for the crawl. This is useful when websites use a robots.txt with long durations and throttle the timeout removing the full concurrency.
1355    pub fn with_crawl_timeout(&mut self, crawl_timeout: Option<Duration>) -> &mut Self {
1356        self.crawl_timeout = crawl_timeout;
1357        self
1358    }
1359
1360    /// Delay between request as ms.
1361    pub fn with_delay(&mut self, delay: u64) -> &mut Self {
1362        self.delay = delay;
1363        self
1364    }
1365
1366    /// Only use HTTP/2.
1367    pub fn with_http2_prior_knowledge(&mut self, http2_prior_knowledge: bool) -> &mut Self {
1368        self.http2_prior_knowledge = http2_prior_knowledge;
1369        self
1370    }
1371
1372    /// Max time to wait for request. By default request times out in 15s. Set to None to disable.
1373    pub fn with_request_timeout(&mut self, request_timeout: Option<Duration>) -> &mut Self {
1374        match request_timeout {
1375            Some(timeout) => self.request_timeout = Some(timeout),
1376            _ => self.request_timeout = None,
1377        };
1378
1379        self
1380    }
1381
1382    #[cfg(feature = "sitemap")]
1383    /// Set the sitemap url. This does nothing without the `sitemap` feature flag.
1384    pub fn with_sitemap(&mut self, sitemap_url: Option<&str>) -> &mut Self {
1385        match sitemap_url {
1386            Some(sitemap_url) => {
1387                self.sitemap_url = Some(CompactString::new(sitemap_url.to_string()).into())
1388            }
1389            _ => self.sitemap_url = None,
1390        };
1391        self
1392    }
1393
1394    #[cfg(not(feature = "sitemap"))]
1395    /// Set the sitemap url. This does nothing without the `sitemap` feature flag.
1396    pub fn with_sitemap(&mut self, _sitemap_url: Option<&str>) -> &mut Self {
1397        self
1398    }
1399
1400    #[cfg(feature = "sitemap")]
1401    /// Ignore the sitemap when crawling. This method does nothing if the `sitemap` is not enabled.
1402    pub fn with_ignore_sitemap(&mut self, ignore_sitemap: bool) -> &mut Self {
1403        self.ignore_sitemap = ignore_sitemap;
1404        self
1405    }
1406
1407    #[cfg(not(feature = "sitemap"))]
1408    /// Ignore the sitemap when crawling. This method does nothing if the `sitemap` is not enabled.
1409    pub fn with_ignore_sitemap(&mut self, _ignore_sitemap: bool) -> &mut Self {
1410        self
1411    }
1412
1413    /// Add user agent to request.
1414    pub fn with_user_agent(&mut self, user_agent: Option<&str>) -> &mut Self {
1415        match user_agent {
1416            Some(agent) => self.user_agent = Some(CompactString::new(agent).into()),
1417            _ => self.user_agent = None,
1418        };
1419        self
1420    }
1421
1422    /// Preserve the HOST header.
1423    pub fn with_preserve_host_header(&mut self, preserve: bool) -> &mut Self {
1424        self.preserve_host_header = preserve;
1425        self
1426    }
1427
1428    /// Use a remote multimodal model to drive browser automation.
1429    /// Requires the `agent` feature.
1430    #[cfg(feature = "agent")]
1431    pub fn with_remote_multimodal(
1432        &mut self,
1433        remote_multimodal: Option<crate::features::automation::RemoteMultimodalConfigs>,
1434    ) -> &mut Self {
1435        self.remote_multimodal = remote_multimodal.map(Box::new);
1436        self
1437    }
1438
1439    /// Use a remote multimodal model to drive browser automation.
1440    /// When the `agent` feature is not enabled, this uses a stub type.
1441    #[cfg(not(feature = "agent"))]
1442    pub fn with_remote_multimodal(
1443        &mut self,
1444        remote_multimodal: Option<crate::features::automation::RemoteMultimodalConfigs>,
1445    ) -> &mut Self {
1446        self.remote_multimodal = remote_multimodal.map(Box::new);
1447        self
1448    }
1449
1450    #[cfg(not(feature = "openai"))]
1451    /// The OpenAI configs to use to drive the browser. This method does nothing if the `openai` is not enabled.
1452    pub fn with_openai(&mut self, _openai_config: Option<GPTConfigs>) -> &mut Self {
1453        self
1454    }
1455
1456    /// The OpenAI configs to use to drive the browser. This method does nothing if the `openai` is not enabled.
1457    #[cfg(feature = "openai")]
1458    pub fn with_openai(&mut self, openai_config: Option<GPTConfigs>) -> &mut Self {
1459        match openai_config {
1460            Some(openai_config) => self.openai_config = Some(Box::new(openai_config)),
1461            _ => self.openai_config = None,
1462        };
1463        self
1464    }
1465
1466    #[cfg(not(feature = "gemini"))]
1467    /// The Gemini configs to use to drive the browser. This method does nothing if the `gemini` is not enabled.
1468    pub fn with_gemini(&mut self, _gemini_config: Option<GeminiConfigs>) -> &mut Self {
1469        self
1470    }
1471
1472    /// The Gemini configs to use to drive the browser. This method does nothing if the `gemini` is not enabled.
1473    #[cfg(feature = "gemini")]
1474    pub fn with_gemini(&mut self, gemini_config: Option<GeminiConfigs>) -> &mut Self {
1475        match gemini_config {
1476            Some(gemini_config) => self.gemini_config = Some(Box::new(gemini_config)),
1477            _ => self.gemini_config = None,
1478        };
1479        self
1480    }
1481
1482    #[cfg(feature = "cookies")]
1483    /// Cookie string to use in request. This does nothing without the `cookies` flag enabled.
1484    pub fn with_cookies(&mut self, cookie_str: &str) -> &mut Self {
1485        self.cookie_str = cookie_str.into();
1486        self
1487    }
1488
1489    #[cfg(not(feature = "cookies"))]
1490    /// Cookie string to use in request. This does nothing without the `cookies` flag enabled.
1491    pub fn with_cookies(&mut self, _cookie_str: &str) -> &mut Self {
1492        self
1493    }
1494
1495    #[cfg(feature = "chrome")]
1496    /// Set custom fingerprint ID for request. This does nothing without the `chrome` flag enabled.
1497    pub fn with_fingerprint(&mut self, fingerprint: bool) -> &mut Self {
1498        if fingerprint {
1499            self.fingerprint = Fingerprint::Basic;
1500        } else {
1501            self.fingerprint = Fingerprint::None;
1502        }
1503        self
1504    }
1505
1506    #[cfg(feature = "chrome")]
1507    /// Set custom fingerprint ID for request. This does nothing without the `chrome` flag enabled.
1508    pub fn with_fingerprint_advanced(&mut self, fingerprint: Fingerprint) -> &mut Self {
1509        self.fingerprint = fingerprint;
1510        self
1511    }
1512
1513    #[cfg(not(feature = "chrome"))]
1514    /// Set custom fingerprint ID for request. This does nothing without the `chrome` flag enabled.
1515    pub fn with_fingerprint(&mut self, _fingerprint: bool) -> &mut Self {
1516        self
1517    }
1518
1519    /// Use proxies for request.
1520    pub fn with_proxies(&mut self, proxies: Option<Vec<String>>) -> &mut Self {
1521        self.proxies = proxies.map(|p| {
1522            p.iter()
1523                .map(|addr| RequestProxy {
1524                    addr: addr.to_owned(),
1525                    ..Default::default()
1526                })
1527                .collect::<Vec<RequestProxy>>()
1528        });
1529        self
1530    }
1531
1532    /// Use proxies for request with control between chrome and http.
1533    pub fn with_proxies_direct(&mut self, proxies: Option<Vec<RequestProxy>>) -> &mut Self {
1534        self.proxies = proxies;
1535        self
1536    }
1537
1538    /// Set the proxy override list for a specific [`ProxyKind`].
1539    ///
1540    /// Lazily registers a sidecar mapping that a
1541    /// [`crate::proxy_strategy::ProxyStrategy`] can route requests
1542    /// through. Pass `None` for `proxies` to remove a previously-set
1543    /// kind. Setting a kind to `Some(empty_vec)` is allowed and means
1544    /// "route here but with no proxy" — the secondary client built for
1545    /// this kind will be unproxied.
1546    ///
1547    /// Has no effect on the primary [`Configuration::proxies`] list or
1548    /// on requests that route to [`ProxyKind::Default`].
1549    pub fn with_proxies_for_kind(
1550        &mut self,
1551        kind: ProxyKind,
1552        proxies: Option<Vec<RequestProxy>>,
1553    ) -> &mut Self {
1554        match (proxies, self.proxies_by_kind.as_mut()) {
1555            (Some(p), Some(map)) => {
1556                map.insert(kind, p);
1557            }
1558            (Some(p), None) => {
1559                let mut map = hashbrown::HashMap::new();
1560                map.insert(kind, p);
1561                self.proxies_by_kind = Some(map);
1562            }
1563            (None, Some(map)) => {
1564                map.remove(&kind);
1565                if map.is_empty() {
1566                    self.proxies_by_kind = None;
1567                }
1568            }
1569            (None, None) => {}
1570        }
1571        self
1572    }
1573
1574    /// Use a shared semaphore to evenly handle workloads. The default is false.
1575    pub fn with_shared_queue(&mut self, shared_queue: bool) -> &mut Self {
1576        self.shared_queue = shared_queue;
1577        self
1578    }
1579
1580    /// Add blacklist urls to ignore.
1581    pub fn with_blacklist_url<T>(&mut self, blacklist_url: Option<Vec<T>>) -> &mut Self
1582    where
1583        Vec<CompactString>: From<Vec<T>>,
1584    {
1585        match blacklist_url {
1586            Some(p) => self.blacklist_url = Some(p.into()),
1587            _ => self.blacklist_url = None,
1588        };
1589        self
1590    }
1591
1592    /// Add whitelist urls to allow.
1593    pub fn with_whitelist_url<T>(&mut self, whitelist_url: Option<Vec<T>>) -> &mut Self
1594    where
1595        Vec<CompactString>: From<Vec<T>>,
1596    {
1597        match whitelist_url {
1598            Some(p) => self.whitelist_url = Some(p.into()),
1599            _ => self.whitelist_url = None,
1600        };
1601        self
1602    }
1603
1604    /// Return the links found on the page in the channel subscriptions. This method does nothing if the `decentralized` is enabled.
1605    pub fn with_return_page_links(&mut self, return_page_links: bool) -> &mut Self {
1606        self.return_page_links = return_page_links;
1607        self
1608    }
1609
1610    /// Set HTTP headers for request using [reqwest::header::HeaderMap](https://docs.rs/reqwest/latest/reqwest/header/struct.HeaderMap.html).
1611    pub fn with_headers(&mut self, headers: Option<reqwest::header::HeaderMap>) -> &mut Self {
1612        match headers {
1613            Some(m) => self.headers = Some(SerializableHeaderMap::from(m).into()),
1614            _ => self.headers = None,
1615        };
1616        self
1617    }
1618
1619    /// Set the max redirects allowed for request.
1620    ///
1621    /// Calling this method opts in to redirect-cap enforcement on both the HTTP
1622    /// and Chrome paths. Without it, Chrome defers to Chromium's internal
1623    /// ~20-hop cap to preserve prior behavior.
1624    pub fn with_redirect_limit(&mut self, redirect_limit: usize) -> &mut Self {
1625        self.redirect_limit = redirect_limit;
1626        self.redirect_limit_set = true;
1627        self
1628    }
1629
1630    /// Cap the number of main-frame cross-document navigations per Chrome
1631    /// `goto()` call. `None` disables the guard.
1632    ///
1633    /// This is the JS / meta-refresh counterpart to `with_redirect_limit` —
1634    /// the HTTP redirect cap cannot catch loops implemented via
1635    /// `location.href`, `<meta http-equiv="refresh">`, or `Refresh:` headers,
1636    /// because each hop is a fresh document rather than a 3xx redirect.
1637    pub fn with_max_main_frame_navigations(&mut self, cap: Option<u32>) -> &mut Self {
1638        self.max_main_frame_navigations = cap;
1639        self
1640    }
1641
1642    /// Set the redirect policy to use.
1643    pub fn with_redirect_policy(&mut self, policy: RedirectPolicy) -> &mut Self {
1644        self.redirect_policy = policy;
1645        self
1646    }
1647
1648    /// Add a referer (mis-spelling) to the request.
1649    pub fn with_referer(&mut self, referer: Option<String>) -> &mut Self {
1650        self.referer = referer;
1651        self
1652    }
1653
1654    /// Add a referer to the request.
1655    pub fn with_referrer(&mut self, referer: Option<String>) -> &mut Self {
1656        self.referer = referer;
1657        self
1658    }
1659
1660    /// Determine whether to collect all the resources found on pages.
1661    pub fn with_full_resources(&mut self, full_resources: bool) -> &mut Self {
1662        self.full_resources = full_resources;
1663        self
1664    }
1665
1666    /// Determine whether to dismiss dialogs. This method does nothing if the `chrome` is enabled.
1667    #[cfg(feature = "chrome")]
1668    pub fn with_dismiss_dialogs(&mut self, dismiss_dialogs: bool) -> &mut Self {
1669        self.dismiss_dialogs = Some(dismiss_dialogs);
1670        self
1671    }
1672
1673    /// Determine whether to dismiss dialogs. This method does nothing if the `chrome` is enabled.
1674    #[cfg(not(feature = "chrome"))]
1675    pub fn with_dismiss_dialogs(&mut self, _dismiss_dialogs: bool) -> &mut Self {
1676        self
1677    }
1678
1679    /// Prefer engine-native Markdown for the page content when the connected
1680    /// browser supports server-side conversion; engines without the
1681    /// capability fall back to the standard HTML extraction path. This
1682    /// method does nothing if the `chrome` feature is not enabled.
1683    #[cfg(feature = "chrome")]
1684    pub fn with_prefer_native_markdown(&mut self, prefer_native_markdown: bool) -> &mut Self {
1685        self.prefer_native_markdown = prefer_native_markdown;
1686        self
1687    }
1688
1689    /// Prefer engine-native Markdown for the page content when the connected
1690    /// browser supports server-side conversion; engines without the
1691    /// capability fall back to the standard HTML extraction path. This
1692    /// method does nothing if the `chrome` feature is not enabled.
1693    #[cfg(not(feature = "chrome"))]
1694    pub fn with_prefer_native_markdown(&mut self, _prefer_native_markdown: bool) -> &mut Self {
1695        self
1696    }
1697
1698    /// Set the request emuluation. This method does nothing if the `wreq` flag is not enabled.
1699    #[cfg(feature = "wreq")]
1700    pub fn with_emulation(&mut self, emulation: Option<wreq_util::Emulation>) -> &mut Self {
1701        self.emulation = emulation;
1702        self
1703    }
1704
1705    #[cfg(feature = "cron")]
1706    /// Setup cron jobs to run. This does nothing without the `cron` flag enabled.
1707    pub fn with_cron(&mut self, cron_str: &str, cron_type: CronType) -> &mut Self {
1708        self.cron_str = cron_str.into();
1709        self.cron_type = cron_type;
1710        self
1711    }
1712
1713    #[cfg(not(feature = "cron"))]
1714    /// Setup cron jobs to run. This does nothing without the `cron` flag enabled.
1715    pub fn with_cron(&mut self, _cron_str: &str, _cron_type: CronType) -> &mut Self {
1716        self
1717    }
1718
1719    /// Set a crawl page limit. If the value is 0 there is no limit.
1720    pub fn with_limit(&mut self, limit: u32) -> &mut Self {
1721        self.with_budget(Some(hashbrown::HashMap::from([("*", limit)])));
1722        self
1723    }
1724
1725    /// Set the concurrency limits. If you set the value to None to use the default limits using the system CPU cors * n.
1726    pub fn with_concurrency_limit(&mut self, limit: Option<usize>) -> &mut Self {
1727        self.concurrency_limit = limit;
1728        self
1729    }
1730
1731    #[cfg(feature = "chrome")]
1732    /// Set the authentiation challenge response. This does nothing without the feat flag `chrome` enabled.
1733    pub fn with_auth_challenge_response(
1734        &mut self,
1735        auth_challenge_response: Option<AuthChallengeResponse>,
1736    ) -> &mut Self {
1737        self.auth_challenge_response = auth_challenge_response;
1738        self
1739    }
1740
1741    #[cfg(feature = "chrome")]
1742    /// Set a custom script to evaluate on new document creation. This does nothing without the feat flag `chrome` enabled.
1743    pub fn with_evaluate_on_new_document(
1744        &mut self,
1745        evaluate_on_new_document: Option<Box<String>>,
1746    ) -> &mut Self {
1747        self.evaluate_on_new_document = evaluate_on_new_document;
1748        self
1749    }
1750
1751    #[cfg(not(feature = "chrome"))]
1752    /// Set a custom script to evaluate on new document creation. This does nothing without the feat flag `chrome` enabled.
1753    pub fn with_evaluate_on_new_document(
1754        &mut self,
1755        _evaluate_on_new_document: Option<Box<String>>,
1756    ) -> &mut Self {
1757        self
1758    }
1759
1760    #[cfg(not(feature = "chrome"))]
1761    /// Set the authentiation challenge response. This does nothing without the feat flag `chrome` enabled.
1762    pub fn with_auth_challenge_response(
1763        &mut self,
1764        _auth_challenge_response: Option<AuthChallengeResponse>,
1765    ) -> &mut Self {
1766        self
1767    }
1768
1769    /// Set a crawl depth limit. If the value is 0 there is no limit.
1770    pub fn with_depth(&mut self, depth: usize) -> &mut Self {
1771        self.depth = depth;
1772        self
1773    }
1774
1775    #[cfg(any(feature = "cache_request", feature = "chrome_remote_cache"))]
1776    /// Cache the page following HTTP rules. This method does nothing if the `cache` feature is not enabled.
1777    pub fn with_caching(&mut self, cache: bool) -> &mut Self {
1778        self.cache = cache;
1779        self
1780    }
1781
1782    #[cfg(not(any(feature = "cache_request", feature = "chrome_remote_cache")))]
1783    /// Cache the page following HTTP rules. This method does nothing if the `cache` feature is not enabled.
1784    pub fn with_caching(&mut self, _cache: bool) -> &mut Self {
1785        self
1786    }
1787
1788    #[cfg(any(feature = "cache_request", feature = "chrome_remote_cache"))]
1789    /// Skip browser rendering entirely if cached response exists.
1790    /// When enabled with caching, returns cached HTML directly without launching Chrome.
1791    /// This is useful for performance when you only need the cached content.
1792    pub fn with_cache_skip_browser(&mut self, skip: bool) -> &mut Self {
1793        self.cache_skip_browser = skip;
1794        self
1795    }
1796
1797    #[cfg(not(any(feature = "cache_request", feature = "chrome_remote_cache")))]
1798    /// Skip browser rendering entirely if cached response exists.
1799    /// This method does nothing if the cache features are not enabled.
1800    pub fn with_cache_skip_browser(&mut self, _skip: bool) -> &mut Self {
1801        self
1802    }
1803
1804    /// Partition the cache by an opaque namespace so logically distinct
1805    /// variants of the same URL (country, proxy pool, tenant, A/B bucket,
1806    /// device profile, …) never collide on the same cached bytes.
1807    /// `None` uses the default (empty) namespace. Has no observable effect
1808    /// when no cache feature is active, but the configuration is always
1809    /// settable regardless of feature flags.
1810    pub fn with_cache_namespace<S: Into<String>>(&mut self, namespace: Option<S>) -> &mut Self {
1811        self.cache_namespace = namespace.map(|s| Box::new(s.into()));
1812        self
1813    }
1814
1815    /// Borrowed access to the cache namespace (`None` = default partition).
1816    /// Used by chrome / cache feature paths; the lib build without those
1817    /// flags has no callers, hence `#[allow(dead_code)]`.
1818    #[inline]
1819    #[allow(dead_code)]
1820    pub(crate) fn cache_namespace_str(&self) -> Option<&str> {
1821        self.cache_namespace.as_ref().map(|s| s.as_str())
1822    }
1823
1824    /// Enable read-only mode for the remote Chrome cache. When `true`, the
1825    /// local cache + per-session cache continue to serve hits but no
1826    /// responses are uploaded to the remote `hybrid_cache_server`. Has no
1827    /// observable effect without the `chrome_remote_cache` feature.
1828    #[cfg(feature = "chrome_remote_cache")]
1829    pub fn with_chrome_remote_cache_read_only(&mut self, read_only: bool) -> &mut Self {
1830        self.chrome_remote_cache_read_only = read_only;
1831        self
1832    }
1833
1834    /// Enable read-only mode for the remote Chrome cache. This method does
1835    /// nothing without the `chrome_remote_cache` feature.
1836    #[cfg(not(feature = "chrome_remote_cache"))]
1837    pub fn with_chrome_remote_cache_read_only(&mut self, _read_only: bool) -> &mut Self {
1838        self
1839    }
1840
1841    /// Whether the remote Chrome cache is in read-only mode. Always `false`
1842    /// without the `chrome_remote_cache` feature. Only consumed by the
1843    /// chrome-cache wiring at `Configuration::chrome_fetch_params`, so the
1844    /// default lib build flags this as unused.
1845    #[inline]
1846    #[allow(dead_code)]
1847    pub(crate) fn chrome_remote_cache_read_only_enabled(&self) -> bool {
1848        #[cfg(feature = "chrome_remote_cache")]
1849        {
1850            self.chrome_remote_cache_read_only
1851        }
1852        #[cfg(not(feature = "chrome_remote_cache"))]
1853        {
1854            false
1855        }
1856    }
1857
1858    /// Enable publishing of fresh HTTP (skip_browser) responses to the
1859    /// shared remote cache worker. Updates the process-global dump flag
1860    /// on `spider_remote_cache` — the setter is wait-free (single atomic
1861    /// store) and safe to call from any thread. Also opts into the
1862    /// disk-backed overflow spool so bursty crawls don't drop under
1863    /// memory pressure. Has no observable effect without the
1864    /// `chrome_remote_cache` feature.
1865    #[cfg(feature = "chrome_remote_cache")]
1866    pub fn with_remote_cache_skip_browser(&mut self, enabled: bool) -> &mut Self {
1867        self.remote_cache_skip_browser = enabled;
1868        spider_remote_cache::set_skip_browser_dumps_enabled(enabled);
1869        spider_remote_cache::set_spool_enabled(enabled);
1870        self
1871    }
1872
1873    /// Enable publishing of fresh HTTP (skip_browser) responses to the
1874    /// shared remote cache worker. This method does nothing without the
1875    /// `chrome_remote_cache` feature.
1876    #[cfg(not(feature = "chrome_remote_cache"))]
1877    pub fn with_remote_cache_skip_browser(&mut self, _enabled: bool) -> &mut Self {
1878        self
1879    }
1880
1881    /// Whether HTTP (skip_browser) responses should be enqueued to the
1882    /// shared remote cache worker. Always `false` without the
1883    /// `chrome_remote_cache` feature. Mirrors the process-global state
1884    /// held in `spider_remote_cache::skip_browser_dumps_enabled()` —
1885    /// callers that need the runtime toggle should read the global
1886    /// directly for wait-free access from the hot path.
1887    #[inline]
1888    #[allow(dead_code)]
1889    pub(crate) fn remote_cache_skip_browser_enabled(&self) -> bool {
1890        #[cfg(feature = "chrome_remote_cache")]
1891        {
1892            self.remote_cache_skip_browser
1893        }
1894        #[cfg(not(feature = "chrome_remote_cache"))]
1895        {
1896            false
1897        }
1898    }
1899
1900    /// Restrict chrome remote-cache dumps to the main (initial)
1901    /// document only. When `true`, the per-response listener runs in
1902    /// `dump_readonly` mode (local + per-session cache only) so
1903    /// CSS/JS/manifests are **not** uploaded to the remote
1904    /// `hybrid_cache_server`; the navigated document body — whatever
1905    /// MIME type it is — still goes through `cache_chrome_response`.
1906    /// Has no observable effect without the `chrome_remote_cache`
1907    /// feature.
1908    #[cfg(feature = "chrome_remote_cache")]
1909    pub fn with_chrome_remote_cache_main_doc_only(&mut self, enabled: bool) -> &mut Self {
1910        self.chrome_remote_cache_main_doc_only = enabled;
1911        self
1912    }
1913
1914    /// Restrict chrome remote-cache dumps to the main document only.
1915    /// This method does nothing without the `chrome_remote_cache`
1916    /// feature.
1917    #[cfg(not(feature = "chrome_remote_cache"))]
1918    pub fn with_chrome_remote_cache_main_doc_only(&mut self, _enabled: bool) -> &mut Self {
1919        self
1920    }
1921
1922    /// Whether chrome remote-cache dumps should be restricted to the
1923    /// main document. Always `false` without the `chrome_remote_cache`
1924    /// feature. Only consumed by the chrome-cache wiring at
1925    /// `Configuration::chrome_fetch_params`; default lib build is unused.
1926    #[inline]
1927    #[allow(dead_code)]
1928    pub(crate) fn chrome_remote_cache_main_doc_only_enabled(&self) -> bool {
1929        #[cfg(feature = "chrome_remote_cache")]
1930        {
1931            self.chrome_remote_cache_main_doc_only
1932        }
1933        #[cfg(not(feature = "chrome_remote_cache"))]
1934        {
1935            false
1936        }
1937    }
1938
1939    #[cfg(feature = "chrome")]
1940    /// Enable or disable Service Workers. This method does nothing if the `chrome` feature is not enabled.
1941    pub fn with_service_worker_enabled(&mut self, enabled: bool) -> &mut Self {
1942        self.service_worker_enabled = enabled;
1943        self
1944    }
1945
1946    #[cfg(not(feature = "chrome"))]
1947    /// Enable or disable Service Workers. This method does nothing if the `chrome` feature is not enabled.
1948    pub fn with_service_worker_enabled(&mut self, _enabled: bool) -> &mut Self {
1949        self
1950    }
1951
1952    /// Automatically setup geo-location configurations when using a proxy. This method does nothing if the `chrome` feature is not enabled.
1953    #[cfg(not(feature = "chrome"))]
1954    pub fn with_auto_geolocation(&mut self, _enabled: bool) -> &mut Self {
1955        self
1956    }
1957
1958    /// Automatically setup geo-location configurations when using a proxy. This method does nothing if the `chrome` feature is not enabled.
1959    #[cfg(feature = "chrome")]
1960    pub fn with_auto_geolocation(&mut self, enabled: bool) -> &mut Self {
1961        self.auto_geolocation = enabled;
1962        self
1963    }
1964
1965    /// Set the retry limit for request. Set the value to 0 for no retries. The default is 0.
1966    pub fn with_retry(&mut self, retry: u8) -> &mut Self {
1967        self.retry = retry;
1968        self
1969    }
1970
1971    /// The default http connect timeout.
1972    pub fn with_default_http_connect_timeout(
1973        &mut self,
1974        default_http_connect_timeout: Option<Duration>,
1975    ) -> &mut Self {
1976        self.default_http_connect_timeout = default_http_connect_timeout;
1977        self
1978    }
1979
1980    /// The default http read timeout.
1981    pub fn with_default_http_read_timeout(
1982        &mut self,
1983        default_http_read_timeout: Option<Duration>,
1984    ) -> &mut Self {
1985        self.default_http_read_timeout = default_http_read_timeout;
1986        self
1987    }
1988
1989    /// Skip setting up a control thread for pause, start, and shutdown programmatic handling. This does nothing without the 'control' flag enabled.
1990    pub fn with_no_control_thread(&mut self, no_control_thread: bool) -> &mut Self {
1991        self.no_control_thread = no_control_thread;
1992        self
1993    }
1994
1995    /// Configures the viewport of the browser, which defaults to 800x600. This method does nothing if the 'chrome' feature is not enabled.
1996    pub fn with_viewport(&mut self, viewport: Option<crate::configuration::Viewport>) -> &mut Self {
1997        self.viewport = viewport.map(|vp| vp);
1998        self
1999    }
2000
2001    #[cfg(feature = "chrome")]
2002    /// Use stealth mode for the request. This does nothing without the `chrome` flag enabled.
2003    pub fn with_stealth(&mut self, stealth_mode: bool) -> &mut Self {
2004        if stealth_mode {
2005            self.stealth_mode = spider_fingerprint::configs::Tier::Basic;
2006        } else {
2007            self.stealth_mode = spider_fingerprint::configs::Tier::None;
2008        }
2009        self
2010    }
2011
2012    #[cfg(feature = "chrome")]
2013    /// Use stealth mode for the request. This does nothing without the `chrome` flag enabled.
2014    pub fn with_stealth_advanced(
2015        &mut self,
2016        stealth_mode: spider_fingerprint::configs::Tier,
2017    ) -> &mut Self {
2018        self.stealth_mode = stealth_mode;
2019        self
2020    }
2021
2022    #[cfg(not(feature = "chrome"))]
2023    /// Use stealth mode for the request. This does nothing without the `chrome` flag enabled.
2024    pub fn with_stealth(&mut self, _stealth_mode: bool) -> &mut Self {
2025        self
2026    }
2027
2028    #[cfg(feature = "chrome")]
2029    /// Wait for network request to be idle within a time frame period (500ms no network connections). This does nothing without the `chrome` flag enabled.
2030    pub fn with_wait_for_idle_network(
2031        &mut self,
2032        wait_for_idle_network: Option<WaitForIdleNetwork>,
2033    ) -> &mut Self {
2034        match self.wait_for.as_mut() {
2035            Some(wait_for) => wait_for.idle_network = wait_for_idle_network,
2036            _ => {
2037                let mut wait_for = WaitFor::default();
2038                wait_for.idle_network = wait_for_idle_network;
2039                self.wait_for = Some(wait_for);
2040            }
2041        }
2042        self
2043    }
2044
2045    #[cfg(feature = "chrome")]
2046    /// Wait for network request with a max timeout. This does nothing without the `chrome` flag enabled.
2047    pub fn with_wait_for_idle_network0(
2048        &mut self,
2049        wait_for_idle_network0: Option<WaitForIdleNetwork>,
2050    ) -> &mut Self {
2051        match self.wait_for.as_mut() {
2052            Some(wait_for) => wait_for.idle_network0 = wait_for_idle_network0,
2053            _ => {
2054                let mut wait_for = WaitFor::default();
2055                wait_for.idle_network0 = wait_for_idle_network0;
2056                self.wait_for = Some(wait_for);
2057            }
2058        }
2059        self
2060    }
2061
2062    #[cfg(feature = "chrome")]
2063    /// Wait for network to be almost idle with a max timeout. This does nothing without the `chrome` flag enabled.
2064    pub fn with_wait_for_almost_idle_network0(
2065        &mut self,
2066        wait_for_almost_idle_network0: Option<WaitForIdleNetwork>,
2067    ) -> &mut Self {
2068        match self.wait_for.as_mut() {
2069            Some(wait_for) => wait_for.almost_idle_network0 = wait_for_almost_idle_network0,
2070            _ => {
2071                let mut wait_for = WaitFor::default();
2072                wait_for.almost_idle_network0 = wait_for_almost_idle_network0;
2073                self.wait_for = Some(wait_for);
2074            }
2075        }
2076        self
2077    }
2078
2079    #[cfg(not(feature = "chrome"))]
2080    /// Wait for network to be almost idle with a max timeout. This does nothing without the `chrome` flag enabled.
2081    pub fn with_wait_for_almost_idle_network0(
2082        &mut self,
2083        _wait_for_almost_idle_network0: Option<WaitForIdleNetwork>,
2084    ) -> &mut Self {
2085        self
2086    }
2087
2088    #[cfg(not(feature = "chrome"))]
2089    /// Wait for network request with a max timeout. This does nothing without the `chrome` flag enabled.
2090    pub fn with_wait_for_idle_network0(
2091        &mut self,
2092        _wait_for_idle_network0: Option<WaitForIdleNetwork>,
2093    ) -> &mut Self {
2094        self
2095    }
2096
2097    #[cfg(not(feature = "chrome"))]
2098    /// Wait for idle network request. This method does nothing if the `chrome` feature is not enabled.
2099    pub fn with_wait_for_idle_network(
2100        &mut self,
2101        _wait_for_idle_network: Option<WaitForIdleNetwork>,
2102    ) -> &mut Self {
2103        self
2104    }
2105
2106    #[cfg(feature = "chrome")]
2107    /// Wait for idle dom mutations for target element. This method does nothing if the [chrome] feature is not enabled.
2108    pub fn with_wait_for_idle_dom(
2109        &mut self,
2110        wait_for_idle_dom: Option<WaitForSelector>,
2111    ) -> &mut Self {
2112        match self.wait_for.as_mut() {
2113            Some(wait_for) => wait_for.dom = wait_for_idle_dom,
2114            _ => {
2115                let mut wait_for = WaitFor::default();
2116                wait_for.dom = wait_for_idle_dom;
2117                self.wait_for = Some(wait_for);
2118            }
2119        }
2120        self
2121    }
2122
2123    #[cfg(not(feature = "chrome"))]
2124    /// Wait for idle dom mutations for target element. This method does nothing if the `chrome` feature is not enabled.
2125    pub fn with_wait_for_idle_dom(
2126        &mut self,
2127        _wait_for_idle_dom: Option<WaitForSelector>,
2128    ) -> &mut Self {
2129        self
2130    }
2131
2132    #[cfg(feature = "chrome")]
2133    /// Wait for a selector. This method does nothing if the `chrome` feature is not enabled.
2134    pub fn with_wait_for_selector(
2135        &mut self,
2136        wait_for_selector: Option<WaitForSelector>,
2137    ) -> &mut Self {
2138        match self.wait_for.as_mut() {
2139            Some(wait_for) => wait_for.selector = wait_for_selector,
2140            _ => {
2141                let mut wait_for = WaitFor::default();
2142                wait_for.selector = wait_for_selector;
2143                self.wait_for = Some(wait_for);
2144            }
2145        }
2146        self
2147    }
2148
2149    #[cfg(not(feature = "chrome"))]
2150    /// Wait for a selector. This method does nothing if the `chrome` feature is not enabled.
2151    pub fn with_wait_for_selector(
2152        &mut self,
2153        _wait_for_selector: Option<WaitForSelector>,
2154    ) -> &mut Self {
2155        self
2156    }
2157
2158    #[cfg(feature = "chrome")]
2159    /// Wait for with delay. Should only be used for testing. This method does nothing if the 'chrome' feature is not enabled.
2160    pub fn with_wait_for_delay(&mut self, wait_for_delay: Option<WaitForDelay>) -> &mut Self {
2161        match self.wait_for.as_mut() {
2162            Some(wait_for) => wait_for.delay = wait_for_delay,
2163            _ => {
2164                let mut wait_for = WaitFor::default();
2165                wait_for.delay = wait_for_delay;
2166                self.wait_for = Some(wait_for);
2167            }
2168        }
2169        self
2170    }
2171
2172    #[cfg(not(feature = "chrome"))]
2173    /// Wait for with delay. Should only be used for testing. This method does nothing if the 'chrome' feature is not enabled.
2174    pub fn with_wait_for_delay(&mut self, _wait_for_delay: Option<WaitForDelay>) -> &mut Self {
2175        self
2176    }
2177
2178    #[cfg(feature = "chrome_intercept")]
2179    /// Use request intercept for the request to only allow content that matches the host. If the content is from a 3rd party it needs to be part of our include list. This method does nothing if the `chrome_intercept` is not enabled.
2180    pub fn with_chrome_intercept(
2181        &mut self,
2182        chrome_intercept: RequestInterceptConfiguration,
2183        url: &Option<Box<url::Url>>,
2184    ) -> &mut Self {
2185        self.chrome_intercept = chrome_intercept;
2186        self.chrome_intercept.setup_intercept_manager(url);
2187        self
2188    }
2189
2190    #[cfg(not(feature = "chrome_intercept"))]
2191    /// Use request intercept for the request to only allow content required for the page that matches the host. If the content is from a 3rd party it needs to be part of our include list. This method does nothing if the `chrome_intercept` is not enabled.
2192    pub fn with_chrome_intercept(
2193        &mut self,
2194        _chrome_intercept: RequestInterceptConfiguration,
2195        _url: &Option<Box<url::Url>>,
2196    ) -> &mut Self {
2197        self
2198    }
2199
2200    #[cfg(feature = "chrome_intercept")]
2201    /// Push the interception policy (the `chrome_intercept` flags + per-job
2202    /// blacklist/whitelist + page url) to a capable remote rendering engine
2203    /// once per navigation, so it resolves block/allow decisions locally
2204    /// instead of round-tripping every paused request. Enables request
2205    /// interception. No-op against a normal Chrome target (the vendor method is
2206    /// ignored), so it only changes behavior for engines that implement it.
2207    pub fn with_remote_local_policy(&mut self, enabled: bool) -> &mut Self {
2208        if enabled {
2209            self.chrome_intercept.enabled = true;
2210        }
2211        self.chrome_intercept.set_remote_local_policy(enabled);
2212        self
2213    }
2214
2215    #[cfg(not(feature = "chrome_intercept"))]
2216    /// Push the interception policy to a capable remote rendering engine. This
2217    /// method does nothing without the `chrome_intercept` flag.
2218    pub fn with_remote_local_policy(&mut self, _enabled: bool) -> &mut Self {
2219        self
2220    }
2221
2222    #[cfg(feature = "chrome")]
2223    /// Set the connection url for the chrome instance. This method does nothing if the `chrome` is not enabled.
2224    pub fn with_chrome_connection(&mut self, chrome_connection_url: Option<String>) -> &mut Self {
2225        self.chrome_connection_url = chrome_connection_url;
2226        self
2227    }
2228
2229    #[cfg(not(feature = "chrome"))]
2230    /// Set the connection url for the chrome instance. This method does nothing if the `chrome` is not enabled.
2231    pub fn with_chrome_connection(&mut self, _chrome_connection_url: Option<String>) -> &mut Self {
2232        self
2233    }
2234
2235    #[cfg(feature = "chrome")]
2236    /// Set multiple remote Chrome connection URLs for failover. When a
2237    /// connection fails after retries, the next URL is tried. Takes
2238    /// priority over `chrome_connection_url` when set.
2239    ///
2240    /// A single-URL vec routes through `chrome_connection_url` so the
2241    /// normal single-endpoint path (10 retries w/ backoff) is used
2242    /// instead of the failover path (3 retries, no other endpoint to try).
2243    pub fn with_chrome_connections(&mut self, urls: Vec<String>) -> &mut Self {
2244        match urls.len() {
2245            0 => {
2246                self.chrome_connection_urls = None;
2247            }
2248            1 => {
2249                self.chrome_connection_url = urls.into_iter().next();
2250                self.chrome_connection_urls = None;
2251            }
2252            _ => {
2253                self.chrome_connection_urls = Some(urls);
2254            }
2255        }
2256        // Drop any previously-built failover so the next setup call reflects
2257        // the new URL list. Outstanding readers keep the old Arc alive until
2258        // they release it; no leak.
2259        self.chrome_failover = crate::features::chrome::LazyChromeFailover::default();
2260        self
2261    }
2262
2263    #[cfg(not(feature = "chrome"))]
2264    /// Set multiple remote Chrome connection URLs. This method does nothing if the `chrome` is not enabled.
2265    pub fn with_chrome_connections(&mut self, _urls: Vec<String>) -> &mut Self {
2266        self
2267    }
2268
2269    #[cfg(feature = "decentralized")]
2270    /// Set the Spider worker URL for crawl requests. `None` clears the
2271    /// per-website override so this `Website` falls back to the process-wide
2272    /// `SPIDER_WORKER` env var (default `http://127.0.0.1:3030`). `Some` with
2273    /// a non-empty URL routes crawl traffic through that worker; `Some` with
2274    /// an empty/whitespace URL disables the crawl worker pool for this
2275    /// `Website` without affecting any other `Website` in the process.
2276    pub fn with_worker_connection(&mut self, worker_connection_url: Option<String>) -> &mut Self {
2277        self.worker_connection_urls = worker_connection_url.map(|url| {
2278            let url = url.trim();
2279            if url.is_empty() {
2280                Vec::new()
2281            } else {
2282                vec![url.to_string()]
2283            }
2284        });
2285        self
2286    }
2287
2288    #[cfg(not(feature = "decentralized"))]
2289    /// Set the Spider worker URL for crawl requests. This method does nothing
2290    /// if the `decentralized` feature is not enabled.
2291    pub fn with_worker_connection(&mut self, _worker_connection_url: Option<String>) -> &mut Self {
2292        self
2293    }
2294
2295    #[cfg(feature = "decentralized")]
2296    /// Set multiple Spider worker URLs for crawl requests. Empty/whitespace
2297    /// entries are dropped. An empty resulting list disables the crawl worker
2298    /// pool for this `Website` only.
2299    pub fn with_worker_connections(&mut self, urls: Vec<String>) -> &mut Self {
2300        self.worker_connection_urls = Some(
2301            urls.into_iter()
2302                .map(|url| url.trim().to_string())
2303                .filter(|url| !url.is_empty())
2304                .collect(),
2305        );
2306        self
2307    }
2308
2309    #[cfg(not(feature = "decentralized"))]
2310    /// Set multiple Spider worker URLs for crawl requests. This method does
2311    /// nothing if the `decentralized` feature is not enabled.
2312    pub fn with_worker_connections(&mut self, _urls: Vec<String>) -> &mut Self {
2313        self
2314    }
2315
2316    #[cfg(feature = "decentralized")]
2317    /// Set the Spider scraper worker URL for scrape requests. `None` clears
2318    /// the per-website override so this `Website` falls back to the
2319    /// process-wide `SPIDER_WORKER_SCRAPER` env var (default
2320    /// `http://127.0.0.1:3031`). `Some` with an empty/whitespace URL disables
2321    /// the scraper worker pool for this `Website` only.
2322    pub fn with_scraper_worker_connection(
2323        &mut self,
2324        scraper_worker_connection_url: Option<String>,
2325    ) -> &mut Self {
2326        self.scraper_worker_connection_urls = scraper_worker_connection_url.map(|url| {
2327            let url = url.trim();
2328            if url.is_empty() {
2329                Vec::new()
2330            } else {
2331                vec![url.to_string()]
2332            }
2333        });
2334        self
2335    }
2336
2337    #[cfg(not(feature = "decentralized"))]
2338    /// Set the Spider scraper worker URL for scrape requests. This method
2339    /// does nothing if the `decentralized` feature is not enabled.
2340    pub fn with_scraper_worker_connection(
2341        &mut self,
2342        _scraper_worker_connection_url: Option<String>,
2343    ) -> &mut Self {
2344        self
2345    }
2346
2347    #[cfg(feature = "decentralized")]
2348    /// Set multiple Spider scraper worker URLs for scrape requests.
2349    /// Empty/whitespace entries are dropped. An empty resulting list disables
2350    /// the scraper worker pool for this `Website` only.
2351    pub fn with_scraper_worker_connections(&mut self, urls: Vec<String>) -> &mut Self {
2352        self.scraper_worker_connection_urls = Some(
2353            urls.into_iter()
2354                .map(|url| url.trim().to_string())
2355                .filter(|url| !url.is_empty())
2356                .collect(),
2357        );
2358        self
2359    }
2360
2361    #[cfg(not(feature = "decentralized"))]
2362    /// Set multiple Spider scraper worker URLs for scrape requests. This
2363    /// method does nothing if the `decentralized` feature is not enabled.
2364    pub fn with_scraper_worker_connections(&mut self, _urls: Vec<String>) -> &mut Self {
2365        self
2366    }
2367
2368    #[cfg(feature = "chrome")]
2369    /// Set the first-byte watchdog timeout for Chrome navigations. `None`
2370    /// disables it; `Some(d)` fires after `d` of silence on both
2371    /// `Network.responseReceived` and `Network.dataReceived` and force-stops
2372    /// the page so the caller can rotate to a different Chrome backend.
2373    pub fn with_chrome_first_byte_timeout(&mut self, timeout: Option<Duration>) -> &mut Self {
2374        self.chrome_first_byte_timeout = timeout;
2375        self
2376    }
2377
2378    #[cfg(not(feature = "chrome"))]
2379    /// Set the first-byte watchdog timeout for Chrome navigations. This method does nothing if the `chrome` is not enabled.
2380    pub fn with_chrome_first_byte_timeout(&mut self, _timeout: Option<Duration>) -> &mut Self {
2381        self
2382    }
2383
2384    #[cfg(feature = "chrome")]
2385    /// Set the per-fetch jitter window for the first-byte watchdog. `None`
2386    /// disables jitter; `Some(j)` randomizes each fetch's timeout uniformly
2387    /// in `[base, base + j)`. Ignored when the base timeout is `None`.
2388    pub fn with_chrome_first_byte_timeout_jitter(&mut self, jitter: Option<Duration>) -> &mut Self {
2389        self.chrome_first_byte_timeout_jitter = jitter;
2390        self
2391    }
2392
2393    #[cfg(not(feature = "chrome"))]
2394    /// Set the first-byte watchdog jitter window. This method does nothing if the `chrome` is not enabled.
2395    pub fn with_chrome_first_byte_timeout_jitter(
2396        &mut self,
2397        _jitter: Option<Duration>,
2398    ) -> &mut Self {
2399        self
2400    }
2401
2402    /// Set the first-byte watchdog timeout for HTTP fetches. `None`
2403    /// disables it; `Some(d)` wraps each `client.get(url).send()` in
2404    /// `tokio::time::timeout(d + rand(0..jitter))` and returns a
2405    /// synthetic `524 GATEWAY_TIMEOUT` response on fire so the retry
2406    /// path rotates the proxy. Covers stalls between TCP connect and
2407    /// the first byte of the response — distinct from
2408    /// `connect_timeout` (handshake-only) and `chunk_idle_timeout`
2409    /// (body-streaming idle).
2410    pub fn with_http_first_byte_timeout(&mut self, timeout: Option<Duration>) -> &mut Self {
2411        self.http_first_byte_timeout = timeout;
2412        self
2413    }
2414
2415    /// Set the per-fetch jitter window for the HTTP first-byte
2416    /// watchdog. Same semantics as
2417    /// `with_chrome_first_byte_timeout_jitter`.
2418    pub fn with_http_first_byte_timeout_jitter(&mut self, jitter: Option<Duration>) -> &mut Self {
2419        self.http_first_byte_timeout_jitter = jitter;
2420        self
2421    }
2422
2423    #[cfg(not(feature = "chrome"))]
2424    /// Set JS to run on certain pages. This method does nothing if the `chrome` is not enabled.
2425    pub fn with_execution_scripts(
2426        &mut self,
2427        _execution_scripts: Option<ExecutionScriptsMap>,
2428    ) -> &mut Self {
2429        self
2430    }
2431
2432    #[cfg(feature = "chrome")]
2433    /// Set JS to run on certain pages. This method does nothing if the `chrome` is not enabled.
2434    pub fn with_execution_scripts(
2435        &mut self,
2436        execution_scripts: Option<ExecutionScriptsMap>,
2437    ) -> &mut Self {
2438        self.execution_scripts =
2439            crate::features::chrome_common::convert_to_trie_execution_scripts(&execution_scripts);
2440        self
2441    }
2442
2443    #[cfg(not(feature = "chrome"))]
2444    /// Run web automated actions on certain pages. This method does nothing if the `chrome` is not enabled.
2445    pub fn with_automation_scripts(
2446        &mut self,
2447        _automation_scripts: Option<AutomationScriptsMap>,
2448    ) -> &mut Self {
2449        self
2450    }
2451
2452    #[cfg(feature = "chrome")]
2453    /// Run web automated actions on certain pages. This method does nothing if the `chrome` is not enabled.
2454    pub fn with_automation_scripts(
2455        &mut self,
2456        automation_scripts: Option<AutomationScriptsMap>,
2457    ) -> &mut Self {
2458        self.automation_scripts =
2459            crate::features::chrome_common::convert_to_trie_automation_scripts(&automation_scripts);
2460        self
2461    }
2462
2463    /// Set a crawl budget per path with levels support /a/b/c or for all paths with "*". This does nothing without the `budget` flag enabled.
2464    pub fn with_budget(&mut self, budget: Option<hashbrown::HashMap<&str, u32>>) -> &mut Self {
2465        self.budget = match budget {
2466            Some(budget) => {
2467                let mut crawl_budget: hashbrown::HashMap<
2468                    case_insensitive_string::CaseInsensitiveString,
2469                    u32,
2470                > = hashbrown::HashMap::new();
2471
2472                for b in budget.into_iter() {
2473                    crawl_budget.insert(
2474                        case_insensitive_string::CaseInsensitiveString::from(b.0),
2475                        b.1,
2476                    );
2477                }
2478
2479                Some(crawl_budget)
2480            }
2481            _ => None,
2482        };
2483        self
2484    }
2485
2486    /// Group external domains to treat the crawl as one. If None is passed this will clear all prior domains.
2487    pub fn with_external_domains<'a, 'b>(
2488        &mut self,
2489        external_domains: Option<impl Iterator<Item = String> + 'a>,
2490    ) -> &mut Self {
2491        match external_domains {
2492            Some(external_domains) => {
2493                self.external_domains_caseless = external_domains
2494                    .into_iter()
2495                    .filter_map(|d| {
2496                        if d == "*" {
2497                            Some("*".into())
2498                        } else {
2499                            let host = get_domain_from_url(&d);
2500
2501                            if !host.is_empty() {
2502                                Some(host.into())
2503                            } else {
2504                                None
2505                            }
2506                        }
2507                    })
2508                    .collect::<hashbrown::HashSet<case_insensitive_string::CaseInsensitiveString>>()
2509                    .into();
2510            }
2511            _ => self.external_domains_caseless = Default::default(),
2512        }
2513
2514        self
2515    }
2516
2517    /// Dangerously accept invalid certificates - this should be used as a last resort.
2518    pub fn with_danger_accept_invalid_certs(&mut self, accept_invalid_certs: bool) -> &mut Self {
2519        self.accept_invalid_certs = accept_invalid_certs;
2520        self
2521    }
2522
2523    /// Normalize the content de-duplicating trailing slash pages and other pages that can be duplicated. This may initially show the link in your links_visited or subscription calls but, the following links will not be crawled.
2524    pub fn with_normalize(&mut self, normalize: bool) -> &mut Self {
2525        self.normalize = normalize;
2526        self
2527    }
2528
2529    #[cfg(not(feature = "disk"))]
2530    /// Store all the links found on the disk to share the state. This does nothing without the `disk` flag enabled.
2531    pub fn with_shared_state(&mut self, _shared: bool) -> &mut Self {
2532        self
2533    }
2534
2535    /// Store all the links found on the disk to share the state. This does nothing without the `disk` flag enabled.
2536    #[cfg(feature = "disk")]
2537    pub fn with_shared_state(&mut self, shared: bool) -> &mut Self {
2538        self.shared = shared;
2539        self
2540    }
2541
2542    #[cfg(not(feature = "chrome"))]
2543    /// Overrides default host system timezone with the specified one. This does nothing without the `chrome` flag enabled.
2544    pub fn with_timezone_id(&mut self, _timezone_id: Option<String>) -> &mut Self {
2545        self
2546    }
2547
2548    #[cfg(feature = "chrome")]
2549    /// Overrides default host system timezone with the specified one. This does nothing without the `chrome` flag enabled.
2550    pub fn with_timezone_id(&mut self, timezone_id: Option<String>) -> &mut Self {
2551        self.timezone_id = timezone_id.map(|timezone_id| timezone_id.into());
2552        self
2553    }
2554
2555    #[cfg(not(feature = "chrome"))]
2556    /// Overrides default host system locale with the specified one. This does nothing without the `chrome` flag enabled.
2557    pub fn with_locale(&mut self, _locale: Option<String>) -> &mut Self {
2558        self
2559    }
2560
2561    #[cfg(feature = "chrome")]
2562    /// Overrides default host system locale with the specified one. This does nothing without the `chrome` flag enabled.
2563    pub fn with_locale(&mut self, locale: Option<String>) -> &mut Self {
2564        self.locale = locale.map(|locale| locale.into());
2565        self
2566    }
2567
2568    #[cfg(feature = "chrome")]
2569    /// Track the events made via chrome.
2570    pub fn with_event_tracker(&mut self, track_events: Option<ChromeEventTracker>) -> &mut Self {
2571        self.track_events = track_events;
2572        self
2573    }
2574
2575    /// Set the chrome screenshot configuration. This does nothing without the `chrome` flag enabled.
2576    #[cfg(not(feature = "chrome"))]
2577    pub fn with_screenshot(&mut self, _screenshot_config: Option<ScreenShotConfig>) -> &mut Self {
2578        self
2579    }
2580
2581    /// Set the chrome screenshot configuration. This does nothing without the `chrome` flag enabled.
2582    #[cfg(feature = "chrome")]
2583    pub fn with_screenshot(&mut self, screenshot_config: Option<ScreenShotConfig>) -> &mut Self {
2584        self.screenshot = screenshot_config;
2585        self
2586    }
2587
2588    /// Set the max amount of bytes to collect per page. This method does nothing if the `chrome` is not enabled.
2589    pub fn with_max_page_bytes(&mut self, max_page_bytes: Option<f64>) -> &mut Self {
2590        self.max_page_bytes = max_page_bytes;
2591        self
2592    }
2593
2594    /// Set the max amount of bytes to collected for the browser context. This method does nothing if the `chrome` is not enabled.
2595    pub fn with_max_bytes_allowed(&mut self, max_bytes_allowed: Option<u64>) -> &mut Self {
2596        self.max_bytes_allowed = max_bytes_allowed;
2597        self
2598    }
2599
2600    /// Block assets from loading from the network.
2601    pub fn with_block_assets(&mut self, only_html: bool) -> &mut Self {
2602        self.only_html = only_html;
2603        self
2604    }
2605
2606    /// Modify the headers to mimic a real browser.
2607    pub fn with_modify_headers(&mut self, modify_headers: bool) -> &mut Self {
2608        self.modify_headers = modify_headers;
2609        self
2610    }
2611
2612    /// Modify the HTTP client headers to mimic a real browser.
2613    pub fn with_modify_http_client_headers(
2614        &mut self,
2615        modify_http_client_headers: bool,
2616    ) -> &mut Self {
2617        self.modify_http_client_headers = modify_http_client_headers;
2618        self
2619    }
2620
2621    /// Set the cache policy.
2622    pub fn with_cache_policy(&mut self, cache_policy: Option<BasicCachePolicy>) -> &mut Self {
2623        self.cache_policy = cache_policy;
2624        self
2625    }
2626
2627    #[cfg(feature = "webdriver")]
2628    /// Set the WebDriver configuration. This does nothing without the `webdriver` flag enabled.
2629    pub fn with_webdriver_config(
2630        &mut self,
2631        webdriver_config: Option<WebDriverConfig>,
2632    ) -> &mut Self {
2633        self.webdriver_config = webdriver_config.map(Box::new);
2634        self
2635    }
2636
2637    #[cfg(not(feature = "webdriver"))]
2638    /// Set the WebDriver configuration. This does nothing without the `webdriver` flag enabled.
2639    pub fn with_webdriver_config(
2640        &mut self,
2641        _webdriver_config: Option<WebDriverConfig>,
2642    ) -> &mut Self {
2643        self
2644    }
2645
2646    /// Resolve the HTTP first-byte watchdog args.
2647    ///
2648    /// Returns the configured `http_first_byte_timeout` + `_jitter`
2649    /// whenever the timeout field is `Some(_)` — caller opted in by
2650    /// setting the field, so honor it regardless of proxy count.
2651    ///
2652    /// Previously this was gated on `balance` feature + ≥2 HTTP-eligible
2653    /// proxies, on the premise that the watchdog firing without a
2654    /// rotation target was wasted. That was wrong for the
2655    /// proxy-shrouded NXDOMAIN case: a single proxy still returns an
2656    /// upstream-DNS-shaped 5xx after ~15-22s, and reqwest's `.timeout()`
2657    /// is not enforced through the proxy CONNECT tunnel for that phase.
2658    /// The watchdog is the only knob that fires reliably, and a fast
2659    /// 524 surfaced to the caller is strictly better than waiting for
2660    /// the proxy's internal DNS deadline — even when no rotation
2661    /// target exists.
2662    ///
2663    /// When the timeout field is `None`, returns `(None, None)` —
2664    /// pure passthrough, no overhead. Setting the field on `Configuration`
2665    /// is the opt-in.
2666    #[inline]
2667    pub fn auto_http_first_byte_args(&self) -> (Option<Duration>, Option<Duration>) {
2668        match self.http_first_byte_timeout {
2669            Some(_) => (
2670                self.http_first_byte_timeout,
2671                self.http_first_byte_timeout_jitter,
2672            ),
2673            None => (None, None),
2674        }
2675    }
2676
2677    /// Native Markdown may only replace the page body when nothing downstream
2678    /// needs the page HTML: a single-page run (wildcard budget == 1 — at 1 no
2679    /// further links are ever admitted), no page-link reporting, and no
2680    /// full-resource capture. Mirrors the `single_page() && !return_page_links`
2681    /// definition the `skip_links` fast path already trusts.
2682    #[cfg(feature = "chrome")]
2683    #[inline]
2684    fn native_markdown_safe(&self) -> bool {
2685        !self.return_page_links
2686            && !self.full_resources
2687            && self
2688                .inner_budget
2689                .as_ref()
2690                .and_then(|b| b.get(&case_insensitive_string::CaseInsensitiveString::from("*")))
2691                .is_some_and(|v| *v == 1)
2692    }
2693
2694    /// Build the borrowed chrome fetch parameter bundle.
2695    ///
2696    /// Zero-copy: all fields borrow directly from `self`. Build once at
2697    /// the top of a call chain and pass `&` through the layers to keep
2698    /// the hot path inlineable.
2699    #[cfg(feature = "chrome")]
2700    #[inline]
2701    pub fn chrome_fetch_params(&self) -> crate::utils::ChromeFetchParams<'_> {
2702        crate::utils::ChromeFetchParams {
2703            wait_for: &self.wait_for,
2704            screenshot: &self.screenshot,
2705            openai_config: &self.openai_config,
2706            execution_scripts: &self.execution_scripts,
2707            automation_scripts: &self.automation_scripts,
2708            viewport: &self.viewport,
2709            request_timeout: &self.request_timeout,
2710            track_events: &self.track_events,
2711            cache_policy: &self.cache_policy,
2712            remote_multimodal: &self.remote_multimodal,
2713            remote_cache_read_only: self.chrome_remote_cache_read_only_enabled(),
2714            remote_cache_main_doc_only: self.chrome_remote_cache_main_doc_only_enabled(),
2715            first_byte_timeout: &self.chrome_first_byte_timeout,
2716            first_byte_timeout_jitter: &self.chrome_first_byte_timeout_jitter,
2717            browser_dead: None,
2718            chrome_failover: Some(&self.chrome_failover),
2719            // Auto-populate the endpoint URL for every chrome / smart
2720            // crawl path so the first-byte watchdog can mark the right
2721            // backend bad without each call site having to thread a
2722            // `BrowserController` through its signature. Multi-URL
2723            // failover wins (the most recent successful URL); single-URL
2724            // config is the fallback. `None` for local launches.
2725            chrome_endpoint_url: self
2726                .chrome_failover
2727                .last_connected_url()
2728                .or(self.chrome_connection_url.as_deref()),
2729            enhancements: self.enhancements,
2730            prefer_native_markdown: self.prefer_native_markdown && self.native_markdown_safe(),
2731        }
2732    }
2733
2734    /// Get the cache option to use for the run. This does nothing without the 'cache_request' feature.
2735    #[cfg(any(feature = "cache_request", feature = "chrome_remote_cache"))]
2736    pub(crate) fn get_cache_options(&self) -> Option<crate::utils::CacheOptions> {
2737        use crate::utils::CacheOptions;
2738        if !self.cache {
2739            return None;
2740        }
2741        let auth_token = self
2742            .headers
2743            .as_ref()
2744            .and_then(|headers| {
2745                headers
2746                    .0
2747                    .get("authorization")
2748                    .or_else(|| headers.0.get("Authorization"))
2749            })
2750            .map(|s| s.to_owned());
2751
2752        // When using in-memory cache (cache_mem), auto-enable skip_browser
2753        // since the cached HTML was already rendered by a prior Chrome crawl
2754        // and re-rendering through Chrome is redundant. The browser only
2755        // launches when the cache has no hit for the requested page.
2756        #[cfg(feature = "cache_mem")]
2757        let skip_browser = true;
2758        #[cfg(not(feature = "cache_mem"))]
2759        let skip_browser = self.cache_skip_browser;
2760
2761        match auth_token {
2762            Some(token) if !token.is_empty() => {
2763                if let Ok(token_str) = token.to_str() {
2764                    if skip_browser {
2765                        Some(CacheOptions::SkipBrowserAuthorized(token_str.into()))
2766                    } else {
2767                        Some(CacheOptions::Authorized(token_str.into()))
2768                    }
2769                } else if skip_browser {
2770                    Some(CacheOptions::SkipBrowser)
2771                } else {
2772                    Some(CacheOptions::Yes)
2773                }
2774            }
2775            _ => {
2776                if skip_browser {
2777                    Some(CacheOptions::SkipBrowser)
2778                } else {
2779                    Some(CacheOptions::Yes)
2780                }
2781            }
2782        }
2783    }
2784
2785    /// Get the cache option to use for the run. This does nothing without the 'cache_request' feature.
2786    #[cfg(all(
2787        feature = "chrome",
2788        not(any(feature = "cache_request", feature = "chrome_remote_cache"))
2789    ))]
2790    pub(crate) fn get_cache_options(&self) -> Option<crate::utils::CacheOptions> {
2791        None
2792    }
2793
2794    /// Get the cache option to use for the run when chrome/cache features are disabled.
2795    #[cfg(not(any(
2796        feature = "cache_request",
2797        feature = "chrome_remote_cache",
2798        feature = "chrome"
2799    )))]
2800    #[allow(dead_code)]
2801    pub(crate) fn get_cache_options(&self) -> Option<crate::utils::CacheOptions> {
2802        None
2803    }
2804
2805    /// Build the website configuration when using with_builder.
2806    pub fn build(&self) -> Self {
2807        self.to_owned()
2808    }
2809
2810    #[cfg(feature = "search")]
2811    /// Configure web search integration. This does nothing without the `search` flag enabled.
2812    pub fn with_search_config(&mut self, search_config: Option<SearchConfig>) -> &mut Self {
2813        self.search_config = search_config.map(Box::new);
2814        self
2815    }
2816
2817    #[cfg(not(feature = "search"))]
2818    /// Configure web search integration. This does nothing without the `search` flag enabled.
2819    pub fn with_search_config(&mut self, _search_config: Option<()>) -> &mut Self {
2820        self
2821    }
2822
2823    /// Set a [spider.cloud](https://spider.cloud) API key (Proxy mode).
2824    #[cfg(feature = "spider_cloud")]
2825    pub fn with_spider_cloud(&mut self, api_key: &str) -> &mut Self {
2826        if is_placeholder_api_key(api_key) {
2827            log::warn!("Spider Cloud API key looks like a placeholder — skipping. Get a real key at https://spider.cloud");
2828            return self;
2829        }
2830        self.spider_cloud = Some(Box::new(SpiderCloudConfig::new(api_key)));
2831        self
2832    }
2833
2834    /// Set a [spider.cloud](https://spider.cloud) API key (no-op without `spider_cloud` feature).
2835    #[cfg(not(feature = "spider_cloud"))]
2836    pub fn with_spider_cloud(&mut self, _api_key: &str) -> &mut Self {
2837        self
2838    }
2839
2840    /// Set a [spider.cloud](https://spider.cloud) config.
2841    #[cfg(feature = "spider_cloud")]
2842    pub fn with_spider_cloud_config(&mut self, config: SpiderCloudConfig) -> &mut Self {
2843        self.spider_cloud = Some(Box::new(config));
2844        self
2845    }
2846
2847    /// Set a [spider.cloud](https://spider.cloud) config (no-op without `spider_cloud` feature).
2848    #[cfg(not(feature = "spider_cloud"))]
2849    pub fn with_spider_cloud_config(&mut self, _config: ()) -> &mut Self {
2850        self
2851    }
2852
2853    /// Connect to [Spider Browser Cloud](https://spider.cloud/docs/api#browser)
2854    /// via CDP over WebSocket using an API key.
2855    ///
2856    /// Routes the browser path through the chrome failover. With a single peer
2857    /// this sets `chrome_connection_url` to
2858    /// `wss://browser.spider.cloud/v1/browser?token=API_KEY`; with multiple
2859    /// peers (see [`SpiderBrowserConfig::with_wss_urls`]) it populates the
2860    /// failover list so a healthy peer is selected like the crawler.
2861    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
2862    pub fn with_spider_browser(&mut self, api_key: &str) -> &mut Self {
2863        if is_placeholder_api_key(api_key) {
2864            log::warn!("Spider Browser Cloud API key looks like a placeholder — skipping. Get a real key at https://spider.cloud");
2865            return self;
2866        }
2867        let cfg = SpiderBrowserConfig::new(api_key);
2868        let peers = cfg.connection_urls();
2869        log::info!(
2870            "[spider-browser] configured {} browser peer(s); healthy-peer failover {}",
2871            peers.len(),
2872            if peers.len() > 1 {
2873                "enabled"
2874            } else {
2875                "disabled (single peer)"
2876            }
2877        );
2878        // Route through the chrome failover so the browser path picks a healthy
2879        // peer / fails over with cooldowns, exactly like the crawler. A single
2880        // peer collapses to the single-endpoint path inside with_chrome_connections.
2881        self.with_chrome_connections(peers);
2882        self.spider_browser = Some(Box::new(cfg));
2883        self
2884    }
2885
2886    /// Connect to Spider Browser Cloud (no-op without `spider_cloud` + `chrome` features).
2887    #[cfg(not(all(feature = "spider_cloud", feature = "chrome")))]
2888    pub fn with_spider_browser(&mut self, _api_key: &str) -> &mut Self {
2889        self
2890    }
2891
2892    /// Connect to [Spider Browser Cloud](https://spider.cloud/docs/api#browser)
2893    /// with full configuration (stealth, country, browser type, etc.).
2894    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
2895    pub fn with_spider_browser_config(&mut self, config: SpiderBrowserConfig) -> &mut Self {
2896        let peers = config.connection_urls();
2897        log::info!(
2898            "[spider-browser] configured {} browser peer(s); healthy-peer failover {}",
2899            peers.len(),
2900            if peers.len() > 1 {
2901                "enabled"
2902            } else {
2903                "disabled (single peer)"
2904            }
2905        );
2906        // Route through the chrome failover (healthy-peer selection + cooldowns)
2907        // just like the crawler; single peer → single-endpoint path.
2908        self.with_chrome_connections(peers);
2909        self.spider_browser = Some(Box::new(config));
2910        self
2911    }
2912
2913    /// Connect to Spider Browser Cloud with config (no-op without features).
2914    #[cfg(not(all(feature = "spider_cloud", feature = "chrome")))]
2915    pub fn with_spider_browser_config(&mut self, _config: ()) -> &mut Self {
2916        self
2917    }
2918
2919    /// Set the hedged request (work-stealing) configuration.
2920    #[cfg(feature = "hedge")]
2921    pub fn with_hedge(&mut self, config: crate::utils::hedge::HedgeConfig) -> &mut Self {
2922        self.hedge = Some(config);
2923        self
2924    }
2925
2926    /// Set the hedged request configuration (no-op without `hedge` feature).
2927    #[cfg(not(feature = "hedge"))]
2928    pub fn with_hedge(&mut self, _config: ()) -> &mut Self {
2929        self
2930    }
2931
2932    #[cfg(feature = "auto_throttle")]
2933    /// Set the auto-throttle configuration for latency-based adaptive delay.
2934    pub fn with_auto_throttle(
2935        &mut self,
2936        config: crate::utils::auto_throttle::AutoThrottleConfig,
2937    ) -> &mut Self {
2938        self.auto_throttle = Some(config);
2939        self
2940    }
2941
2942    /// Set the auto-throttle configuration (no-op without `auto_throttle` feature).
2943    #[cfg(not(feature = "auto_throttle"))]
2944    pub fn with_auto_throttle(&mut self, _config: ()) -> &mut Self {
2945        self
2946    }
2947
2948    #[cfg(feature = "etag_cache")]
2949    /// Enable or disable ETag / conditional request caching for bandwidth-efficient re-crawls.
2950    pub fn with_etag_cache(&mut self, enabled: bool) -> &mut Self {
2951        self.etag_cache = enabled;
2952        self
2953    }
2954
2955    /// Enable or disable ETag caching (no-op without `etag_cache` feature).
2956    #[cfg(not(feature = "etag_cache"))]
2957    pub fn with_etag_cache(&mut self, _enabled: bool) -> &mut Self {
2958        self
2959    }
2960
2961    #[cfg(feature = "warc")]
2962    /// Configure WARC output for writing a web archive file during crawl.
2963    pub fn with_warc(&mut self, config: crate::utils::warc::WarcConfig) -> &mut Self {
2964        self.warc = Some(config);
2965        self
2966    }
2967
2968    /// Configure WARC output (no-op without `warc` feature).
2969    #[cfg(not(feature = "warc"))]
2970    pub fn with_warc(&mut self, _config: ()) -> &mut Self {
2971        self
2972    }
2973}
2974
2975/// Search provider configuration for web search integration.
2976#[cfg(feature = "search")]
2977#[derive(Debug, Clone, PartialEq)]
2978#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2979pub struct SearchConfig {
2980    /// The search provider to use.
2981    pub provider: SearchProviderType,
2982    /// API key for the search provider.
2983    pub api_key: String,
2984    /// Custom API URL (overrides default endpoint for the provider).
2985    pub api_url: Option<String>,
2986    /// Default search options.
2987    pub default_options: Option<SearchOptions>,
2988}
2989
2990#[cfg(feature = "search")]
2991impl SearchConfig {
2992    /// Create a new search configuration.
2993    pub fn new(provider: SearchProviderType, api_key: impl Into<String>) -> Self {
2994        Self {
2995            provider,
2996            api_key: api_key.into(),
2997            api_url: None,
2998            default_options: None,
2999        }
3000    }
3001
3002    /// Use a custom API endpoint for this provider.
3003    pub fn with_api_url(mut self, url: impl Into<String>) -> Self {
3004        self.api_url = Some(url.into());
3005        self
3006    }
3007
3008    /// Set default search options.
3009    pub fn with_default_options(mut self, options: SearchOptions) -> Self {
3010        self.default_options = Some(options);
3011        self
3012    }
3013
3014    /// Check if this configuration is valid and search is enabled.
3015    ///
3016    /// Returns true if an API key is set or a custom API URL is configured.
3017    pub fn is_enabled(&self) -> bool {
3018        !self.api_key.is_empty() || self.api_url.is_some()
3019    }
3020}
3021
3022/// Available search providers.
3023#[cfg(feature = "search")]
3024#[derive(Debug, Clone, Default, PartialEq, Eq)]
3025#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
3026pub enum SearchProviderType {
3027    /// Serper.dev - Google SERP API (high quality).
3028    #[default]
3029    Serper,
3030    /// Brave Search API (privacy-focused).
3031    Brave,
3032    /// Microsoft Bing Web Search API.
3033    Bing,
3034    /// Tavily AI Search (optimized for LLMs).
3035    Tavily,
3036}
3037
3038// ─── Spider Cloud ───────────────────────────────────────────────────────────
3039
3040/// Integration mode for [spider.cloud](https://spider.cloud).
3041#[cfg(feature = "spider_cloud")]
3042#[derive(Debug, Clone, Default, PartialEq, Eq)]
3043#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
3044pub enum SpiderCloudMode {
3045    /// Route all HTTP requests through `proxy.spider.cloud`.
3046    /// This is the simplest mode — the existing fetch pipeline works
3047    /// unmodified, traffic goes through the proxy transparently.
3048    #[default]
3049    Proxy,
3050    /// Use the spider.cloud `POST /crawl` API (with `limit: 1`) for each page.
3051    /// Best for simple scraping needs.
3052    Api,
3053    /// Use the spider.cloud `POST /unblocker` API for anti-bot bypass.
3054    /// Best for hard-to-get pages behind advanced bot protection.
3055    Unblocker,
3056    /// Direct fetch first; fall back to spider.cloud API on
3057    /// 403 / 429 / 503 or connection errors.
3058    Fallback,
3059    /// Intelligent mode: proxy by default, automatically falls back to
3060    /// `/unblocker` when it detects bot protection (403, 429, 503, CAPTCHA
3061    /// pages, Cloudflare challenges, empty bodies on HTML pages, etc.).
3062    /// This is the recommended mode for production use.
3063    Smart,
3064}
3065
3066/// Return format for Spider Cloud API responses.
3067#[cfg(feature = "spider_cloud")]
3068#[derive(Debug, Clone, Default, PartialEq, Eq)]
3069#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
3070pub enum SpiderCloudReturnFormat {
3071    /// Original HTML (default).
3072    #[default]
3073    #[cfg_attr(feature = "serde", serde(rename = "raw"))]
3074    Raw,
3075    /// Clean markdown — ideal for LLM pipelines.
3076    #[cfg_attr(feature = "serde", serde(rename = "markdown"))]
3077    Markdown,
3078    /// CommonMark-flavored markdown.
3079    #[cfg_attr(feature = "serde", serde(rename = "commonmark"))]
3080    CommonMark,
3081    /// Plain text with markup stripped.
3082    #[cfg_attr(feature = "serde", serde(rename = "text"))]
3083    Text,
3084    /// Raw bytes (no encoding conversion).
3085    #[cfg_attr(feature = "serde", serde(rename = "bytes"))]
3086    Bytes,
3087}
3088
3089#[cfg(feature = "spider_cloud")]
3090impl SpiderCloudReturnFormat {
3091    /// The API wire value sent to spider.cloud.
3092    pub fn as_str(&self) -> &'static str {
3093        match self {
3094            Self::Raw => "raw",
3095            Self::Markdown => "markdown",
3096            Self::CommonMark => "commonmark",
3097            Self::Text => "text",
3098            Self::Bytes => "bytes",
3099        }
3100    }
3101}
3102
3103#[cfg(feature = "spider_cloud")]
3104impl std::fmt::Display for SpiderCloudReturnFormat {
3105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3106        f.write_str(self.as_str())
3107    }
3108}
3109
3110#[cfg(feature = "spider_cloud")]
3111impl From<&str> for SpiderCloudReturnFormat {
3112    fn from(s: &str) -> Self {
3113        match s {
3114            "markdown" | "Markdown" | "MARKDOWN" => Self::Markdown,
3115            "commonmark" | "CommonMark" | "COMMONMARK" => Self::CommonMark,
3116            "text" | "Text" | "TEXT" => Self::Text,
3117            "bytes" | "Bytes" | "BYTES" => Self::Bytes,
3118            _ => Self::Raw,
3119        }
3120    }
3121}
3122
3123/// Configuration for spider.cloud integration.
3124///
3125/// Spider Cloud provides anti-bot bypass, proxy rotation, and high-throughput
3126/// data collection. Sign up at <https://spider.cloud> to obtain an API key.
3127#[cfg(feature = "spider_cloud")]
3128#[derive(Debug, Clone, PartialEq, Eq)]
3129#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
3130pub struct SpiderCloudConfig {
3131    /// API key / secret. Sign up at <https://spider.cloud> to get one.
3132    pub api_key: String,
3133    /// Integration mode.
3134    #[cfg_attr(feature = "serde", serde(default))]
3135    pub mode: SpiderCloudMode,
3136    /// API base URL (default: `https://api.spider.cloud`).
3137    #[cfg_attr(
3138        feature = "serde",
3139        serde(default = "SpiderCloudConfig::default_api_url")
3140    )]
3141    pub api_url: String,
3142    /// Proxy URL (default: `https://proxy.spider.cloud`).
3143    #[cfg_attr(
3144        feature = "serde",
3145        serde(default = "SpiderCloudConfig::default_proxy_url")
3146    )]
3147    pub proxy_url: String,
3148    /// Return format for API responses (default: [`SpiderCloudReturnFormat::Raw`]).
3149    #[cfg_attr(feature = "serde", serde(default))]
3150    pub return_format: SpiderCloudReturnFormat,
3151    /// Request multiple return formats in a single crawl.
3152    ///
3153    /// When set, the API returns `content` as an object keyed by format
3154    /// (e.g. `{"markdown": "...", "raw": "..."}`). The primary `return_format`
3155    /// is stored in [`Page::get_content`](crate::page::Page::get_content) and
3156    /// the extras are accessible via [`Page::get_content_for`](crate::page::Page::get_content_for).
3157    #[cfg_attr(
3158        feature = "serde",
3159        serde(default, skip_serializing_if = "Option::is_none")
3160    )]
3161    pub return_formats: Option<Vec<SpiderCloudReturnFormat>>,
3162    /// Extra params forwarded in API mode (e.g. `stealth`, `fingerprint`, `cache`).
3163    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
3164    pub extra_params: Option<hashbrown::HashMap<String, serde_json::Value>>,
3165}
3166
3167#[cfg(feature = "spider_cloud")]
3168impl Default for SpiderCloudConfig {
3169    fn default() -> Self {
3170        Self {
3171            api_key: String::new(),
3172            mode: SpiderCloudMode::default(),
3173            api_url: Self::default_api_url(),
3174            proxy_url: Self::default_proxy_url(),
3175            return_format: SpiderCloudReturnFormat::default(),
3176            return_formats: None,
3177            extra_params: None,
3178        }
3179    }
3180}
3181
3182#[cfg(feature = "spider_cloud")]
3183impl SpiderCloudConfig {
3184    /// Create a new config with defaults (Proxy mode).
3185    pub fn new(api_key: impl Into<String>) -> Self {
3186        Self {
3187            api_key: api_key.into(),
3188            ..Default::default()
3189        }
3190    }
3191
3192    /// Set the integration mode.
3193    pub fn with_mode(mut self, mode: SpiderCloudMode) -> Self {
3194        self.mode = mode;
3195        self
3196    }
3197
3198    /// Set a custom API base URL.
3199    pub fn with_api_url(mut self, url: impl Into<String>) -> Self {
3200        self.api_url = url.into();
3201        self
3202    }
3203
3204    /// Set a custom proxy URL.
3205    pub fn with_proxy_url(mut self, url: impl Into<String>) -> Self {
3206        self.proxy_url = url.into();
3207        self
3208    }
3209
3210    /// Set the return format for API responses.
3211    ///
3212    /// Accepts `SpiderCloudReturnFormat` directly or a string like `"markdown"`:
3213    /// ```ignore
3214    /// config.with_return_format(SpiderCloudReturnFormat::Markdown)
3215    /// config.with_return_format("markdown")
3216    /// ```
3217    pub fn with_return_format(mut self, fmt: impl Into<SpiderCloudReturnFormat>) -> Self {
3218        self.return_format = fmt.into();
3219        self
3220    }
3221
3222    /// Request multiple return formats in a single crawl.
3223    ///
3224    /// The first format becomes the primary content (accessible via
3225    /// [`Page::get_content`](crate::page::Page::get_content)), and all formats are
3226    /// accessible via [`Page::get_content_for`](crate::page::Page::get_content_for).
3227    ///
3228    /// ```ignore
3229    /// config.with_return_formats(vec![
3230    ///     SpiderCloudReturnFormat::Markdown,
3231    ///     SpiderCloudReturnFormat::Raw,
3232    /// ])
3233    /// ```
3234    pub fn with_return_formats(mut self, formats: Vec<SpiderCloudReturnFormat>) -> Self {
3235        // Deduplicate while preserving order.
3236        let mut seen = Vec::with_capacity(formats.len());
3237        for f in formats {
3238            if !seen.contains(&f) {
3239                seen.push(f);
3240            }
3241        }
3242        if let Some(first) = seen.first() {
3243            self.return_format = first.clone();
3244        }
3245        self.return_formats = Some(seen);
3246        self
3247    }
3248
3249    /// Check if multiple return formats are requested.
3250    pub fn has_multiple_formats(&self) -> bool {
3251        self.return_formats.as_ref().is_some_and(|f| f.len() > 1)
3252    }
3253
3254    /// Set extra params for API mode.
3255    pub fn with_extra_params(
3256        mut self,
3257        params: hashbrown::HashMap<String, serde_json::Value>,
3258    ) -> Self {
3259        self.extra_params = Some(params);
3260        self
3261    }
3262
3263    /// Determine if a response should trigger a spider.cloud API fallback.
3264    ///
3265    /// This encapsulates the intelligence about which status codes and
3266    /// content patterns indicate the page needs spider.cloud's help.
3267    ///
3268    /// Checks for:
3269    /// - HTTP 403 (Forbidden) — typically bot protection
3270    /// - HTTP 429 (Too Many Requests) — rate limiting
3271    /// - HTTP 503 (Service Unavailable) — often Cloudflare/DDoS protection
3272    /// - HTTP 520-530 (Cloudflare error range)
3273    /// - HTTP 5xx (server errors)
3274    /// - Empty body on what should be an HTML page
3275    /// - Known CAPTCHA / challenge page markers in the response body
3276    pub fn should_fallback(&self, status_code: u16, body: Option<&[u8]>) -> bool {
3277        match self.mode {
3278            SpiderCloudMode::Api | SpiderCloudMode::Unblocker => false, // already using API
3279            SpiderCloudMode::Proxy => false,                            // proxy-only, no fallback
3280            SpiderCloudMode::Fallback | SpiderCloudMode::Smart => {
3281                // Status code triggers
3282                if matches!(status_code, 403 | 429 | 503 | 520..=530) {
3283                    return true;
3284                }
3285                if status_code >= 500 {
3286                    return true;
3287                }
3288
3289                // Content-based triggers (Smart mode only)
3290                if self.mode == SpiderCloudMode::Smart {
3291                    if let Some(body) = body {
3292                        // Empty body when we expected HTML
3293                        if body.is_empty() {
3294                            return true;
3295                        }
3296
3297                        // Check for bot protection / CAPTCHA markers in the body
3298                        // (only check first 4KB for performance)
3299                        let check_len = body.len().min(4096);
3300                        let snippet = String::from_utf8_lossy(&body[..check_len]);
3301                        let lower = snippet.to_lowercase();
3302
3303                        // Cloudflare challenge
3304                        if lower.contains("cf-browser-verification")
3305                            || lower.contains("cloudflare") && lower.contains("challenge-platform")
3306                        {
3307                            return true;
3308                        }
3309
3310                        // Generic CAPTCHA / bot detection markers
3311                        if lower.contains("captcha") && lower.contains("challenge")
3312                            || lower.contains("please verify you are a human")
3313                            || lower.contains("access denied") && lower.contains("automated")
3314                            || lower.contains("bot detection")
3315                        {
3316                            return true;
3317                        }
3318
3319                        // Distil Networks / Imperva / Akamai patterns
3320                        if lower.contains("distil_r_captcha")
3321                            || lower.contains("_imperva")
3322                            || lower.contains("akamai") && lower.contains("bot manager")
3323                        {
3324                            return true;
3325                        }
3326                    }
3327                }
3328
3329                false
3330            }
3331        }
3332    }
3333
3334    /// Get the fallback API route for this config.
3335    ///
3336    /// - `Smart` mode → `/unblocker` (best for bot-protected pages)
3337    /// - `Fallback` mode → `/crawl` (general purpose)
3338    /// - Other modes → `/crawl` (default)
3339    pub fn fallback_route(&self) -> &'static str {
3340        match self.mode {
3341            SpiderCloudMode::Smart | SpiderCloudMode::Unblocker => "unblocker",
3342            _ => "crawl",
3343        }
3344    }
3345
3346    /// Whether this mode uses the proxy transport layer.
3347    pub fn uses_proxy(&self) -> bool {
3348        matches!(
3349            self.mode,
3350            SpiderCloudMode::Proxy | SpiderCloudMode::Fallback | SpiderCloudMode::Smart
3351        )
3352    }
3353
3354    fn default_api_url() -> String {
3355        "https://api.spider.cloud".to_string()
3356    }
3357
3358    fn default_proxy_url() -> String {
3359        "https://proxy.spider.cloud".to_string()
3360    }
3361}
3362
3363// ─── Spider Browser Cloud ────────────────────────────────────────────────────
3364
3365/// Configuration for [Spider Browser Cloud](https://spider.cloud/docs/api#browser).
3366///
3367/// Connects to a remote Chromium instance via CDP over WebSocket at
3368/// `wss://browser.spider.cloud/v1/browser`.  Authentication is via
3369/// `?token=API_KEY` query parameter.
3370///
3371/// Optional query parameters: `stealth`, `browser`, `country`.
3372#[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3373#[derive(Debug, Clone, PartialEq, Eq)]
3374#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
3375pub struct SpiderBrowserConfig {
3376    /// API key / secret. Sign up at <https://spider.cloud> to get one.
3377    pub api_key: String,
3378    /// WebSocket base URL (default: `wss://browser.spider.cloud/v1/browser`).
3379    #[cfg_attr(
3380        feature = "serde",
3381        serde(default = "SpiderBrowserConfig::default_wss_url")
3382    )]
3383    pub wss_url: String,
3384    /// Optional list of WSS peer base URLs for healthy-peer failover. When set
3385    /// with two or more peers, the browser path routes through the crawler's
3386    /// `ChromeConnectionFailover`: each peer is health-tracked independently, a
3387    /// peer that times out is put on cooldown and skipped until it recovers, and
3388    /// connections stick to the last good peer — exactly how the crawler scales
3389    /// chrome across peers. Falls back to the single `wss_url` when unset.
3390    #[cfg_attr(
3391        feature = "serde",
3392        serde(default, skip_serializing_if = "Option::is_none")
3393    )]
3394    pub wss_urls: Option<Vec<String>>,
3395    /// Enable stealth mode (anti-fingerprinting). Sent as `stealth=true` query param.
3396    #[cfg_attr(feature = "serde", serde(default))]
3397    pub stealth: bool,
3398    /// Browser type to request (e.g. `"chrome"`, `"firefox"`). Sent as `browser=<value>`.
3399    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
3400    pub browser: Option<String>,
3401    /// Country code for geo-targeting (e.g. `"us"`, `"gb"`). Sent as `country=<value>`.
3402    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
3403    pub country: Option<String>,
3404    /// Extra query parameters appended to the WSS URL.
3405    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
3406    pub extra_params: Option<Vec<(String, String)>>,
3407}
3408
3409#[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3410impl Default for SpiderBrowserConfig {
3411    fn default() -> Self {
3412        Self {
3413            api_key: String::new(),
3414            wss_url: Self::default_wss_url(),
3415            wss_urls: None,
3416            stealth: false,
3417            browser: None,
3418            country: None,
3419            extra_params: None,
3420        }
3421    }
3422}
3423
3424#[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3425impl SpiderBrowserConfig {
3426    /// Create a new config with the given API key.
3427    pub fn new(api_key: impl Into<String>) -> Self {
3428        Self {
3429            api_key: api_key.into(),
3430            ..Default::default()
3431        }
3432    }
3433
3434    /// Set a custom WSS base URL.
3435    pub fn with_wss_url(mut self, url: impl Into<String>) -> Self {
3436        self.wss_url = url.into();
3437        self
3438    }
3439
3440    /// Set multiple WSS peer base URLs for healthy-peer failover.
3441    ///
3442    /// With two or more peers the browser path acquires a *healthy* peer via the
3443    /// crawler's `ChromeConnectionFailover` (cooldown-tracked, sticky to the last
3444    /// good peer) instead of hammering one possibly-cold endpoint. An empty or
3445    /// single-element list collapses to the single-endpoint path.
3446    pub fn with_wss_urls(mut self, urls: Vec<String>) -> Self {
3447        self.wss_urls = Some(urls);
3448        self
3449    }
3450
3451    /// Enable or disable stealth mode.
3452    pub fn with_stealth(mut self, stealth: bool) -> Self {
3453        self.stealth = stealth;
3454        self
3455    }
3456
3457    /// Set the browser type to request.
3458    pub fn with_browser(mut self, browser: impl Into<String>) -> Self {
3459        self.browser = Some(browser.into());
3460        self
3461    }
3462
3463    /// Set the country code for geo-targeting.
3464    pub fn with_country(mut self, country: impl Into<String>) -> Self {
3465        self.country = Some(country.into());
3466        self
3467    }
3468
3469    /// Add extra query parameters.
3470    pub fn with_extra_params(mut self, params: Vec<(String, String)>) -> Self {
3471        self.extra_params = Some(params);
3472        self
3473    }
3474
3475    /// Build the full WSS connection URL with authentication and options.
3476    ///
3477    /// Returns a URL like:
3478    /// `wss://browser.spider.cloud/v1/browser?token=KEY&stealth=true&country=us`
3479    pub fn connection_url(&self) -> String {
3480        self.build_connection_url(&self.wss_url)
3481    }
3482
3483    /// Build the per-peer connection URLs used for healthy-peer failover.
3484    ///
3485    /// When [`wss_urls`](Self::wss_urls) holds one or more peer base URLs,
3486    /// returns one fully-built connection URL per peer (auth + options applied
3487    /// to each) so [`ChromeConnectionFailover`] can rotate across healthy peers
3488    /// and cool down ones that time out. Falls back to a single-element vec
3489    /// built from [`wss_url`](Self::wss_url) when no peer list is configured.
3490    pub fn connection_urls(&self) -> Vec<String> {
3491        match self.wss_urls {
3492            Some(ref bases) if !bases.is_empty() => {
3493                bases.iter().map(|b| self.build_connection_url(b)).collect()
3494            }
3495            _ => vec![self.connection_url()],
3496        }
3497    }
3498
3499    /// Apply auth + query options (`token`, `stealth`, `browser`, `country`,
3500    /// `extra_params`) to a single WSS base URL. Shared by
3501    /// [`connection_url`](Self::connection_url) and
3502    /// [`connection_urls`](Self::connection_urls) so every peer is built
3503    /// identically.
3504    fn build_connection_url(&self, base: &str) -> String {
3505        let mut url = base.to_string();
3506
3507        // Start query string
3508        if url.contains('?') {
3509            url.push('&');
3510        } else {
3511            url.push('?');
3512        }
3513        url.push_str("token=");
3514        url.push_str(&self.api_key);
3515
3516        if self.stealth {
3517            url.push_str("&stealth=true");
3518        }
3519        if let Some(ref browser) = self.browser {
3520            url.push_str("&browser=");
3521            url.push_str(browser);
3522        }
3523        if let Some(ref country) = self.country {
3524            url.push_str("&country=");
3525            url.push_str(country);
3526        }
3527        if let Some(ref extra) = self.extra_params {
3528            for (k, v) in extra {
3529                url.push('&');
3530                url.push_str(k);
3531                url.push('=');
3532                url.push_str(v);
3533            }
3534        }
3535
3536        url
3537    }
3538
3539    fn default_wss_url() -> String {
3540        "wss://browser.spider.cloud/v1/browser".to_string()
3541    }
3542}
3543
3544#[cfg(all(test, feature = "chrome"))]
3545mod native_markdown_gate_tests {
3546    use super::*;
3547
3548    #[test]
3549    fn native_markdown_gate_matrix() {
3550        let mut no_budget = Configuration::default();
3551        no_budget.prefer_native_markdown = true;
3552        assert!(!no_budget.chrome_fetch_params().prefer_native_markdown);
3553
3554        let mut limit_five = Configuration::default();
3555        limit_five.prefer_native_markdown = true;
3556        limit_five.with_limit(5);
3557        limit_five.configure_budget();
3558        assert!(!limit_five.chrome_fetch_params().prefer_native_markdown);
3559
3560        let mut page_links = Configuration::default();
3561        page_links.prefer_native_markdown = true;
3562        page_links.with_limit(1);
3563        page_links.return_page_links = true;
3564        page_links.configure_budget();
3565        assert!(!page_links.chrome_fetch_params().prefer_native_markdown);
3566
3567        let mut full_resources = Configuration::default();
3568        full_resources.prefer_native_markdown = true;
3569        full_resources.with_limit(1);
3570        full_resources.full_resources = true;
3571        full_resources.configure_budget();
3572        assert!(!full_resources.chrome_fetch_params().prefer_native_markdown);
3573
3574        let mut clean = Configuration::default();
3575        clean.prefer_native_markdown = true;
3576        clean.with_limit(1);
3577        clean.configure_budget();
3578        assert!(clean.chrome_fetch_params().prefer_native_markdown);
3579    }
3580}
3581
3582#[cfg(test)]
3583mod tests {
3584    use super::*;
3585
3586    #[test]
3587    fn test_configuration_defaults() {
3588        let config = Configuration::default();
3589        assert!(!config.respect_robots_txt);
3590        assert!(!config.subdomains);
3591        assert!(!config.tld);
3592        assert_eq!(config.delay, 0);
3593        assert!(config.user_agent.is_none());
3594        assert!(config.blacklist_url.is_none());
3595        assert!(config.whitelist_url.is_none());
3596        assert!(config.proxies.is_none());
3597        assert!(!config.http2_prior_knowledge);
3598    }
3599
3600    #[test]
3601    fn test_redirect_policy_variants() {
3602        assert_eq!(RedirectPolicy::default(), RedirectPolicy::Loose);
3603        let strict = RedirectPolicy::Strict;
3604        let none = RedirectPolicy::None;
3605        assert_ne!(strict, RedirectPolicy::Loose);
3606        assert_ne!(none, RedirectPolicy::Loose);
3607        assert_ne!(strict, none);
3608    }
3609
3610    #[test]
3611    fn test_redirect_limit_is_opt_in_for_chrome_path() {
3612        // Fresh config preserves prior behavior: no flag, no Chrome enforcement.
3613        let fresh = Configuration::default();
3614        assert!(
3615            !fresh.redirect_limit_set,
3616            "Configuration::default() must not claim the redirect_limit was set"
3617        );
3618
3619        // Explicit opt-in flips the flag and records the cap.
3620        let mut opt_in = Configuration::default();
3621        opt_in.with_redirect_limit(3);
3622        assert!(opt_in.redirect_limit_set);
3623        assert_eq!(opt_in.redirect_limit, 3);
3624    }
3625
3626    #[test]
3627    fn test_proxy_ignore_variants() {
3628        assert_eq!(ProxyIgnore::default(), ProxyIgnore::No);
3629        let chrome = ProxyIgnore::Chrome;
3630        let http = ProxyIgnore::Http;
3631        assert_ne!(chrome, ProxyIgnore::No);
3632        assert_ne!(http, ProxyIgnore::No);
3633        assert_ne!(chrome, http);
3634    }
3635
3636    #[test]
3637    fn test_request_proxy_construction() {
3638        let proxy = RequestProxy {
3639            addr: "http://proxy.example.com:8080".to_string(),
3640            ignore: ProxyIgnore::No,
3641        };
3642        assert_eq!(proxy.addr, "http://proxy.example.com:8080");
3643        assert_eq!(proxy.ignore, ProxyIgnore::No);
3644    }
3645
3646    #[test]
3647    fn test_request_proxy_default() {
3648        let proxy = RequestProxy::default();
3649        assert!(proxy.addr.is_empty());
3650        assert_eq!(proxy.ignore, ProxyIgnore::No);
3651    }
3652
3653    #[test]
3654    fn test_configuration_blacklist_setup() {
3655        let mut config = Configuration::default();
3656        config.blacklist_url = Some(vec![
3657            "https://example.com/private".into(),
3658            "https://example.com/admin".into(),
3659        ]);
3660        assert_eq!(config.blacklist_url.as_ref().unwrap().len(), 2);
3661    }
3662
3663    #[test]
3664    fn test_configuration_whitelist_setup() {
3665        let mut config = Configuration::default();
3666        config.whitelist_url = Some(vec!["https://example.com/public".into()]);
3667        assert_eq!(config.whitelist_url.as_ref().unwrap().len(), 1);
3668    }
3669
3670    #[test]
3671    fn test_configuration_external_domains() {
3672        let mut config = Configuration::default();
3673        config.external_domains_caseless = Arc::new(
3674            [
3675                case_insensitive_string::CaseInsensitiveString::from("Example.Com"),
3676                case_insensitive_string::CaseInsensitiveString::from("OTHER.org"),
3677            ]
3678            .into_iter()
3679            .collect(),
3680        );
3681        assert_eq!(config.external_domains_caseless.len(), 2);
3682        assert!(config.external_domains_caseless.contains(
3683            &case_insensitive_string::CaseInsensitiveString::from("example.com")
3684        ));
3685    }
3686
3687    #[test]
3688    fn test_configuration_budget() {
3689        let mut config = Configuration::default();
3690        let mut budget = hashbrown::HashMap::new();
3691        budget.insert(
3692            case_insensitive_string::CaseInsensitiveString::from("/path"),
3693            100u32,
3694        );
3695        config.budget = Some(budget);
3696        assert!(config.budget.is_some());
3697        assert_eq!(
3698            config.budget.as_ref().unwrap().get(
3699                &case_insensitive_string::CaseInsensitiveString::from("/path")
3700            ),
3701            Some(&100u32)
3702        );
3703    }
3704
3705    #[cfg(not(feature = "regex"))]
3706    #[test]
3707    fn test_allow_list_set_default() {
3708        let allow_list = AllowListSet::default();
3709        assert!(allow_list.0.is_empty());
3710    }
3711
3712    #[cfg(feature = "agent")]
3713    #[test]
3714    fn test_build_remote_multimodal_engine_preserves_dual_models() {
3715        use crate::features::automation::{
3716            ModelEndpoint, RemoteMultimodalConfigs, VisionRouteMode,
3717        };
3718
3719        let mut config = Configuration::default();
3720        let mm = RemoteMultimodalConfigs::new(
3721            "https://api.example.com/v1/chat/completions",
3722            "primary-model",
3723        )
3724        .with_vision_model(ModelEndpoint::new("vision-model").with_api_key("vision-key"))
3725        .with_text_model(
3726            ModelEndpoint::new("text-model")
3727                .with_api_url("https://text.example.com/v1/chat/completions")
3728                .with_api_key("text-key"),
3729        )
3730        .with_vision_route_mode(VisionRouteMode::TextFirst);
3731        config.remote_multimodal = Some(Box::new(mm));
3732
3733        let engine = config
3734            .build_remote_multimodal_engine()
3735            .expect("engine should be built");
3736
3737        assert_eq!(
3738            engine.vision_model.as_ref().map(|m| m.model_name.as_str()),
3739            Some("vision-model")
3740        );
3741        assert_eq!(
3742            engine.text_model.as_ref().map(|m| m.model_name.as_str()),
3743            Some("text-model")
3744        );
3745        assert_eq!(engine.vision_route_mode, VisionRouteMode::TextFirst);
3746    }
3747
3748    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3749    #[test]
3750    fn test_spider_browser_config_defaults() {
3751        let cfg = SpiderBrowserConfig::new("test-key");
3752        assert_eq!(cfg.api_key, "test-key");
3753        assert_eq!(cfg.wss_url, "wss://browser.spider.cloud/v1/browser");
3754        assert!(!cfg.stealth);
3755        assert!(cfg.browser.is_none());
3756        assert!(cfg.country.is_none());
3757        assert!(cfg.extra_params.is_none());
3758    }
3759
3760    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3761    #[test]
3762    fn test_spider_browser_connection_url_basic() {
3763        let cfg = SpiderBrowserConfig::new("sk-abc123");
3764        assert_eq!(
3765            cfg.connection_url(),
3766            "wss://browser.spider.cloud/v1/browser?token=sk-abc123"
3767        );
3768    }
3769
3770    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3771    #[test]
3772    fn test_spider_browser_connection_url_full() {
3773        let cfg = SpiderBrowserConfig::new("sk-abc123")
3774            .with_stealth(true)
3775            .with_browser("chrome")
3776            .with_country("us")
3777            .with_extra_params(vec![("timeout".into(), "30000".into())]);
3778        assert_eq!(
3779            cfg.connection_url(),
3780            "wss://browser.spider.cloud/v1/browser?token=sk-abc123&stealth=true&browser=chrome&country=us&timeout=30000"
3781        );
3782    }
3783
3784    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3785    #[test]
3786    fn test_spider_browser_connection_url_custom_wss() {
3787        let cfg = SpiderBrowserConfig::new("key")
3788            .with_wss_url("wss://custom.browser.example.com/v1/browser");
3789        assert_eq!(
3790            cfg.connection_url(),
3791            "wss://custom.browser.example.com/v1/browser?token=key"
3792        );
3793    }
3794
3795    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3796    #[test]
3797    fn test_with_spider_browser_sets_chrome_connection() {
3798        let mut config = Configuration::default();
3799        config.with_spider_browser("my-api-key");
3800        assert_eq!(
3801            config.chrome_connection_url.as_deref(),
3802            Some("wss://browser.spider.cloud/v1/browser?token=my-api-key")
3803        );
3804        assert!(config.spider_browser.is_some());
3805    }
3806
3807    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3808    #[test]
3809    fn test_with_spider_browser_config_stealth() {
3810        let mut config = Configuration::default();
3811        let browser_cfg = SpiderBrowserConfig::new("key")
3812            .with_stealth(true)
3813            .with_country("gb");
3814        config.with_spider_browser_config(browser_cfg);
3815        assert_eq!(
3816            config.chrome_connection_url.as_deref(),
3817            Some("wss://browser.spider.cloud/v1/browser?token=key&stealth=true&country=gb")
3818        );
3819    }
3820
3821    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3822    #[test]
3823    fn test_spider_browser_connection_urls_single_default() {
3824        // No peer list -> one connection URL, identical to connection_url().
3825        let cfg = SpiderBrowserConfig::new("sk-abc123");
3826        assert_eq!(cfg.connection_urls(), vec![cfg.connection_url()]);
3827        assert_eq!(cfg.connection_urls().len(), 1);
3828    }
3829
3830    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3831    #[test]
3832    fn test_spider_browser_connection_urls_multi_peer() {
3833        // Each peer base gets the same auth + options applied.
3834        let cfg = SpiderBrowserConfig::new("sk-abc123")
3835            .with_stealth(true)
3836            .with_wss_urls(vec![
3837                "wss://browser-a.spider.cloud/v1/browser".into(),
3838                "wss://browser-b.spider.cloud/v1/browser".into(),
3839            ]);
3840        assert_eq!(
3841            cfg.connection_urls(),
3842            vec![
3843                "wss://browser-a.spider.cloud/v1/browser?token=sk-abc123&stealth=true".to_string(),
3844                "wss://browser-b.spider.cloud/v1/browser?token=sk-abc123&stealth=true".to_string(),
3845            ]
3846        );
3847    }
3848
3849    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3850    #[test]
3851    fn test_with_spider_browser_config_multi_peer_uses_failover() {
3852        // Two peers must populate the failover list, not the single-URL field.
3853        let mut config = Configuration::default();
3854        let browser_cfg = SpiderBrowserConfig::new("key").with_wss_urls(vec![
3855            "wss://browser-a.spider.cloud/v1/browser".into(),
3856            "wss://browser-b.spider.cloud/v1/browser".into(),
3857        ]);
3858        config.with_spider_browser_config(browser_cfg);
3859        assert_eq!(
3860            config.chrome_connection_urls.as_ref().map(|u| u.len()),
3861            Some(2),
3862            "multi-peer browser config must route through chrome_connection_urls (failover)"
3863        );
3864        assert!(
3865            config.chrome_connection_url.is_none(),
3866            "multi-peer config must not pin a single chrome_connection_url"
3867        );
3868        assert!(config.spider_browser.is_some());
3869    }
3870
3871    #[cfg(all(feature = "spider_cloud", feature = "chrome"))]
3872    #[test]
3873    fn test_with_spider_browser_config_single_peer_single_path() {
3874        // One peer stays on the single-endpoint path (no failover list).
3875        let mut config = Configuration::default();
3876        config.with_spider_browser_config(SpiderBrowserConfig::new("key"));
3877        assert!(config.chrome_connection_url.is_some());
3878        assert!(config.chrome_connection_urls.is_none());
3879    }
3880
3881    #[test]
3882    fn enhancement_map_defaults_are_empty() {
3883        let s = EnhancementSettings::new();
3884        assert!(!s.is_customized());
3885        for e in CrawlEnhancement::ALL {
3886            assert_eq!(s.get(e), None, "{e:?} should have no override by default");
3887        }
3888        assert_eq!(EnhancementSettings::default(), s);
3889    }
3890
3891    #[test]
3892    fn enhancement_map_per_section_override() {
3893        let mut s = EnhancementSettings::new();
3894        s.set(CrawlEnhancement::DnsGuard, false)
3895            .set(CrawlEnhancement::RenderUpgrade, true);
3896        assert_eq!(s.get(CrawlEnhancement::DnsGuard), Some(false));
3897        assert_eq!(s.get(CrawlEnhancement::RenderUpgrade), Some(true));
3898        // Untouched sections stay unset (fall back to the env/global default).
3899        assert_eq!(s.get(CrawlEnhancement::PointerAssist), None);
3900        assert!(s.is_customized());
3901        // Clearing an override reverts the section to its default.
3902        s.clear(CrawlEnhancement::DnsGuard);
3903        assert_eq!(s.get(CrawlEnhancement::DnsGuard), None);
3904    }
3905
3906    #[test]
3907    fn enhancement_map_set_all_and_all_off() {
3908        let mut s = EnhancementSettings::new();
3909        s.set_all(false);
3910        for e in CrawlEnhancement::ALL {
3911            assert_eq!(s.get(e), Some(false));
3912        }
3913        assert_eq!(EnhancementSettings::all_off(), s);
3914        s.set_all(true);
3915        for e in CrawlEnhancement::ALL {
3916            assert_eq!(s.get(e), Some(true));
3917        }
3918    }
3919
3920    #[test]
3921    fn enhancement_settings_is_copy() {
3922        // Copy semantics: no heap, cheap to duplicate into every fetch.
3923        let mut a = EnhancementSettings::new();
3924        a.set(CrawlEnhancement::DnsHedge, false);
3925        let b = a; // copy, not move — `a` is still usable below
3926        assert_eq!(a.get(CrawlEnhancement::DnsHedge), Some(false));
3927        assert_eq!(b.get(CrawlEnhancement::DnsHedge), Some(false));
3928    }
3929
3930    #[cfg(feature = "chrome")]
3931    #[test]
3932    fn enhancement_override_wins_over_env_default() {
3933        // An explicit per-crawl override resolves regardless of the env default.
3934        let mut s = EnhancementSettings::new();
3935        s.set(CrawlEnhancement::DnsGuard, false);
3936        assert!(!s.enabled(CrawlEnhancement::DnsGuard));
3937        s.set(CrawlEnhancement::DnsGuard, true);
3938        assert!(s.enabled(CrawlEnhancement::DnsGuard));
3939        // An unset section delegates to its env/global default.
3940        assert_eq!(
3941            EnhancementSettings::new().enabled(CrawlEnhancement::PointerAssist),
3942            CrawlEnhancement::PointerAssist.env_default()
3943        );
3944    }
3945
3946    #[cfg(feature = "chrome")]
3947    #[test]
3948    fn opt_out_flag_disable_tokens() {
3949        use crate::utils::opt_out_flag;
3950        // Default-ON: unset or any non-disable value enables.
3951        assert!(opt_out_flag(None));
3952        assert!(opt_out_flag(Some("")));
3953        assert!(opt_out_flag(Some("1")));
3954        assert!(opt_out_flag(Some("true")));
3955        assert!(opt_out_flag(Some("on")));
3956        assert!(opt_out_flag(Some("anything")));
3957        // Disable tokens, trimmed + case-insensitive.
3958        assert!(!opt_out_flag(Some("0")));
3959        assert!(!opt_out_flag(Some(" 0 ")));
3960        assert!(!opt_out_flag(Some("false")));
3961        assert!(!opt_out_flag(Some("FALSE")));
3962        assert!(!opt_out_flag(Some("off")));
3963        assert!(!opt_out_flag(Some("  Off  ")));
3964    }
3965}