1use 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
25fn 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#[derive(Debug, Deserialize, JsonSchema, Default)]
44pub struct ClickArgs {
45 pub session_id: String,
46 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#[derive(Debug, Deserialize, JsonSchema, Default)]
65pub struct ClickVisualCoordsArgs {
66 pub session_id: String,
67 pub x: f64,
69 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 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#[derive(Debug, Deserialize, JsonSchema, Default)]
112pub struct TypeTextArgs {
113 pub session_id: String,
114 #[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 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#[derive(Debug, Deserialize, JsonSchema, Default)]
152pub struct EvalJsArgs {
153 pub session_id: String,
154 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#[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#[derive(Debug, Deserialize, JsonSchema, Default)]
198pub struct ExtractArgs {
199 pub session_id: String,
200 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#[derive(Debug, Deserialize, JsonSchema, Default)]
233pub struct AxTreeArgs {
234 pub session_id: String,
235 #[serde(default)]
238 pub mode: Option<String>,
239 #[serde(default)]
241 pub depth: Option<i64>,
242}
243
244#[derive(Debug, Serialize, JsonSchema)]
245pub struct AxTreeResult {
246 pub tree: String,
248 #[schemars(schema_with = "any_value_array_schema")]
250 pub nodes: Vec<Value>,
251 pub node_count: usize,
253 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 pub role: String,
283 pub name: String,
285 #[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#[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#[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 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#[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#[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 pub kind: Option<String>,
401 pub sitekey: Option<String>,
403 pub widget_selector: Option<String>,
405 pub widget_rect: Option<WidgetRectJson>,
406 pub widget_rendered: bool,
409 pub response_field_selector: Option<String>,
411 pub existing_token: Option<String>,
413 pub action: Option<String>,
415 pub cdata: Option<String>,
416 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#[derive(Debug, Deserialize, JsonSchema, Default)]
463pub struct InjectCaptchaTokenArgs {
464 pub session_id: String,
465 pub token: String,
467 #[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 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#[derive(Debug, Deserialize, JsonSchema, Default)]
510pub struct SolveCaptchaArgs {
511 pub session_id: String,
512 #[serde(default)]
515 pub wait_secs: Option<u64>,
516 #[serde(default)]
521 pub checkbox_offset_x: Option<f64>,
522}
523
524#[derive(Debug, Serialize, JsonSchema)]
525pub struct SolveCaptchaResult {
526 pub kind: Option<String>,
528 pub clicked: Option<(f64, f64)>,
531 pub token: Option<String>,
536 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 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 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 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 let offset_x = args.checkbox_offset_x.unwrap_or(28.0);
618 let jitter_x: f64 = (rx.fract() * 100.0) % 3.0 - 1.5; 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 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 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 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#[derive(Debug, Deserialize, JsonSchema, Default)]
707pub struct TeleportArgs {
708 pub session_id: String,
709 pub latitude: f64,
711 pub longitude: f64,
713 #[serde(default)]
716 pub timezone: Option<String>,
717 #[serde(default)]
719 pub locale: Option<String>,
720 #[serde(default)]
722 pub accuracy: Option<f64>,
723}
724
725pub 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
743async 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}