Skip to main content

spider_browser/
page.rs

1//! SpiderPage -- deterministic browser tab abstraction.
2//!
3//! All standard browser automation methods (no LLM required).
4//! Works over both CDP (Chrome/Servo/LightPanda) and BiDi (Firefox)
5//! through the [`ProtocolAdapter`].
6
7use crate::errors::{Result, SpiderError};
8use crate::events::SpiderEventEmitter;
9use crate::protocol::protocol_adapter::ProtocolAdapter;
10use arc_swap::ArcSwap;
11use serde_json::Value;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14use tokio::time::sleep;
15
16#[cfg(feature = "ai")]
17use crate::ai::agent::{Agent, AgentOptions, AgentResult, AgentScope};
18#[cfg(feature = "ai")]
19use crate::ai::llm_provider::LLMProvider;
20
21/// Browser tab abstraction with full automation API.
22///
23/// Wraps a [`ProtocolAdapter`] and exposes high-level navigation, content
24/// extraction, click/input/scroll primitives, wait helpers, and viewport
25/// control. The adapter can be swapped atomically via [`set_adapter`] during
26/// browser rotation without dropping inflight references.
27pub struct SpiderPage {
28    adapter: ArcSwap<ProtocolAdapter>,
29    emitter: SpiderEventEmitter,
30    #[cfg(feature = "ai")]
31    llm: Option<Arc<dyn LLMProvider>>,
32}
33
34/// Selector specification for [`SpiderPage::extract_fields`].
35///
36/// Use [`From<&str>`] for the common text-content case:
37/// ```ignore
38/// page.extract_fields(&[
39///     ("title", "#productTitle".into()),
40///     ("image", FieldSelector::Attr { selector: "#img", attribute: "src" }),
41/// ]).await?;
42/// ```
43#[derive(Debug, Clone)]
44pub enum FieldSelector<'a> {
45    /// Extract `textContent` (trimmed) from the element matching this CSS
46    /// selector.
47    Text(&'a str),
48    /// Extract an attribute value from the element matching the CSS selector.
49    Attr {
50        selector: &'a str,
51        attribute: &'a str,
52    },
53}
54
55impl<'a> From<&'a str> for FieldSelector<'a> {
56    fn from(s: &'a str) -> Self {
57        Self::Text(s)
58    }
59}
60
61impl SpiderPage {
62    // -----------------------------------------------------------------
63    // Construction
64    // -----------------------------------------------------------------
65
66    /// Create a new `SpiderPage` wrapping the given protocol adapter.
67    pub fn new(adapter: ProtocolAdapter) -> Self {
68        Self {
69            adapter: ArcSwap::from_pointee(adapter),
70            emitter: SpiderEventEmitter::default(),
71            #[cfg(feature = "ai")]
72            llm: None,
73        }
74    }
75
76    /// Create a new `SpiderPage` from an already-`Arc`-wrapped adapter.
77    pub fn from_arc(adapter: Arc<ProtocolAdapter>) -> Self {
78        Self {
79            adapter: ArcSwap::from(adapter),
80            emitter: SpiderEventEmitter::default(),
81            #[cfg(feature = "ai")]
82            llm: None,
83        }
84    }
85
86    /// Create a new `SpiderPage` from an already-`Arc`-wrapped adapter, with
87    /// the parent browser's event emitter and LLM provider wired through so
88    /// [`SpiderPage::agent`] works with zero extra config. Used internally by
89    /// `SpiderBrowser::init`.
90    #[cfg(feature = "ai")]
91    pub(crate) fn from_arc_with(
92        adapter: Arc<ProtocolAdapter>,
93        emitter: SpiderEventEmitter,
94        llm: Option<Arc<dyn LLMProvider>>,
95    ) -> Self {
96        Self {
97            adapter: ArcSwap::from(adapter),
98            emitter,
99            llm,
100        }
101    }
102
103    /// Load the current adapter snapshot. All methods go through this.
104    #[inline]
105    pub(crate) fn adapter(&self) -> arc_swap::Guard<Arc<ProtocolAdapter>> {
106        self.adapter.load()
107    }
108
109    // =================================================================
110    // AI
111    // =================================================================
112
113    /// Run an autonomous agent scoped to THIS page/tab only.
114    ///
115    /// Unlike `SpiderBrowser::agent()`, the agent is instructed (and
116    /// best-effort guarded via an injected script) to stay in the current
117    /// tab: no new tabs/windows, `target="_blank"` links open in-place.
118    ///
119    /// Uses the browser's configured LLM by default; pass `options.llm` to
120    /// override with a different provider for this run.
121    #[cfg(feature = "ai")]
122    pub async fn agent(
123        &self,
124        instruction: &str,
125        options: Option<AgentOptions>,
126    ) -> Result<AgentResult> {
127        let mut opts = options.unwrap_or_default();
128        let provider: Arc<dyn LLMProvider> = if let Some(config) = opts.llm.take() {
129            Arc::from(crate::ai::llm_provider::create_provider(config))
130        } else {
131            self.llm.clone().ok_or_else(|| {
132                SpiderError::Llm(
133                    "LLM not configured. Pass llm option to SpiderBrowser for AI methods.".into(),
134                )
135            })?
136        };
137        // Clone the Arc out of the ArcSwap snapshot before any `.await` --
138        // holding a `Guard` across an await point is unsound with arc-swap.
139        let adapter = self.adapter.load_full();
140        opts.scope = AgentScope::Page;
141        let agent = Agent::new(&adapter, provider.as_ref(), &self.emitter, Some(opts));
142        Ok(agent.execute(instruction).await)
143    }
144
145    // =================================================================
146    // Navigation
147    // =================================================================
148
149    /// Navigate to a URL and wait for load.
150    pub async fn goto(&self, url: &str) -> Result<()> {
151        self.adapter().navigate(url).await
152    }
153
154    /// Navigate without waiting for full page load (5 s max wait).
155    /// Use with [`content_with_early_return`] for SPAs that never fire
156    /// `loadEventFired`.
157    pub async fn goto_fast(&self, url: &str) -> Result<()> {
158        self.adapter().navigate_fast(url).await
159    }
160
161    /// Navigate and return as soon as `DOMContentLoaded` fires (3 s max).
162    /// Fastest option -- the DOM shell is ready but subresources may still
163    /// load. Pair with [`content_with_early_return`] or
164    /// [`content_with_network_idle`] for best results.
165    pub async fn goto_dom(&self, url: &str) -> Result<()> {
166        self.adapter().navigate_dom(url).await
167    }
168
169    /// Go back in browser history.
170    pub async fn go_back(&self) -> Result<()> {
171        self.adapter().evaluate("window.history.back()").await?;
172        Ok(())
173    }
174
175    /// Go forward in browser history.
176    pub async fn go_forward(&self) -> Result<()> {
177        self.adapter().evaluate("window.history.forward()").await?;
178        Ok(())
179    }
180
181    /// Reload the page.
182    pub async fn reload(&self) -> Result<()> {
183        self.adapter().evaluate("window.location.reload()").await?;
184        Ok(())
185    }
186
187    // =================================================================
188    // Content
189    // =================================================================
190
191    /// Get the full page HTML, ensuring the page is ready first.
192    ///
193    /// Waits for network idle + DOM stability, then checks content quality.
194    /// If the content seems incomplete (too short or looks like a loading
195    /// state), does incremental waits with exponential backoff before
196    /// returning.
197    ///
198    /// * `wait_ms`   -- Max time to wait for readiness (default 8000).
199    ///                  Pass 0 to skip readiness checks and return
200    ///                  immediately.
201    /// * `min_length` -- Minimum content length to consider "good"
202    ///                   (default 1000).
203    pub async fn content(&self, wait_ms: u64, min_length: usize) -> Result<String> {
204        // ---- SSR fast path ----
205        // Check if content is already sufficient before waiting for network
206        // idle.  SSR pages have full HTML available immediately after
207        // navigation, so this skips the expensive networkIdle wait.
208        if wait_ms > 0 {
209            let early_html = self.adapter().get_html().await.unwrap_or_default();
210            if early_html.len() >= min_length
211                && !Self::is_interstitial_content(&early_html)
212                && !Self::is_rate_limit_content(&early_html)
213            {
214                return Ok(early_html);
215            }
216            self.wait_for_network_idle(wait_ms).await?;
217        }
218
219        let mut html = self.adapter().get_html().await.unwrap_or_default();
220
221        // ---- Interstitial detection ----
222        // Cloudflare "Just a moment...", PerimeterX "Verifying the
223        // device...", and similar interstitials auto-resolve after a few
224        // seconds.  PerimeterX can take 30-45 s.
225        // Graduated waits: 2+2+3+4+5+7+7 = 30 s max.
226        // No no-growth early exit: PerimeterX pages stay identical during
227        // JS verification then suddenly redirect when challenge passes.
228        // Must wait the full budget.
229        if wait_ms > 0 && Self::is_interstitial_content(&html) {
230            let interstitial_waits: &[u64] = &[2000, 2000, 3000, 4000, 5000, 7000, 7000];
231            for &wait in interstitial_waits {
232                sleep(Duration::from_millis(wait)).await;
233                html = self.adapter().get_html().await.unwrap_or_default();
234                if !Self::is_interstitial_content(&html) {
235                    break;
236                }
237                // Content-growth early exit: real page rendered
238                if html.len() > 15_000 {
239                    break;
240                }
241            }
242            // If still an interstitial after all waits, throw Blocked so
243            // retry engine rotates browser.
244            if Self::is_interstitial_content(&html) {
245                return Err(SpiderError::Blocked(
246                    "Page stuck on interstitial challenge".into(),
247                ));
248            }
249        }
250
251        // ---- Site-level rate limiting ----
252        // Throw Blocked so retry engine rotates browser (new profile).
253        if wait_ms > 0 && Self::is_rate_limit_content(&html) {
254            return Err(SpiderError::Blocked(
255                "Rate limit exceeded (site-level)".into(),
256            ));
257        }
258
259        // ---- Incremental quality check ----
260        // If content seems incomplete, wait progressively.  After
261        // incremental waits, fall back to polling (catches SPAs that never
262        // fire load but have content available via client-side rendering).
263        if wait_ms > 0 && html.len() < min_length {
264            let increments: &[u64] = &[300, 500, 800, 1200];
265            for &extra in increments {
266                sleep(Duration::from_millis(extra)).await;
267                let updated = self.adapter().get_html().await.unwrap_or_default();
268                if updated.len() > html.len() {
269                    html = updated;
270                }
271                if html.len() >= min_length {
272                    break;
273                }
274            }
275            // If still short after incremental waits, do a brief polling
276            // phase. This catches SPAs that render content asynchronously
277            // after page load.
278            if html.len() < min_length {
279                let poll_deadline = Instant::now() + Duration::from_millis(3000);
280                while Instant::now() < poll_deadline {
281                    sleep(Duration::from_millis(1000)).await;
282                    let polled = self.adapter().get_html().await.unwrap_or_default();
283                    if polled.len() > html.len() {
284                        html = polled;
285                    }
286                    if html.len() >= min_length {
287                        break;
288                    }
289                }
290            }
291        }
292
293        Ok(html)
294    }
295
296    /// Get the raw page HTML without any readiness waiting.
297    /// Use this when you need immediate access or have already waited.
298    pub async fn raw_content(&self) -> Result<String> {
299        self.adapter().get_html().await
300    }
301
302    /// Poll for content with early return -- for SPAs that never fire
303    /// `loadEventFired`.
304    ///
305    /// Instead of waiting for a full page load event, this polls for HTML
306    /// content at regular intervals and returns as soon as sufficient
307    /// content is available.  Useful for timeout retries where the page
308    /// loads data asynchronously.
309    ///
310    /// * `max_wait_ms`        -- Max time to poll (default 15 s).
311    /// * `min_content_length` -- Minimum HTML length to accept (default 500).
312    /// * `poll_interval_ms`   -- Interval between polls (default 2 s).
313    pub async fn content_with_early_return(
314        &self,
315        max_wait_ms: u64,
316        min_content_length: usize,
317        poll_interval_ms: u64,
318    ) -> Result<String> {
319        let deadline = Instant::now() + Duration::from_millis(max_wait_ms);
320        while Instant::now() < deadline {
321            let html = self.adapter().get_html().await.unwrap_or_default();
322            if html.len() >= min_content_length
323                && !Self::is_interstitial_content(&html)
324                && !Self::is_rate_limit_content(&html)
325            {
326                return Ok(html);
327            }
328            let remaining = deadline.saturating_duration_since(Instant::now());
329            if remaining.is_zero() {
330                break;
331            }
332            let wait = Duration::from_millis(poll_interval_ms).min(remaining);
333            sleep(wait).await;
334        }
335        // Final attempt -- return whatever we have
336        Ok(self.adapter().get_html().await.unwrap_or_default())
337    }
338
339    /// Get content using network idle detection + polling hybrid approach.
340    ///
341    /// Best for heavy SPAs: uses `PerformanceObserver` + `MutationObserver`
342    /// to detect when the page stops loading, combined with content-length
343    /// thresholds.
344    ///
345    /// Strategy:
346    /// 1. Wait for `readyState=interactive` (DOM parsed)
347    /// 2. Start network+DOM idle monitoring (400 ms silence threshold)
348    /// 3. Poll HTML length -- return early if sufficient + idle
349    /// 4. Interstitial detection with configurable wait budget
350    ///
351    /// * `max_wait_ms`           -- Max total time to wait (default 20 s).
352    /// * `min_content_length`    -- Minimum HTML length to accept (default 1000).
353    /// * `interstitial_budget_ms` -- Max time to wait for interstitials to
354    ///                               resolve (default 16 s, use 30 s for
355    ///                               retries).
356    pub async fn content_with_network_idle(
357        &self,
358        max_wait_ms: u64,
359        min_content_length: usize,
360        interstitial_budget_ms: u64,
361    ) -> Result<String> {
362        let deadline = Instant::now() + Duration::from_millis(max_wait_ms);
363
364        // Phase 1: Quick check -- SSR pages have content immediately
365        let mut html = self.adapter().get_html().await.unwrap_or_default();
366        if html.len() >= min_content_length
367            && !Self::is_interstitial_content(&html)
368            && !Self::is_rate_limit_content(&html)
369        {
370            return Ok(html);
371        }
372
373        // Phase 2: Wait for readyState=interactive or complete (DOM parsed)
374        let dom_deadline = deadline.min(Instant::now() + Duration::from_millis(5000));
375        while Instant::now() < dom_deadline {
376            let state = self.adapter().evaluate("document.readyState").await;
377            if let Ok(val) = state {
378                let s = val.as_str().unwrap_or("");
379                if s == "interactive" || s == "complete" {
380                    break;
381                }
382            }
383            sleep(Duration::from_millis(200)).await;
384        }
385
386        // Phase 3: Network + DOM idle monitoring with content polling.
387        // Inject a combined observer that tracks resource loads and DOM
388        // mutations.
389        let idle_ms: u64 = 400;
390        let idle_check_ms = {
391            let remaining = deadline.saturating_duration_since(Instant::now());
392            remaining.as_millis().min(8000) as u64
393        };
394        if idle_check_ms > 500 {
395            let js = format!(
396                r#"
397                new Promise((resolve) => {{
398                    let lastActivity = Date.now();
399                    const idleThreshold = {idle_ms};
400                    const deadline = Date.now() + {idle_check_ms};
401                    const perfObs = new PerformanceObserver(() => {{ lastActivity = Date.now(); }});
402                    try {{ perfObs.observe({{ entryTypes: ['resource'] }}); }} catch(e) {{}}
403                    const mutObs = new MutationObserver(() => {{ lastActivity = Date.now(); }});
404                    mutObs.observe(document.documentElement, {{ childList: true, subtree: true, attributes: true }});
405                    const check = () => {{
406                        const now = Date.now();
407                        if (now >= deadline || (now - lastActivity >= idleThreshold)) {{
408                            perfObs.disconnect(); mutObs.disconnect(); resolve(true); return;
409                        }}
410                        setTimeout(check, 100);
411                    }};
412                    setTimeout(check, idleThreshold);
413                }})
414                "#
415            );
416            if self.adapter().evaluate(&js).await.is_err() {
417                sleep(Duration::from_millis(500)).await;
418            }
419        }
420
421        // Check content after idle
422        html = self.adapter().get_html().await.unwrap_or_default();
423        if html.len() >= min_content_length
424            && !Self::is_interstitial_content(&html)
425            && !Self::is_rate_limit_content(&html)
426        {
427            return Ok(html);
428        }
429
430        // Phase 4: Interstitial handling with configurable budget.
431        // No no-growth early exit: PerimeterX/Akamai pages stay identical
432        // during JS verification then suddenly redirect. Must wait the
433        // full budget.
434        if Self::is_interstitial_content(&html) {
435            let i_deadline =
436                deadline.min(Instant::now() + Duration::from_millis(interstitial_budget_ms));
437            let waits: &[u64] = &[2000, 2000, 3000, 4000, 5000, 7000, 10000];
438            for &wait in waits {
439                if Instant::now() >= i_deadline {
440                    break;
441                }
442                let remaining = i_deadline.saturating_duration_since(Instant::now());
443                let actual_wait = Duration::from_millis(wait).min(remaining);
444                sleep(actual_wait).await;
445                html = self.adapter().get_html().await.unwrap_or_default();
446                if !Self::is_interstitial_content(&html) {
447                    break;
448                }
449                if html.len() > 15_000 {
450                    break;
451                }
452            }
453            if Self::is_interstitial_content(&html) {
454                return Err(SpiderError::Blocked(
455                    "Page stuck on interstitial challenge".into(),
456                ));
457            }
458        }
459
460        if Self::is_rate_limit_content(&html) {
461            return Err(SpiderError::Blocked(
462                "Rate limit exceeded (site-level)".into(),
463            ));
464        }
465
466        // Phase 5: Final polling for async content
467        if html.len() < min_content_length {
468            while Instant::now() < deadline {
469                sleep(Duration::from_millis(1000)).await;
470                let polled = self.adapter().get_html().await.unwrap_or_default();
471                if polled.len() > html.len() {
472                    html = polled;
473                }
474                if html.len() >= min_content_length {
475                    break;
476                }
477            }
478        }
479
480        Ok(html)
481    }
482
483    // =================================================================
484    // Info
485    // =================================================================
486
487    /// Get the page title.
488    pub async fn title(&self) -> Result<String> {
489        let val = self.adapter().evaluate("document.title").await?;
490        Ok(val.as_str().unwrap_or("").to_string())
491    }
492
493    /// Get the current page URL.
494    pub async fn url(&self) -> Result<String> {
495        let val = self.adapter().evaluate("window.location.href").await?;
496        Ok(val.as_str().unwrap_or("").to_string())
497    }
498
499    /// Capture a screenshot as base64 PNG.
500    pub async fn screenshot(&self) -> Result<String> {
501        self.adapter().capture_screenshot().await
502    }
503
504    /// Evaluate arbitrary JavaScript and return the result.
505    pub async fn evaluate(&self, expression: &str) -> Result<Value> {
506        self.adapter().evaluate(expression).await
507    }
508
509    // -----------------------------------------------------------------
510    // Session snapshots -- persist a session and resume it later
511    // -----------------------------------------------------------------
512
513    /// Save the current session as a portable snapshot to persist and restore
514    /// later -- cookies, local/session storage, the current URL, extra request
515    /// headers, and the viewport. Returns the snapshot blob; store it and pass
516    /// it back to [`restore_snapshot`](Self::restore_snapshot) to resume.
517    pub async fn save_snapshot(&self, snapshot_id: Option<&str>) -> Result<Value> {
518        let mut params = serde_json::Map::new();
519        if let Some(id) = snapshot_id {
520            params.insert("id".into(), Value::String(id.to_string()));
521        }
522        let resp = self
523            .adapter()
524            .send_command("Snapshot.capture", Value::Object(params))
525            .await?;
526        // Return the blob directly for ergonomic round-tripping.
527        Ok(match resp.get("snapshot") {
528            Some(blob) => blob.clone(),
529            None => resp,
530        })
531    }
532
533    /// Restore a previously saved session snapshot into this page. Accepts the
534    /// blob from [`save_snapshot`](Self::save_snapshot) or the full result.
535    pub async fn restore_snapshot(&self, snapshot: Value) -> Result<Value> {
536        let blob = match snapshot.get("snapshot") {
537            Some(b) => b.clone(),
538            None => snapshot,
539        };
540        self.adapter()
541            .send_command("Snapshot.restore", serde_json::json!({ "snapshot": blob }))
542            .await
543    }
544
545    /// Delete a saved snapshot by id from the browser's local cache.
546    pub async fn delete_snapshot(&self, snapshot_id: &str) -> Result<Value> {
547        self.adapter()
548            .send_command("Snapshot.delete", serde_json::json!({ "id": snapshot_id }))
549            .await
550    }
551
552    // =================================================================
553    // Click Actions
554    // =================================================================
555
556    /// Click an element by CSS selector.
557    pub async fn click(&self, selector: &str) -> Result<()> {
558        let (x, y) = self.get_element_center(selector).await?;
559        self.adapter().click_point(x, y).await
560    }
561
562    /// Click at specific viewport coordinates.
563    pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
564        self.adapter().click_point(x, y).await
565    }
566
567    /// Double-click an element by CSS selector.
568    pub async fn dblclick(&self, selector: &str) -> Result<()> {
569        let (x, y) = self.get_element_center(selector).await?;
570        self.adapter().double_click_point(x, y).await
571    }
572
573    /// Right-click an element by CSS selector.
574    pub async fn right_click(&self, selector: &str) -> Result<()> {
575        let (x, y) = self.get_element_center(selector).await?;
576        self.adapter().right_click_point(x, y).await
577    }
578
579    /// Click and hold an element for a duration.
580    ///
581    /// Useful for long-press interactions, drag initiation, and
582    /// mobile-style gestures.
583    ///
584    /// * `selector` -- CSS selector of the element.
585    /// * `hold_ms`  -- Duration in milliseconds to hold (default 1000).
586    pub async fn click_and_hold(&self, selector: &str, hold_ms: u64) -> Result<()> {
587        let (x, y) = self.get_element_center(selector).await?;
588        self.adapter().click_hold_point(x, y, hold_ms).await
589    }
590
591    /// Click and hold at specific viewport coordinates for a duration.
592    ///
593    /// * `x`       -- X coordinate (CSS pixels).
594    /// * `y`       -- Y coordinate (CSS pixels).
595    /// * `hold_ms` -- Duration in milliseconds to hold (default 1000).
596    pub async fn click_and_hold_at(&self, x: f64, y: f64, hold_ms: u64) -> Result<()> {
597        self.adapter().click_hold_point(x, y, hold_ms).await
598    }
599
600    /// Click all elements matching a selector.
601    pub async fn click_all(&self, selector: &str) -> Result<()> {
602        let escaped = serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
603        let js = format!(
604            r#"
605            (function() {{
606                const els = document.querySelectorAll({escaped});
607                return Array.from(els).map(el => {{
608                    const r = el.getBoundingClientRect();
609                    return {{ x: r.x + r.width / 2, y: r.y + r.height / 2 }};
610                }});
611            }})()
612            "#
613        );
614        let result = self.adapter().evaluate(&js).await?;
615        if let Some(points) = result.as_array() {
616            for pt in points {
617                let x = pt.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
618                let y = pt.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
619                self.adapter().click_point(x, y).await?;
620                sleep(Duration::from_millis(100)).await;
621            }
622        }
623        Ok(())
624    }
625
626    // =================================================================
627    // Input Actions
628    // =================================================================
629
630    /// Fill a form field -- focus, clear existing value, type new value.
631    pub async fn fill(&self, selector: &str, value: &str) -> Result<()> {
632        let escaped_sel =
633            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
634        // Clear via JS
635        let clear_js = format!(
636            r#"
637            (function() {{
638                const el = document.querySelector({escaped_sel});
639                if (el) {{ el.focus(); el.value = ''; }}
640            }})()
641            "#
642        );
643        self.adapter().evaluate(&clear_js).await?;
644
645        // Click to ensure focus with real browser event
646        if let Ok((x, y)) = self.get_element_center(selector).await {
647            let _ = self.adapter().click_point(x, y).await;
648        }
649
650        // Insert text
651        self.adapter().insert_text(value).await?;
652
653        // Dispatch input + change events
654        let dispatch_js = format!(
655            r#"
656            (function() {{
657                const el = document.querySelector({escaped_sel});
658                if (el) {{
659                    el.dispatchEvent(new Event('input', {{ bubbles: true }}));
660                    el.dispatchEvent(new Event('change', {{ bubbles: true }}));
661                }}
662            }})()
663            "#
664        );
665        self.adapter().evaluate(&dispatch_js).await?;
666        Ok(())
667    }
668
669    /// Type text into the currently focused element.
670    pub async fn type_text(&self, value: &str) -> Result<()> {
671        self.adapter().insert_text(value).await
672    }
673
674    /// Press a named key (e.g. "Enter", "Tab", "Escape").
675    pub async fn press(&self, key: &str) -> Result<()> {
676        self.adapter().press_key(key).await
677    }
678
679    /// Clear an input field.
680    pub async fn clear(&self, selector: &str) -> Result<()> {
681        let escaped =
682            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
683        let js = format!("document.querySelector({escaped}).value = ''");
684        self.adapter().evaluate(&js).await?;
685        Ok(())
686    }
687
688    /// Select an option in a `<select>` element.
689    pub async fn select(&self, selector: &str, value: &str) -> Result<()> {
690        let escaped_sel =
691            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
692        let escaped_val =
693            serde_json::to_string(value).unwrap_or_else(|_| format!("\"{}\"", value));
694        let js = format!(
695            r#"
696            (function() {{
697                const el = document.querySelector({escaped_sel});
698                if (el) {{
699                    el.value = {escaped_val};
700                    el.dispatchEvent(new Event('change', {{ bubbles: true }}));
701                }}
702            }})()
703            "#
704        );
705        self.adapter().evaluate(&js).await?;
706        Ok(())
707    }
708
709    // =================================================================
710    // Focus & Hover
711    // =================================================================
712
713    /// Focus an element.
714    pub async fn focus(&self, selector: &str) -> Result<()> {
715        let escaped =
716            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
717        let js = format!("document.querySelector({escaped})?.focus()");
718        self.adapter().evaluate(&js).await?;
719        Ok(())
720    }
721
722    /// Blur (unfocus) an element.
723    pub async fn blur(&self, selector: &str) -> Result<()> {
724        let escaped =
725            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
726        let js = format!("document.querySelector({escaped})?.blur()");
727        self.adapter().evaluate(&js).await?;
728        Ok(())
729    }
730
731    /// Hover over an element.
732    pub async fn hover(&self, selector: &str) -> Result<()> {
733        let (x, y) = self.get_element_center(selector).await?;
734        self.adapter().hover_point(x, y).await
735    }
736
737    // =================================================================
738    // Drag
739    // =================================================================
740
741    /// Drag from one element to another.
742    pub async fn drag(&self, from_selector: &str, to_selector: &str) -> Result<()> {
743        let (fx, fy) = self.get_element_center(from_selector).await?;
744        let (tx, ty) = self.get_element_center(to_selector).await?;
745        self.adapter().drag_point(fx, fy, tx, ty).await
746    }
747
748    // =================================================================
749    // Scroll
750    // =================================================================
751
752    /// Scroll vertically by pixels (positive = down).
753    pub async fn scroll_y(&self, pixels: i64) -> Result<()> {
754        let js = format!("window.scrollBy(0, {pixels})");
755        self.adapter().evaluate(&js).await?;
756        Ok(())
757    }
758
759    /// Scroll horizontally by pixels (positive = right).
760    pub async fn scroll_x(&self, pixels: i64) -> Result<()> {
761        let js = format!("window.scrollBy({pixels}, 0)");
762        self.adapter().evaluate(&js).await?;
763        Ok(())
764    }
765
766    /// Scroll an element into view.
767    pub async fn scroll_to(&self, selector: &str) -> Result<()> {
768        let escaped =
769            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
770        let js = format!(
771            "document.querySelector({escaped})?.scrollIntoView({{ behavior: 'smooth', block: 'center' }})"
772        );
773        self.adapter().evaluate(&js).await?;
774        Ok(())
775    }
776
777    /// Scroll to absolute page coordinates.
778    pub async fn scroll_to_point(&self, x: f64, y: f64) -> Result<()> {
779        let js = format!("window.scrollTo({x}, {y})");
780        self.adapter().evaluate(&js).await?;
781        Ok(())
782    }
783
784    // =================================================================
785    // Wait
786    // =================================================================
787
788    /// Wait for a CSS selector to appear in the DOM.
789    pub async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<()> {
790        let interval: u64 = 100;
791        let max_iter = (timeout_ms + interval - 1) / interval; // ceil division
792        let escaped =
793            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
794        let check_js = format!("!!document.querySelector({escaped})");
795        for _ in 0..max_iter {
796            let found = self.adapter().evaluate(&check_js).await?;
797            if found.as_bool().unwrap_or(false) {
798                return Ok(());
799            }
800            sleep(Duration::from_millis(interval)).await;
801        }
802        Err(SpiderError::Timeout(format!(
803            "Timeout waiting for selector: {selector}"
804        )))
805    }
806
807    /// Wait for navigation/page load (simple delay).
808    pub async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<()> {
809        let wait = timeout_ms.min(1000);
810        sleep(Duration::from_millis(wait)).await;
811        Ok(())
812    }
813
814    /// Wait until the page is fully loaded and DOM is stable.
815    ///
816    /// Checks:
817    /// 1. `document.readyState === 'complete'`
818    /// 2. DOM content length stabilizes (no changes for 500 ms)
819    ///
820    /// Use after `goto()` for SPAs and dynamic pages to ensure all
821    /// content is rendered before extracting HTML.
822    pub async fn wait_for_ready(&self, timeout_ms: u64) -> Result<()> {
823        let start = Instant::now();
824        let poll_interval: u64 = 200;
825        let stable_threshold = Duration::from_millis(500);
826        let timeout = Duration::from_millis(timeout_ms);
827
828        // Phase 1: wait for document.readyState === 'complete'
829        while start.elapsed() < timeout {
830            let state = self.adapter().evaluate("document.readyState").await;
831            if let Ok(val) = state {
832                if val.as_str() == Some("complete") {
833                    break;
834                }
835            }
836            sleep(Duration::from_millis(poll_interval)).await;
837        }
838
839        // Phase 2: wait for DOM content length to stabilize
840        let mut last_length: i64 = 0;
841        let mut stable_since = Instant::now();
842
843        while start.elapsed() < timeout {
844            let length = self
845                .adapter()
846                .evaluate("document.documentElement.innerHTML.length")
847                .await
848                .ok()
849                .and_then(|v| v.as_i64())
850                .unwrap_or(0);
851
852            if length != last_length {
853                last_length = length;
854                stable_since = Instant::now();
855            } else if stable_since.elapsed() >= stable_threshold {
856                return Ok(());
857            }
858
859            sleep(Duration::from_millis(poll_interval)).await;
860        }
861
862        Ok(())
863    }
864
865    /// Wait until page content exceeds a minimum length.
866    /// Useful for SPAs where content loads asynchronously.
867    pub async fn wait_for_content(&self, min_length: usize, timeout_ms: u64) -> Result<()> {
868        let start = Instant::now();
869        let timeout = Duration::from_millis(timeout_ms);
870        while start.elapsed() < timeout {
871            let length = self
872                .adapter()
873                .evaluate("document.documentElement.innerHTML.length")
874                .await
875                .ok()
876                .and_then(|v| v.as_u64())
877                .unwrap_or(0) as usize;
878            if length >= min_length {
879                return Ok(());
880            }
881            sleep(Duration::from_millis(200)).await;
882        }
883        Ok(())
884    }
885
886    /// Wait for network idle + DOM stability (cross-platform).
887    ///
888    /// Uses the Performance/Resource Timing API and `MutationObserver`
889    /// (works in both Chrome/CDP and Firefox/BiDi) to detect when:
890    /// 1. `document.readyState === 'complete'`
891    /// 2. No new network resources loading (`PerformanceObserver`)
892    /// 3. DOM mutations have settled
893    ///
894    /// This is more comprehensive than [`wait_for_ready`] -- it also
895    /// catches lazy-loaded images, XHR/fetch requests, and
896    /// script-injected content.
897    pub async fn wait_for_network_idle(&self, timeout_ms: u64) -> Result<()> {
898        let start = Instant::now();
899        let poll_interval: u64 = 250;
900        let timeout = Duration::from_millis(timeout_ms);
901
902        // Phase 1: wait for document.readyState === 'complete'
903        while start.elapsed() < timeout {
904            let state = self.adapter().evaluate("document.readyState").await;
905            if let Ok(val) = state {
906                if val.as_str() == Some("complete") {
907                    break;
908                }
909            }
910            sleep(Duration::from_millis(poll_interval)).await;
911        }
912
913        // Phase 2: inject a combined network + DOM stability checker.
914        // Uses PerformanceObserver for resource timing + MutationObserver
915        // for DOM changes. Returns a promise that resolves when both are
916        // quiet for `idle_ms`.
917        let idle_ms: u64 = 400;
918        let remaining = {
919            let elapsed = start.elapsed();
920            if timeout > elapsed {
921                (timeout - elapsed).as_millis().max(1000) as u64
922            } else {
923                1000
924            }
925        };
926        let js = format!(
927            r#"
928            new Promise((resolve) => {{
929                let lastActivity = Date.now();
930                const idleThreshold = {idle_ms};
931                const deadline = Date.now() + {remaining};
932
933                const perfObs = new PerformanceObserver(() => {{ lastActivity = Date.now(); }});
934                try {{ perfObs.observe({{ entryTypes: ['resource'] }}); }} catch(e) {{}}
935
936                const mutObs = new MutationObserver(() => {{ lastActivity = Date.now(); }});
937                mutObs.observe(document.documentElement, {{
938                    childList: true, subtree: true, attributes: true
939                }});
940
941                const check = () => {{
942                    const now = Date.now();
943                    if (now >= deadline || (now - lastActivity >= idleThreshold)) {{
944                        perfObs.disconnect();
945                        mutObs.disconnect();
946                        resolve(true);
947                        return;
948                    }}
949                    setTimeout(check, 100);
950                }};
951                setTimeout(check, idleThreshold);
952            }})
953            "#
954        );
955        if self.adapter().evaluate(&js).await.is_err() {
956            // If the evaluate fails (e.g. page navigated away), just
957            // continue.
958            sleep(Duration::from_millis(500)).await;
959        }
960
961        Ok(())
962    }
963
964    // =================================================================
965    // Viewport
966    // =================================================================
967
968    /// Set the viewport dimensions.
969    pub async fn set_viewport(
970        &self,
971        width: u32,
972        height: u32,
973        device_scale_factor: f64,
974        mobile: bool,
975    ) -> Result<()> {
976        self.adapter()
977            .set_viewport(width, height, device_scale_factor, mobile)
978            .await
979    }
980
981    // =================================================================
982    // DOM Queries
983    // =================================================================
984
985    /// Query a single element and return its outer HTML.
986    pub async fn query_selector(&self, selector: &str) -> Result<Option<String>> {
987        let escaped =
988            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
989        let js = format!("document.querySelector({escaped})?.outerHTML ?? null");
990        let val = self.adapter().evaluate(&js).await?;
991        if val.is_null() {
992            Ok(None)
993        } else {
994            Ok(val.as_str().map(|s| s.to_string()))
995        }
996    }
997
998    /// Query all matching elements and return their outer HTML.
999    pub async fn query_selector_all(&self, selector: &str) -> Result<Vec<String>> {
1000        let escaped =
1001            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
1002        let js = format!(
1003            "Array.from(document.querySelectorAll({escaped})).map(el => el.outerHTML)"
1004        );
1005        let val = self.adapter().evaluate(&js).await?;
1006        let items = val
1007            .as_array()
1008            .map(|arr| {
1009                arr.iter()
1010                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
1011                    .collect()
1012            })
1013            .unwrap_or_default();
1014        Ok(items)
1015    }
1016
1017    /// Get text content of an element.
1018    pub async fn text_content(&self, selector: &str) -> Result<Option<String>> {
1019        let escaped =
1020            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
1021        let js = format!("document.querySelector({escaped})?.textContent ?? null");
1022        let val = self.adapter().evaluate(&js).await?;
1023        if val.is_null() {
1024            Ok(None)
1025        } else {
1026            Ok(val.as_str().map(|s| s.to_string()))
1027        }
1028    }
1029
1030    /// Extract multiple fields from the page in a single evaluate call.
1031    ///
1032    /// Each entry maps a key name to a [`FieldSelector`]. Returns a map of
1033    /// key → value (or `None` if the element was not found).
1034    ///
1035    /// # Example
1036    ///
1037    /// ```ignore
1038    /// use std::collections::HashMap;
1039    /// use spider_browser::page::FieldSelector;
1040    ///
1041    /// let data = page.extract_fields(&[
1042    ///     ("title", "#productTitle".into()),
1043    ///     ("price", ".a-price .a-offscreen".into()),
1044    ///     ("image", FieldSelector::Attr {
1045    ///         selector: "#main-image",
1046    ///         attribute: "src",
1047    ///     }),
1048    /// ]).await?;
1049    /// println!("{:?}", data.get("title"));
1050    /// ```
1051    pub async fn extract_fields(
1052        &self,
1053        fields: &[(&str, FieldSelector<'_>)],
1054    ) -> Result<std::collections::HashMap<String, Option<String>>> {
1055        // Build the field map as a JSON array for the browser JS.
1056        let field_map: Vec<Value> = fields
1057            .iter()
1058            .map(|(key, sel)| {
1059                let (css, attr) = match sel {
1060                    FieldSelector::Text(s) => (*s, None),
1061                    FieldSelector::Attr {
1062                        selector,
1063                        attribute,
1064                    } => (*selector, Some(*attribute)),
1065                };
1066                serde_json::json!({
1067                    "key": key,
1068                    "selector": css,
1069                    "attribute": attr,
1070                })
1071            })
1072            .collect();
1073
1074        let field_json = serde_json::to_string(&field_map)
1075            .unwrap_or_else(|_| "[]".to_string());
1076
1077        let js = format!(
1078            r#"
1079            (() => {{
1080                const fields = {field_json};
1081                const result = {{}};
1082                for (const f of fields) {{
1083                    const el = document.querySelector(f.selector);
1084                    result[f.key] = el
1085                        ? (f.attribute ? el.getAttribute(f.attribute) : el.textContent?.trim()) ?? null
1086                        : null;
1087                }}
1088                return JSON.stringify(result);
1089            }})()
1090            "#
1091        );
1092
1093        let val = self.adapter().evaluate(&js).await?;
1094        let raw = val.as_str().unwrap_or("{}");
1095        let parsed: std::collections::HashMap<String, Option<String>> =
1096            serde_json::from_str(raw).unwrap_or_default();
1097        Ok(parsed)
1098    }
1099
1100    // =================================================================
1101    // Internals
1102    // =================================================================
1103
1104    /// Get the center coordinates of a DOM element (scrolls into view
1105    /// first).
1106    async fn get_element_center(&self, selector: &str) -> Result<(f64, f64)> {
1107        let escaped =
1108            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
1109        let js = format!(
1110            r#"
1111            (function() {{
1112                const el = document.querySelector({escaped});
1113                if (!el) return null;
1114                el.scrollIntoView({{ block: 'center', behavior: 'instant' }});
1115                const r = el.getBoundingClientRect();
1116                return {{ x: r.x + r.width / 2, y: r.y + r.height / 2 }};
1117            }})()
1118            "#
1119        );
1120        let result = self.adapter().evaluate(&js).await?;
1121
1122        if result.is_null() {
1123            return Err(SpiderError::Other(format!(
1124                "Element not found: {selector}"
1125            )));
1126        }
1127
1128        let x = result
1129            .get("x")
1130            .and_then(|v| v.as_f64())
1131            .ok_or_else(|| SpiderError::Other(format!("Element not found: {selector}")))?;
1132        let y = result
1133            .get("y")
1134            .and_then(|v| v.as_f64())
1135            .ok_or_else(|| SpiderError::Other(format!("Element not found: {selector}")))?;
1136
1137        Ok((x, y))
1138    }
1139
1140    /// Route an incoming WebSocket message to the underlying protocol session.
1141    pub fn route_message(&self, data: &str) {
1142        self.adapter.load().route_message(data);
1143    }
1144
1145    /// Clean up protocol resources.
1146    pub fn destroy(&self) {
1147        self.adapter.load().destroy();
1148    }
1149
1150    /// Replace the adapter (used during browser switching).
1151    ///
1152    /// Atomically swaps the underlying [`ProtocolAdapter`] so that
1153    /// inflight operations on the old adapter can finish while new
1154    /// operations use the replacement.
1155    pub fn set_adapter(&self, adapter: ProtocolAdapter) {
1156        self.adapter.store(Arc::new(adapter));
1157    }
1158
1159    /// Replace the adapter with an already-`Arc`-wrapped instance.
1160    pub fn set_adapter_arc(&self, adapter: Arc<ProtocolAdapter>) {
1161        self.adapter.store(adapter);
1162    }
1163
1164    /// Detect challenge interstitials that may auto-resolve (e.g.
1165    /// Cloudflare "Just a moment...").
1166    ///
1167    /// These pages show briefly before redirecting to the real content.
1168    pub fn is_interstitial_content(html: &str) -> bool {
1169        if html.len() > 15_000 {
1170            return false; // Real pages are larger
1171        }
1172        let lower = html.to_lowercase();
1173
1174        // Challenge / WAF interstitials
1175        if lower.contains("just a moment")
1176            || lower.contains("checking your browser")
1177            || lower.contains("please wait while we verify")
1178            || lower.contains("verifying the device")
1179            || lower.contains("available after verification")
1180            || lower.contains("ddos-guard")
1181            || lower.contains("challenge-platform")
1182            || lower.contains("px-captcha")
1183            || lower.contains("_cf_chl_opt")
1184            || lower.contains("managed_challenge")
1185            || lower.contains("datadome")
1186            || lower.contains("ak_bmsc")
1187            || lower.contains("please enable cookies")
1188        {
1189            return true;
1190        }
1191
1192        // SPA loading states -- page shell rendered but content still
1193        // loading.  These auto-resolve once JS fetches actual data.
1194        // Only match on very small pages to avoid false positives on
1195        // real pages that mention "loading".
1196        if html.len() < 5_000 {
1197            if lower.contains("loading...") || lower.contains("loading results") {
1198                return true;
1199            }
1200            if lower.contains("please wait") && !lower.contains("article") {
1201                return true;
1202            }
1203        }
1204
1205        false
1206    }
1207
1208    /// Detect site-level rate limiting in page content.
1209    ///
1210    /// Browser rotation gives a new profile which bypasses per-session
1211    /// rate limits.
1212    pub fn is_rate_limit_content(html: &str) -> bool {
1213        if html.len() > 20_000 {
1214            return false; // Real pages won't be just a rate limit message
1215        }
1216        let lower = html.to_lowercase();
1217        lower.contains("rate limit exceeded")
1218            || lower.contains("too many requests")
1219            || (lower.contains("rate limit") && lower.contains("please try again"))
1220    }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225    use super::*;
1226
1227    // -----------------------------------------------------------------
1228    // is_interstitial_content tests
1229    // -----------------------------------------------------------------
1230
1231    #[test]
1232    fn interstitial_cloudflare() {
1233        let html = "<html><body>Just a moment...</body></html>";
1234        assert!(SpiderPage::is_interstitial_content(html));
1235    }
1236
1237    #[test]
1238    fn interstitial_checking_browser() {
1239        let html = "<html><body>Checking your browser before accessing</body></html>";
1240        assert!(SpiderPage::is_interstitial_content(html));
1241    }
1242
1243    #[test]
1244    fn interstitial_perimeterx() {
1245        let html = "<html><body>Verifying the device...</body></html>";
1246        assert!(SpiderPage::is_interstitial_content(html));
1247    }
1248
1249    #[test]
1250    fn interstitial_ddos_guard() {
1251        let html = "<html><head></head><body>ddos-guard check</body></html>";
1252        assert!(SpiderPage::is_interstitial_content(html));
1253    }
1254
1255    #[test]
1256    fn interstitial_challenge_platform() {
1257        let html = "<html><body class='challenge-platform'>wait</body></html>";
1258        assert!(SpiderPage::is_interstitial_content(html));
1259    }
1260
1261    #[test]
1262    fn interstitial_px_captcha() {
1263        let html = "<html><body><div id='px-captcha'></div></body></html>";
1264        assert!(SpiderPage::is_interstitial_content(html));
1265    }
1266
1267    #[test]
1268    fn interstitial_cf_chl_opt() {
1269        let html = "<html><body><script>var _cf_chl_opt={}</script></body></html>";
1270        assert!(SpiderPage::is_interstitial_content(html));
1271    }
1272
1273    #[test]
1274    fn interstitial_managed_challenge() {
1275        let html = "<html><body>managed_challenge page</body></html>";
1276        assert!(SpiderPage::is_interstitial_content(html));
1277    }
1278
1279    #[test]
1280    fn interstitial_datadome() {
1281        let html = "<html><body>DataDome verification</body></html>";
1282        assert!(SpiderPage::is_interstitial_content(html));
1283    }
1284
1285    #[test]
1286    fn interstitial_akamai() {
1287        let html = "<html><body><script>ak_bmsc=cookie</script></body></html>";
1288        assert!(SpiderPage::is_interstitial_content(html));
1289    }
1290
1291    #[test]
1292    fn interstitial_enable_cookies() {
1293        let html = "<html><body>Please enable cookies to continue</body></html>";
1294        assert!(SpiderPage::is_interstitial_content(html));
1295    }
1296
1297    #[test]
1298    fn interstitial_loading_small() {
1299        let html = "<html><body>Loading...</body></html>";
1300        assert!(SpiderPage::is_interstitial_content(html));
1301    }
1302
1303    #[test]
1304    fn interstitial_loading_results() {
1305        let html = "<html><body>Loading results</body></html>";
1306        assert!(SpiderPage::is_interstitial_content(html));
1307    }
1308
1309    #[test]
1310    fn interstitial_please_wait_small() {
1311        let html = "<html><body>Please wait</body></html>";
1312        assert!(SpiderPage::is_interstitial_content(html));
1313    }
1314
1315    #[test]
1316    fn interstitial_please_wait_with_article_not_detected() {
1317        let html = "<html><body>Please wait for this article</body></html>";
1318        assert!(!SpiderPage::is_interstitial_content(html));
1319    }
1320
1321    #[test]
1322    fn interstitial_large_page_not_detected() {
1323        let html = "x".repeat(16_000);
1324        assert!(!SpiderPage::is_interstitial_content(&html));
1325    }
1326
1327    #[test]
1328    fn interstitial_normal_page_not_detected() {
1329        let html = "<html><body><h1>Welcome</h1><p>Normal content here.</p></body></html>";
1330        assert!(!SpiderPage::is_interstitial_content(html));
1331    }
1332
1333    // -----------------------------------------------------------------
1334    // is_rate_limit_content tests
1335    // -----------------------------------------------------------------
1336
1337    #[test]
1338    fn rate_limit_exceeded() {
1339        let html = "<html><body>Rate limit exceeded</body></html>";
1340        assert!(SpiderPage::is_rate_limit_content(html));
1341    }
1342
1343    #[test]
1344    fn rate_limit_too_many_requests() {
1345        let html = "<html><body>Too many requests</body></html>";
1346        assert!(SpiderPage::is_rate_limit_content(html));
1347    }
1348
1349    #[test]
1350    fn rate_limit_try_again() {
1351        let html = "<html><body>Rate limit hit. Please try again later.</body></html>";
1352        assert!(SpiderPage::is_rate_limit_content(html));
1353    }
1354
1355    #[test]
1356    fn rate_limit_large_page_not_detected() {
1357        let html = format!(
1358            "<html><body>{}</body></html>",
1359            "x".repeat(21_000)
1360        );
1361        assert!(!SpiderPage::is_rate_limit_content(&html));
1362    }
1363
1364    #[test]
1365    fn rate_limit_normal_page_not_detected() {
1366        let html = "<html><body><h1>Normal page</h1></body></html>";
1367        assert!(!SpiderPage::is_rate_limit_content(html));
1368    }
1369}