Skip to main content

voidcrawl_mcp/tools/
actions.rs

1//! Session-scoped interaction primitives: click, type, eval JS, read
2//! title, extract text, capture network entries, wait for network idle.
3//!
4//! Each fn takes an existing session (already opened via `session_open`)
5//! and runs one action against its page. These are the Claude-Code-facing
6//! primitives — small, composable, no hidden state.
7
8use std::{sync::Arc, time::Duration};
9
10use rmcp::ErrorData;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use tokio::time::{Instant, sleep};
15use void_crawl_core::{
16    CaptchaInfo, CaptchaKind, DispatchMouseEventType, MouseButton, ax, capture_captcha,
17    detect_captcha, inject_captcha_token,
18};
19
20use crate::{
21    errors::map_err, server::VoidCrawlServer, sessions::DedicatedSession,
22    tools::session::DEFAULT_TIMEOUT_SECS,
23};
24
25// ── Schema helpers ───────────────────────────────────────────────────────
26//
27// `serde_json::Value` fields make schemars emit a boolean `true` sub-schema,
28// which Claude Code's tool-output validator rejects — and one bad tool schema
29// fails the ENTIRE `tools/list`, so the client connects but registers zero
30// tools. These emit an explicit permissive object schema (`{}`) instead, which
31// validates cleanly across hosts.
32
33fn any_value_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
34    schemars::json_schema!({})
35}
36
37fn any_value_array_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
38    schemars::json_schema!({ "type": "array", "items": {} })
39}
40
41// ── Click ───────────────────────────────────────────────────────────────
42
43#[derive(Debug, Deserialize, JsonSchema, Default)]
44pub struct ClickArgs {
45    pub session_id: String,
46    /// CSS selector of the element to click.
47    pub selector:   String,
48}
49
50#[derive(Debug, Serialize, JsonSchema)]
51pub struct OkResult {
52    pub ok: bool,
53}
54
55pub async fn click(server: &VoidCrawlServer, args: ClickArgs) -> Result<OkResult, ErrorData> {
56    let handle = lookup(server, &args.session_id).await?;
57    let page = handle.page.lock().await;
58    page.click_element(&args.selector).await.map_err(map_err)?;
59    Ok(OkResult { ok: true })
60}
61
62// ── Click visual coords ─────────────────────────────────────────────────
63
64#[derive(Debug, Deserialize, JsonSchema, Default)]
65pub struct ClickVisualCoordsArgs {
66    pub session_id: String,
67    /// X coordinate in CSS pixels (pre-DPR).
68    pub x:          f64,
69    /// Y coordinate in CSS pixels (pre-DPR).
70    pub y:          f64,
71}
72
73pub async fn click_visual_coords(
74    server: &VoidCrawlServer,
75    args: ClickVisualCoordsArgs,
76) -> Result<OkResult, ErrorData> {
77    let handle = lookup(server, &args.session_id).await?;
78    let page = handle.page.lock().await;
79    // mousePressed + mouseReleased at (x, y) with left button. Matches
80    // the CDP recipe that React-rendered forms respond to when CSS
81    // selector clicks fail silently.
82    page.dispatch_mouse_event(
83        DispatchMouseEventType::MousePressed,
84        args.x,
85        args.y,
86        Some(MouseButton::Left),
87        Some(1),
88        None,
89        None,
90        None,
91    )
92    .await
93    .map_err(map_err)?;
94    page.dispatch_mouse_event(
95        DispatchMouseEventType::MouseReleased,
96        args.x,
97        args.y,
98        Some(MouseButton::Left),
99        Some(1),
100        None,
101        None,
102        None,
103    )
104    .await
105    .map_err(map_err)?;
106    Ok(OkResult { ok: true })
107}
108
109// ── Type text ───────────────────────────────────────────────────────────
110
111#[derive(Debug, Deserialize, JsonSchema, Default)]
112pub struct TypeTextArgs {
113    pub session_id: String,
114    /// CSS selector of the target input. When omitted, keys are
115    /// dispatched to whatever currently has focus.
116    #[serde(default)]
117    pub selector:   Option<String>,
118    pub text:       String,
119}
120
121pub async fn type_text(
122    server: &VoidCrawlServer,
123    args: TypeTextArgs,
124) -> Result<OkResult, ErrorData> {
125    let handle = lookup(server, &args.session_id).await?;
126    let page = handle.page.lock().await;
127    if let Some(sel) = args.selector {
128        page.type_into(&sel, &args.text).await.map_err(map_err)?;
129    } else {
130        // No selector: dispatch each character as a keypress to the
131        // currently-focused element (matches the React recipe where
132        // you click first, then type).
133        for ch in args.text.chars() {
134            let s = ch.to_string();
135            page.dispatch_key_event(
136                void_crawl_core::DispatchKeyEventType::Char,
137                Some(&s),
138                None,
139                Some(&s),
140                None,
141            )
142            .await
143            .map_err(map_err)?;
144        }
145    }
146    Ok(OkResult { ok: true })
147}
148
149// ── Eval JS ─────────────────────────────────────────────────────────────
150
151#[derive(Debug, Deserialize, JsonSchema, Default)]
152pub struct EvalJsArgs {
153    pub session_id: String,
154    /// A JavaScript expression. Its value is returned as JSON.
155    pub expression: String,
156}
157
158#[derive(Debug, Serialize, JsonSchema)]
159pub struct EvalJsResult {
160    #[schemars(schema_with = "any_value_schema")]
161    pub value: Value,
162}
163
164pub async fn eval_js(
165    server: &VoidCrawlServer,
166    args: EvalJsArgs,
167) -> Result<EvalJsResult, ErrorData> {
168    let handle = lookup(server, &args.session_id).await?;
169    let page = handle.page.lock().await;
170    let value = page.evaluate_js(&args.expression).await.map_err(map_err)?;
171    Ok(EvalJsResult { value })
172}
173
174// ── Title ───────────────────────────────────────────────────────────────
175
176#[derive(Debug, Deserialize, JsonSchema, Default)]
177pub struct SessionIdArgs {
178    pub session_id: String,
179}
180
181#[derive(Debug, Serialize, JsonSchema)]
182pub struct TitleResult {
183    pub title: Option<String>,
184}
185
186pub async fn title(
187    server: &VoidCrawlServer,
188    args: SessionIdArgs,
189) -> Result<TitleResult, ErrorData> {
190    let handle = lookup(server, &args.session_id).await?;
191    let page = handle.page.lock().await;
192    Ok(TitleResult { title: page.title().await.ok().flatten() })
193}
194
195// ── Extract ─────────────────────────────────────────────────────────────
196
197#[derive(Debug, Deserialize, JsonSchema, Default)]
198pub struct ExtractArgs {
199    pub session_id: String,
200    /// CSS selector. Uses `document.querySelectorAll` — returns text
201    /// content (not inner HTML) for each matching element.
202    pub selector:   String,
203}
204
205#[derive(Debug, Serialize, JsonSchema)]
206pub struct ExtractResult {
207    pub texts: Vec<String>,
208}
209
210pub async fn extract(
211    server: &VoidCrawlServer,
212    args: ExtractArgs,
213) -> Result<ExtractResult, ErrorData> {
214    let handle = lookup(server, &args.session_id).await?;
215    let page = handle.page.lock().await;
216    let js = format!(
217        "Array.from(document.querySelectorAll({sel:?})).map(e => e.textContent || '')",
218        sel = args.selector
219    );
220    let value = page.evaluate_js(&js).await.map_err(map_err)?;
221    let texts = match value {
222        Value::Array(arr) => {
223            arr.into_iter().map(|v| v.as_str().unwrap_or("").to_string()).collect()
224        }
225        _ => Vec::new(),
226    };
227    Ok(ExtractResult { texts })
228}
229
230// ── Accessibility tree ────────────────────────────────────────────────
231
232#[derive(Debug, Deserialize, JsonSchema, Default)]
233pub struct AxTreeArgs {
234    pub session_id: String,
235    /// "compact" (default): a pruned, indented role/name outline meant for an
236    /// agent to read. "raw": the full CDP AX nodes for programmatic use.
237    #[serde(default)]
238    pub mode:       Option<String>,
239    /// Maximum descendant depth to traverse; omit for the whole tree.
240    #[serde(default)]
241    pub depth:      Option<i64>,
242}
243
244#[derive(Debug, Serialize, JsonSchema)]
245pub struct AxTreeResult {
246    /// Indented `role "name"` outline. Populated in compact mode only.
247    pub tree:        String,
248    /// Raw CDP AX nodes. Populated in raw mode only.
249    #[schemars(schema_with = "any_value_array_schema")]
250    pub nodes:       Vec<Value>,
251    /// Total AX nodes the browser returned.
252    pub node_count:  usize,
253    /// Non-ignored nodes carrying a non-empty accessible name. A low ratio of
254    /// `named_count` to `node_count` signals a thin/poor AX tree — prefer
255    /// falling back to HTML, screenshot, or CSS selectors on such pages.
256    pub named_count: usize,
257}
258
259pub async fn ax_tree(
260    server: &VoidCrawlServer,
261    args: AxTreeArgs,
262) -> Result<AxTreeResult, ErrorData> {
263    let handle = lookup(server, &args.session_id).await?;
264    let page = handle.page.lock().await;
265    let value = page.get_full_ax_tree(args.depth).await.map_err(map_err)?;
266    let nodes = match value {
267        Value::Array(arr) => arr,
268        _ => Vec::new(),
269    };
270    let (node_count, named_count) = ax::richness(&nodes);
271
272    let raw = args.mode.as_deref() == Some("raw");
273    let (tree, nodes) =
274        if raw { (String::new(), nodes) } else { (ax::compact_outline(&nodes), Vec::new()) };
275    Ok(AxTreeResult { tree, nodes, node_count, named_count })
276}
277
278#[derive(Debug, Deserialize, JsonSchema, Default)]
279pub struct ClickByRoleArgs {
280    pub session_id: String,
281    /// Computed accessibility role, e.g. "button", "link", "checkbox".
282    pub role:       String,
283    /// Computed accessible name (exact match).
284    pub name:       String,
285    /// 0-based index when several nodes match the same role + name.
286    #[serde(default)]
287    pub nth:        Option<usize>,
288}
289
290pub async fn click_by_role(
291    server: &VoidCrawlServer,
292    args: ClickByRoleArgs,
293) -> Result<OkResult, ErrorData> {
294    let handle = lookup(server, &args.session_id).await?;
295    let page = handle.page.lock().await;
296    page.click_by_role(&args.role, &args.name, args.nth.unwrap_or(0)).await.map_err(map_err)?;
297    Ok(OkResult { ok: true })
298}
299
300// ── Wait for network idle ───────────────────────────────────────────────
301
302#[derive(Debug, Deserialize, JsonSchema, Default)]
303pub struct WaitIdleArgs {
304    pub session_id:   String,
305    #[serde(default)]
306    pub timeout_secs: Option<u64>,
307}
308
309pub async fn wait_for_network_idle(
310    server: &VoidCrawlServer,
311    args: WaitIdleArgs,
312) -> Result<OkResult, ErrorData> {
313    let handle = lookup(server, &args.session_id).await?;
314    let page = handle.page.lock().await;
315    let timeout = Duration::from_secs(args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
316    page.wait_for_network_idle(timeout).await.map_err(map_err)?;
317    Ok(OkResult { ok: true })
318}
319
320// ── Network capture ─────────────────────────────────────────────────────
321
322#[derive(Debug, Serialize, JsonSchema)]
323pub struct NetworkEntry {
324    pub url:            String,
325    pub initiator_type: String,
326    pub transfer_size:  f64,
327    pub duration_ms:    f64,
328}
329
330#[derive(Debug, Serialize, JsonSchema)]
331pub struct NetworkCaptureResult {
332    pub entries: Vec<NetworkEntry>,
333}
334
335pub async fn network_capture(
336    server: &VoidCrawlServer,
337    args: SessionIdArgs,
338) -> Result<NetworkCaptureResult, ErrorData> {
339    let handle = lookup(server, &args.session_id).await?;
340    let page = handle.page.lock().await;
341    // Pull from the Resource Timing API — same source DevTools uses for
342    // the Network panel's "transferred" column.
343    const JS: &str = r#"
344        performance.getEntriesByType('resource').map(e => ({
345            url: e.name,
346            initiator_type: e.initiatorType || '',
347            transfer_size: e.transferSize || 0,
348            duration_ms: e.duration || 0,
349        }))
350    "#;
351    let value = page.evaluate_js(JS).await.map_err(map_err)?;
352    let entries = match value {
353        Value::Array(arr) => arr
354            .into_iter()
355            .filter_map(|v| {
356                let obj = v.as_object()?;
357                Some(NetworkEntry {
358                    url:            obj.get("url")?.as_str()?.to_string(),
359                    initiator_type: obj.get("initiator_type")?.as_str().unwrap_or("").to_string(),
360                    transfer_size:  obj.get("transfer_size").and_then(Value::as_f64).unwrap_or(0.0),
361                    duration_ms:    obj.get("duration_ms").and_then(Value::as_f64).unwrap_or(0.0),
362                })
363            })
364            .collect(),
365        _ => Vec::new(),
366    };
367    Ok(NetworkCaptureResult { entries })
368}
369
370// ── Detect captcha ──────────────────────────────────────────────────────
371
372#[derive(Debug, Serialize, JsonSchema)]
373pub struct DetectCaptchaResult {
374    pub kind: Option<String>,
375}
376
377pub async fn detect_captcha_tool(
378    server: &VoidCrawlServer,
379    args: SessionIdArgs,
380) -> Result<DetectCaptchaResult, ErrorData> {
381    let handle = lookup(server, &args.session_id).await?;
382    let page = handle.page.lock().await;
383    let kind = detect_captcha(&page).await.map_err(map_err)?;
384    Ok(DetectCaptchaResult { kind: kind.map(|k| k.as_str().to_string()) })
385}
386
387// ── Capture captcha (full structured) ───────────────────────────────────
388
389#[derive(Debug, Serialize, JsonSchema)]
390pub struct WidgetRectJson {
391    pub x:      f64,
392    pub y:      f64,
393    pub width:  f64,
394    pub height: f64,
395}
396
397#[derive(Debug, Serialize, JsonSchema)]
398pub struct CaptureCaptchaResult {
399    /// Kind tag (same values as detect_captcha). Null when no captcha.
400    pub kind:                    Option<String>,
401    /// Site key for third-party solver APIs (2Captcha, CapSolver, etc.).
402    pub sitekey:                 Option<String>,
403    /// CSS selector of the widget container.
404    pub widget_selector:         Option<String>,
405    pub widget_rect:             Option<WidgetRectJson>,
406    /// True when the widget element is actually in the DOM.
407    /// False when only the runtime is loaded (Ahrefs-style lazy mount).
408    pub widget_rendered:         bool,
409    /// Field to write a solved token into (via `inject_captcha_token`).
410    pub response_field_selector: Option<String>,
411    /// Token already present — skip solving when set.
412    pub existing_token:          Option<String>,
413    /// Turnstile action / cdata attributes (pass through to solver).
414    pub action:                  Option<String>,
415    pub cdata:                   Option<String>,
416    /// Current document URL — required by most solver APIs.
417    pub page_url:                String,
418}
419
420pub async fn capture_captcha_tool(
421    server: &VoidCrawlServer,
422    args: SessionIdArgs,
423) -> Result<CaptureCaptchaResult, ErrorData> {
424    let handle = lookup(server, &args.session_id).await?;
425    let page = handle.page.lock().await;
426    let info: Option<CaptchaInfo> = capture_captcha(&page).await.map_err(map_err)?;
427    Ok(match info {
428        None => CaptureCaptchaResult {
429            kind:                    None,
430            sitekey:                 None,
431            widget_selector:         None,
432            widget_rect:             None,
433            widget_rendered:         false,
434            response_field_selector: None,
435            existing_token:          None,
436            action:                  None,
437            cdata:                   None,
438            page_url:                String::new(),
439        },
440        Some(i) => CaptureCaptchaResult {
441            kind:                    Some(i.kind.as_str().to_string()),
442            sitekey:                 i.sitekey,
443            widget_selector:         i.widget_selector,
444            widget_rect:             i.widget_rect.map(|r| WidgetRectJson {
445                x:      r.x,
446                y:      r.y,
447                width:  r.width,
448                height: r.height,
449            }),
450            widget_rendered:         i.widget_rendered,
451            response_field_selector: i.response_field_selector,
452            existing_token:          i.existing_token,
453            action:                  i.action,
454            cdata:                   i.cdata,
455            page_url:                i.page_url,
456        },
457    })
458}
459
460// ── Inject captcha token ────────────────────────────────────────────────
461
462#[derive(Debug, Deserialize, JsonSchema, Default)]
463pub struct InjectCaptchaTokenArgs {
464    pub session_id: String,
465    /// Token returned by your solver (e.g. 2Captcha's `gRecaptchaResponse`).
466    pub token:      String,
467    /// Kind tag. Must match the captcha on the page: one of
468    /// "turnstile", "recaptcha", "hcaptcha". Defaults to whatever
469    /// `capture_captcha` currently detects.
470    #[serde(default)]
471    pub kind:       Option<String>,
472}
473
474pub async fn inject_captcha_token_tool(
475    server: &VoidCrawlServer,
476    args: InjectCaptchaTokenArgs,
477) -> Result<OkResult, ErrorData> {
478    let handle = lookup(server, &args.session_id).await?;
479    let page = handle.page.lock().await;
480    let kind = match args.kind.as_deref() {
481        Some("turnstile") => CaptchaKind::Turnstile,
482        Some("recaptcha") => CaptchaKind::Recaptcha,
483        Some("hcaptcha") => CaptchaKind::Hcaptcha,
484        Some(other) => {
485            return Err(ErrorData::invalid_params(
486                format!(
487                    "unknown captcha kind {other:?} — expected 'turnstile', 'recaptcha', or 'hcaptcha'"
488                ),
489                None,
490            ));
491        }
492        None => {
493            // Auto-detect from the page.
494            let info = capture_captcha(&page).await.map_err(map_err)?;
495            info.map(|i| i.kind).ok_or_else(|| {
496                ErrorData::invalid_params(
497                    String::from("no captcha detected on page — pass `kind` explicitly"),
498                    None,
499                )
500            })?
501        }
502    };
503    inject_captcha_token(&page, kind, &args.token).await.map_err(map_err)?;
504    Ok(OkResult { ok: true })
505}
506
507// ── Solve captcha ───────────────────────────────────────────────────────
508
509#[derive(Debug, Deserialize, JsonSchema, Default)]
510pub struct SolveCaptchaArgs {
511    pub session_id:        String,
512    /// How long to wait (seconds) for the response token to appear after
513    /// clicking the widget. Default 20.
514    #[serde(default)]
515    pub wait_secs:         Option<u64>,
516    /// Click offset inside the widget's bounding rect from the left edge,
517    /// in CSS pixels. Default 28 — matches the checkbox position for
518    /// Turnstile / reCAPTCHA-v2 / hCaptcha anchor iframes. Override only
519    /// when a site customises widget size.
520    #[serde(default)]
521    pub checkbox_offset_x: Option<f64>,
522}
523
524#[derive(Debug, Serialize, JsonSchema)]
525pub struct SolveCaptchaResult {
526    /// Detected captcha kind (same tags as detect_captcha).
527    pub kind:    Option<String>,
528    /// Click coordinates dispatched in CSS pixels, if a widget rect was
529    /// found.
530    pub clicked: Option<(f64, f64)>,
531    /// Response token value, if one was observed within wait_secs.
532    /// Turnstile: `input[name=cf-turnstile-response]`.
533    /// reCAPTCHA: `#g-recaptcha-response`.
534    /// hCaptcha: `textarea[name=h-captcha-response]`.
535    pub token:   Option<String>,
536    /// True when a token was obtained (widget solved) or when the
537    /// detector no longer reports a captcha (page passed the wall).
538    pub solved:  bool,
539}
540
541pub async fn solve_captcha(
542    server: &VoidCrawlServer,
543    args: SolveCaptchaArgs,
544) -> Result<SolveCaptchaResult, ErrorData> {
545    let handle = lookup(server, &args.session_id).await?;
546    let page = handle.page.lock().await;
547
548    // 1. Identify what's on the page.
549    let kind = detect_captcha(&page).await.map_err(map_err)?;
550    let Some(kind) = kind else {
551        return Ok(SolveCaptchaResult {
552            kind:    None,
553            clicked: None,
554            token:   None,
555            solved:  true,
556        });
557    };
558    let kind_tag = kind.as_str().to_string();
559
560    // 2. Locate the widget's bounding rect. We try candidate selectors specific to
561    //    the detected kind, then fall back to generic iframe queries. Returns {x,
562    //    y, w, h} of the widget's *on-screen* box in CSS pixels, already offset by
563    //    any enclosing iframe origins.
564    const RECT_JS: &str = r#"
565        (function(kind) {
566            function rectOf(el) {
567                if (!el) return null;
568                const r = el.getBoundingClientRect();
569                if (r.width < 4 || r.height < 4) return null;
570                return { x: r.left, y: r.top, w: r.width, h: r.height };
571            }
572            const SELS = {
573                turnstile: [
574                    '.cf-turnstile iframe',
575                    'iframe[src*="challenges.cloudflare.com/turnstile"]',
576                    '.cf-turnstile',
577                ],
578                recaptcha: [
579                    'iframe[src*="recaptcha/api2/anchor"]',
580                    'iframe[src*="google.com/recaptcha"]',
581                    '.g-recaptcha',
582                ],
583                hcaptcha: [
584                    'iframe[src*="hcaptcha.com"][data-hcaptcha-widget-id]',
585                    'iframe[src*="hcaptcha.com"]',
586                    '.h-captcha',
587                ],
588            };
589            const list = SELS[kind] || [];
590            for (const sel of list) {
591                const el = document.querySelector(sel);
592                const r = rectOf(el);
593                if (r) return r;
594            }
595            return null;
596        })(arguments_kind_placeholder)
597    "#;
598    // The evaluate_js API takes a bare expression; inject the literal.
599    let rect_expr = RECT_JS.replace("arguments_kind_placeholder", &format!("{kind_tag:?}"));
600    let rect_val = page.evaluate_js(&rect_expr).await.map_err(map_err)?;
601
602    let Some(rect) = rect_val.as_object() else {
603        return Ok(SolveCaptchaResult {
604            kind:    Some(kind_tag),
605            clicked: None,
606            token:   None,
607            solved:  false,
608        });
609    };
610    let rx = rect.get("x").and_then(Value::as_f64).unwrap_or(0.0);
611    let ry = rect.get("y").and_then(Value::as_f64).unwrap_or(0.0);
612    let rh = rect.get("h").and_then(Value::as_f64).unwrap_or(0.0);
613
614    // 3. Compute click point — the standard checkbox sits ~28px from the iframe's
615    //    left edge, vertically centred. Small jitter keeps the event looking less
616    //    mechanical.
617    let offset_x = args.checkbox_offset_x.unwrap_or(28.0);
618    let jitter_x: f64 = (rx.fract() * 100.0) % 3.0 - 1.5; // deterministic ±1.5px
619    let jitter_y: f64 = (ry.fract() * 100.0) % 3.0 - 1.5;
620    let cx = rx + offset_x + jitter_x;
621    let cy = ry + rh / 2.0 + jitter_y;
622
623    // 4. Move, press, release — distinct MouseMoved first gives the widget's JS a
624    //    chance to observe a realistic pointer track.
625    page.dispatch_mouse_event(
626        void_crawl_core::DispatchMouseEventType::MouseMoved,
627        cx,
628        cy,
629        None,
630        None,
631        None,
632        None,
633        None,
634    )
635    .await
636    .map_err(map_err)?;
637    sleep(Duration::from_millis(60)).await;
638    page.dispatch_mouse_event(
639        DispatchMouseEventType::MousePressed,
640        cx,
641        cy,
642        Some(MouseButton::Left),
643        Some(1),
644        None,
645        None,
646        None,
647    )
648    .await
649    .map_err(map_err)?;
650    sleep(Duration::from_millis(50)).await;
651    page.dispatch_mouse_event(
652        DispatchMouseEventType::MouseReleased,
653        cx,
654        cy,
655        Some(MouseButton::Left),
656        Some(1),
657        None,
658        None,
659        None,
660    )
661    .await
662    .map_err(map_err)?;
663
664    // 5. Poll for the response token. Each family writes its solved token into a
665    //    known hidden input/textarea — presence of a non-empty value is the
666    //    canonical "solved" signal.
667    const TOKEN_JS: &str = r#"
668        (function() {
669            const q = (s) => { const el = document.querySelector(s); return el ? (el.value || el.textContent || '') : ''; };
670            const t = q('input[name="cf-turnstile-response"]') || q('textarea[name="cf-turnstile-response"]');
671            if (t) return t;
672            const r = q('#g-recaptcha-response') || q('textarea[name="g-recaptcha-response"]');
673            if (r) return r;
674            const h = q('textarea[name="h-captcha-response"]') || q('[name="h-captcha-response"]');
675            if (h) return h;
676            return '';
677        })()
678    "#;
679    let wait_for = Duration::from_secs(args.wait_secs.unwrap_or(20));
680    let deadline = Instant::now() + wait_for;
681    let mut token: Option<String> = None;
682    let mut solved = false;
683    while Instant::now() < deadline {
684        let v = page.evaluate_js(TOKEN_JS).await.map_err(map_err)?;
685        if let Some(s) = v.as_str()
686            && !s.is_empty()
687        {
688            token = Some(s.to_string());
689            solved = true;
690            break;
691        }
692        // Also accept: detector no longer sees a captcha (page passed
693        // the interstitial entirely, e.g. Cloudflare managed challenge).
694        if detect_captcha(&page).await.map_err(map_err)?.is_none() {
695            solved = true;
696            break;
697        }
698        sleep(Duration::from_millis(500)).await;
699    }
700
701    Ok(SolveCaptchaResult { kind: Some(kind_tag), clicked: Some((cx, cy)), token, solved })
702}
703
704// ── Teleport (geolocation / timezone / locale override) ─────────────────
705
706#[derive(Debug, Deserialize, JsonSchema, Default)]
707pub struct TeleportArgs {
708    pub session_id: String,
709    /// Latitude in decimal degrees (e.g. 30.2672 for Austin, TX).
710    pub latitude:   f64,
711    /// Longitude in decimal degrees (e.g. -97.7431 for Austin, TX).
712    pub longitude:  f64,
713    /// IANA timezone matching the coordinates (e.g. "America/Chicago").
714    /// Omit to leave the session's timezone unchanged.
715    #[serde(default)]
716    pub timezone:   Option<String>,
717    /// Accept-Language / locale to match (e.g. "en-US"). Omit to leave it.
718    #[serde(default)]
719    pub locale:     Option<String>,
720    /// GPS accuracy in meters reported to `navigator.geolocation`. Default 50.
721    #[serde(default)]
722    pub accuracy:   Option<f64>,
723}
724
725/// Override the session's geolocation (and optionally timezone + locale) so
726/// `navigator.geolocation` and location-aware sites resolve to the given
727/// coordinates — "teleport" the browser. The geolocation permission is granted
728/// automatically. Apply AFTER `session_open` and BEFORE navigating; the
729/// override persists across navigations on the session.
730pub async fn teleport(server: &VoidCrawlServer, args: TeleportArgs) -> Result<OkResult, ErrorData> {
731    let handle = lookup(server, &args.session_id).await?;
732    let page = handle.page.lock().await;
733    page.set_geolocation(args.latitude, args.longitude, args.accuracy).await.map_err(map_err)?;
734    if let Some(tz) = args.timezone.as_deref() {
735        page.set_timezone(tz).await.map_err(map_err)?;
736    }
737    if let Some(loc) = args.locale.as_deref() {
738        page.set_locale(loc).await.map_err(map_err)?;
739    }
740    Ok(OkResult { ok: true })
741}
742
743// ── Helper ──────────────────────────────────────────────────────────────
744
745async fn lookup(server: &VoidCrawlServer, id: &str) -> Result<Arc<DedicatedSession>, ErrorData> {
746    server
747        .state()
748        .sessions
749        .get(id)
750        .await
751        .ok_or_else(|| ErrorData::invalid_params(format!("unknown session_id: {id}"), None))
752}