1use crate::browser::Page;
12use anyhow::Result;
13use std::time::{Duration, Instant};
14
15pub const DEFAULT_FRAME_RETRY_INTERVAL: Duration = Duration::from_millis(100);
19
20pub const DEFAULT_FRAME_RETRY_TIMEOUT: Duration = Duration::from_secs(8);
24
25fn next_poll_sleep(now: Instant, deadline: Instant, interval: Duration) -> Option<Duration> {
31 if now >= deadline {
32 return None;
33 }
34 let remaining = deadline.saturating_duration_since(now);
35 Some(remaining.min(interval))
36}
37
38fn escape_js_string(s: &str) -> String {
50 let mut out = String::with_capacity(s.len());
51 for ch in s.chars() {
52 match ch {
53 '\\' => out.push_str("\\\\"),
54 '\'' => out.push_str("\\'"),
55 '"' => out.push_str("\\\""),
56 '\n' => out.push_str("\\n"),
57 '\r' => out.push_str("\\r"),
58 '\t' => out.push_str("\\t"),
59 '\0' => out.push_str("\\0"),
60 c => out.push(c),
61 }
62 }
63 out
64}
65
66fn lookup_iframe_offset(
72 iframe_offsets: &[(usize, String, String, f64, f64)],
73 url: &str,
74 iframe_idx: i64,
75) -> (f64, f64) {
76 if iframe_idx >= 0 {
77 iframe_offsets
78 .iter()
79 .find(|(idx, src, id, _, _)| *idx == iframe_idx as usize && (src == url || id == url))
80 .map(|(_, _, _, x, y)| (*x, *y))
81 } else {
82 iframe_offsets
83 .iter()
84 .find(|(_, src, id, _, _)| src == url || id == url)
85 .map(|(_, _, _, x, y)| (*x, *y))
86 }
87 .unwrap_or((0.0, 0.0))
88}
89
90pub async fn evaluate_in_all_frames<T>(page: &Page, expression: &str) -> Result<Vec<T>>
106where
107 T: serde::de::DeserializeOwned,
108{
109 let frame_ids = page.frames().await?;
110 let mut out = Vec::with_capacity(frame_ids.len());
111 for fid in frame_ids {
112 match page.evaluate_in_context(expression, &fid).await {
113 Ok(eval) => {
114 if let Ok(v) = eval.into_value::<T>() {
115 out.push(v);
116 }
117 }
118 Err(e) => {
119 tracing::debug!("frame {:?} disappeared during batch eval: {}", fid, e);
120 }
121 }
122 }
123 Ok(out)
124}
125
126pub async fn evaluate_in_frames_first<T, F>(
130 page: &Page,
131 expression: &str,
132 filter: F,
133 default: T,
134) -> Result<T>
135where
136 T: serde::de::DeserializeOwned + Clone,
137 F: Fn(&T) -> bool,
138{
139 let all = evaluate_in_all_frames::<T>(page, expression).await?;
140 Ok(all.into_iter().find(filter).unwrap_or(default))
141}
142
143async fn collect_iframe_offsets(
149 page: &Page,
150 main_frame: Option<&crate::FrameId>,
151) -> Result<Vec<(usize, String, String, f64, f64)>> {
152 let mut iframe_offsets: Vec<(usize, String, String, f64, f64)> = Vec::new();
153 if let Some(main) = main_frame {
154 let js = r#"
155 (function() {
156 const out = [];
157 const frames = document.querySelectorAll('iframe');
158 for (let i = 0; i < frames.length; i++) {
159 const f = frames[i];
160 const r = f.getBoundingClientRect();
161 out.push({ idx: i, src: f.src, id: f.id, x: r.left, y: r.top });
162 }
163 return out;
164 })()
165 "#;
166 let eval = page.evaluate_in_context(js, main).await?;
167 if let Ok(vals) = eval.into_value::<Vec<serde_json::Value>>() {
168 for v in vals {
169 if let (Some(idx), Some(x), Some(y)) =
170 (v["idx"].as_u64(), v["x"].as_f64(), v["y"].as_f64())
171 {
172 let src = v["src"].as_str().unwrap_or("").to_string();
173 let id = v["id"].as_str().unwrap_or("").to_string();
174 iframe_offsets.push((idx as usize, src, id, x, y));
175 }
176 }
177 }
178 }
179 Ok(iframe_offsets)
180}
181
182pub async fn find_element_centre_in_frames(
201 page: &Page,
202 selector: &str,
203) -> Result<Option<(f64, f64)>> {
204 let frame_ids = page.frames().await?;
205 let main_frame = page.mainframe().await?;
206 let iframe_offsets = collect_iframe_offsets(page, main_frame.as_ref()).await?;
207
208 let escaped = escape_js_string(selector);
209 let js = format!(
210 r#"(function() {{
211 const el = document.querySelector('{}');
212 if (!el) return null;
213 const r = el.getBoundingClientRect();
214 let iframeIdx = -1;
215 try {{
216 const frames = window.parent.frames;
217 for (let i = 0; i < frames.length; i++) {{
218 if (frames[i] === window) {{
219 iframeIdx = i;
220 break;
221 }}
222 }}
223 }} catch (e) {{}}
224 return {{ x: r.left + r.width / 2, y: r.top + r.height / 2, url: window.location.href, iframeIdx: iframeIdx }};
225 }})()"#,
226 escaped
227 );
228
229 for fid in frame_ids {
230 match page.evaluate_in_context(&js, &fid).await {
231 Ok(eval) => {
232 if let Ok(val) = eval.into_value::<serde_json::Value>() {
233 if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
234 let url = val["url"].as_str().unwrap_or("");
235 let iframe_idx = val["iframeIdx"].as_i64().unwrap_or(-1);
236 let (offset_x, offset_y) = if Some(&fid) == main_frame.as_ref() {
237 (0.0, 0.0)
238 } else {
239 lookup_iframe_offset(&iframe_offsets, url, iframe_idx)
240 };
241 return Ok(Some((x + offset_x, y + offset_y)));
242 }
243 }
244 }
245 Err(e) => {
246 tracing::debug!("frame {:?} disappeared during element search: {}", fid, e);
247 }
248 }
249 }
250 Ok(None)
251}
252
253pub async fn find_element_centre_in_frames_retry(
272 page: &Page,
273 selector: &str,
274 timeout: Duration,
275 interval: Duration,
276) -> Result<Option<(f64, f64)>> {
277 let deadline = Instant::now() + timeout;
278 loop {
279 if let Some(centre) = find_element_centre_in_frames(page, selector).await? {
280 return Ok(Some(centre));
281 }
282 match next_poll_sleep(Instant::now(), deadline, interval) {
283 Some(d) => tokio::time::sleep(d).await,
284 None => return Ok(None),
285 }
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq)]
294pub struct FrameTile {
295 pub index: usize,
297 pub left: f64,
299 pub top: f64,
301 pub width: f64,
303 pub height: f64,
305}
306
307impl FrameTile {
308 pub fn centre(&self) -> (f64, f64) {
312 (self.left + self.width / 2.0, self.top + self.height / 2.0)
313 }
314}
315
316pub async fn find_tiles_in_frames(page: &Page, selector: &str) -> Result<Vec<FrameTile>> {
334 let frame_ids = page.frames().await?;
335 let main_frame = page.mainframe().await?;
336 let iframe_offsets = collect_iframe_offsets(page, main_frame.as_ref()).await?;
337
338 let escaped = escape_js_string(selector);
339 let js = format!(
340 r#"(function() {{
341 const els = document.querySelectorAll('{}');
342 if (!els || els.length === 0) return null;
343 let iframeIdx = -1;
344 try {{
345 const frames = window.parent.frames;
346 for (let i = 0; i < frames.length; i++) {{
347 if (frames[i] === window) {{ iframeIdx = i; break; }}
348 }}
349 }} catch (e) {{}}
350 const tiles = [];
351 for (let i = 0; i < els.length; i++) {{
352 const r = els[i].getBoundingClientRect();
353 tiles.push({{ index: i, left: r.left, top: r.top, width: r.width, height: r.height }});
354 }}
355 return {{ url: window.location.href, iframeIdx: iframeIdx, tiles: tiles }};
356 }})()"#,
357 escaped
358 );
359
360 for fid in frame_ids {
361 let eval = match page.evaluate_in_context(&js, &fid).await {
362 Ok(e) => e,
363 Err(e) => {
364 tracing::debug!("frame {:?} disappeared during tile search: {}", fid, e);
365 continue;
366 }
367 };
368 let Ok(val) = eval.into_value::<serde_json::Value>() else {
369 continue;
370 };
371 let Some(raw_tiles) = val["tiles"].as_array() else {
372 continue;
373 };
374 if raw_tiles.is_empty() {
375 continue;
376 }
377 let url = val["url"].as_str().unwrap_or("");
378 let iframe_idx = val["iframeIdx"].as_i64().unwrap_or(-1);
379 let (offset_x, offset_y) = if Some(&fid) == main_frame.as_ref() {
380 (0.0, 0.0)
381 } else {
382 lookup_iframe_offset(&iframe_offsets, url, iframe_idx)
383 };
384 let mut out = Vec::with_capacity(raw_tiles.len());
385 for t in raw_tiles {
386 if let (Some(index), Some(left), Some(top), Some(width), Some(height)) = (
387 t["index"].as_u64(),
388 t["left"].as_f64(),
389 t["top"].as_f64(),
390 t["width"].as_f64(),
391 t["height"].as_f64(),
392 ) {
393 out.push(FrameTile {
394 index: index as usize,
395 left: left + offset_x,
396 top: top + offset_y,
397 width,
398 height,
399 });
400 }
401 }
402 if !out.is_empty() {
403 return Ok(out);
404 }
405 }
406 Ok(Vec::new())
407}
408
409pub async fn harvest_token_in_frames_retry(
417 page: &Page,
418 token_input_name: &str,
419 timeout: Duration,
420 interval: Duration,
421) -> Result<Option<String>> {
422 let deadline = Instant::now() + timeout;
423 loop {
424 if let Some(tok) = harvest_token_in_frames(page, token_input_name).await? {
425 return Ok(Some(tok));
426 }
427 match next_poll_sleep(Instant::now(), deadline, interval) {
428 Some(d) => tokio::time::sleep(d).await,
429 None => return Ok(None),
430 }
431 }
432}
433
434pub async fn find_iframe_rect_by_src(
440 page: &Page,
441 pattern: &str,
442) -> Result<Option<(f64, f64, f64, f64)>> {
443 let escaped = escape_js_string(pattern);
444 let js = format!(
445 r#"(() => {{
446 const frames = document.querySelectorAll('iframe');
447 for (const f of frames) {{
448 if (f.src && f.src.includes('{}')) {{
449 const r = f.getBoundingClientRect();
450 return {{ left: r.left, top: r.top, width: r.width, height: r.height }};
451 }}
452 }}
453 return null;
454 }})()"#,
455 escaped
456 );
457 let v = page.evaluate(js.as_str()).await?;
458 let val = v.into_value::<serde_json::Value>().unwrap_or(serde_json::Value::Null);
459 if let (Some(l), Some(t), Some(w), Some(h)) = (
460 val["left"].as_f64(),
461 val["top"].as_f64(),
462 val["width"].as_f64(),
463 val["height"].as_f64(),
464 ) {
465 Ok(Some((l, t, w, h)))
466 } else {
467 Ok(None)
468 }
469}
470
471pub async fn verify_any_token_in_frames(page: &Page) -> Result<bool> {
494 const ANY_TOKEN_JS: &str = r#"(() => {
495 const sels = [
496 '[name="cf-turnstile-response"]',
497 '[name="g-recaptcha-response"]',
498 '#g-recaptcha-response',
499 '[name="h-captcha-response"]',
500 '[name="captchaToken"]',
501 '[name="frc-captcha-solution"]',
502 '[name="altcha"]',
503 '[name="mcaptcha__token"]',
504 '[name="cap_token"]',
505 ];
506 for (const sel of sels) {
507 try {
508 const els = document.querySelectorAll(sel);
509 for (const el of els) {
510 const v = (el.value || el.textContent || '').trim();
511 if (v) return true;
512 }
513 } catch (_) { /* keep going */ }
514 }
515 return false;
516 })()"#;
517 let results = evaluate_in_all_frames::<bool>(page, ANY_TOKEN_JS).await?;
518 Ok(results.into_iter().any(|v| v))
519}
520
521pub async fn verify_token_in_frames(page: &Page, token_input_name: &str) -> Result<bool> {
522 Ok(harvest_token_in_frames(page, token_input_name)
523 .await?
524 .is_some())
525}
526
527pub async fn harvest_token_in_frames(
538 page: &Page,
539 token_input_name: &str,
540) -> Result<Option<String>> {
541 let escaped = escape_js_string(token_input_name);
542 let js = format!(
545 r#"(() => {{
546 const els = document.querySelectorAll('input[name="{0}"], textarea[name="{0}"], #{0}');
547 for (const el of els) {{
548 const v = (el.value || el.textContent || '').trim();
549 if (v) return v;
550 }}
551 return null;
552 }})()"#,
553 escaped
554 );
555 let results = evaluate_in_all_frames::<Option<String>>(page, &js).await?;
556 Ok(results.into_iter().flatten().find(|v| !v.is_empty()))
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 #[test]
564 fn escape_js_string_all_special_chars() {
565 let input = "\\'\"\n\r\t\0";
566 assert_eq!(escape_js_string(input), "\\\\\\\'\\\"\\n\\r\\t\\0");
567 }
568
569 #[test]
570 fn escape_js_string_backslash() {
571 assert_eq!(escape_js_string(r"\"), "\\\\");
572 }
573
574 #[test]
575 fn escape_js_string_single_quote() {
576 assert_eq!(escape_js_string("'"), "\\'");
577 }
578
579 #[test]
580 fn escape_js_string_double_quote() {
581 assert_eq!(escape_js_string("\""), "\\\"");
582 }
583
584 #[test]
585 fn escape_js_string_newline() {
586 assert_eq!(escape_js_string("a\nb"), "a\\nb");
587 }
588
589 #[test]
590 fn escape_js_string_carriage_return() {
591 assert_eq!(escape_js_string("a\rb"), "a\\rb");
592 }
593
594 #[test]
595 fn escape_js_string_tab() {
596 assert_eq!(escape_js_string("a\tb"), "a\\tb");
597 }
598
599 #[test]
600 fn escape_js_string_null_byte() {
601 assert_eq!(escape_js_string("a\0b"), "a\\0b");
602 }
603
604 #[test]
605 fn escape_js_string_mixed() {
606 let input = "line1\nline2\tcol\0end\\\"'";
607 assert_eq!(
608 escape_js_string(input),
609 "line1\\nline2\\tcol\\0end\\\\\\\"\\'"
610 );
611 }
612
613 #[test]
614 fn escape_js_string_no_special_chars() {
615 assert_eq!(escape_js_string("#simple-id"), "#simple-id");
616 }
617
618 #[test]
619 fn lookup_iframe_offset_by_index_and_url() {
620 let offsets = vec![
621 (0, "a.html".into(), "".into(), 10.0, 20.0),
622 (1, "b.html".into(), "".into(), 30.0, 40.0),
623 ];
624 assert_eq!(lookup_iframe_offset(&offsets, "a.html", 0), (10.0, 20.0));
625 assert_eq!(lookup_iframe_offset(&offsets, "b.html", 1), (30.0, 40.0));
626 }
627
628 #[test]
629 fn lookup_iframe_offset_fallback_when_index_missing() {
630 let offsets = vec![(0, "a.html".into(), "".into(), 10.0, 20.0)];
631 assert_eq!(lookup_iframe_offset(&offsets, "a.html", -1), (10.0, 20.0));
632 }
633
634 #[test]
635 fn lookup_iframe_offset_disambiguates_duplicate_src() {
636 let offsets = vec![
637 (0, "same.html".into(), "".into(), 10.0, 20.0),
638 (1, "same.html".into(), "".into(), 30.0, 40.0),
639 ];
640 assert_eq!(lookup_iframe_offset(&offsets, "same.html", 0), (10.0, 20.0));
642 assert_eq!(lookup_iframe_offset(&offsets, "same.html", 1), (30.0, 40.0));
643 assert_eq!(
645 lookup_iframe_offset(&offsets, "same.html", -1),
646 (10.0, 20.0)
647 );
648 }
649
650 #[test]
651 fn lookup_iframe_offset_empty_src_and_id() {
652 let offsets = vec![
653 (0, "".into(), "".into(), 5.0, 5.0),
654 (1, "".into(), "".into(), 15.0, 15.0),
655 ];
656 assert_eq!(lookup_iframe_offset(&offsets, "", 0), (5.0, 5.0));
657 assert_eq!(lookup_iframe_offset(&offsets, "", 1), (15.0, 15.0));
658 }
659
660 #[test]
661 fn lookup_iframe_offset_no_match() {
662 let offsets = vec![(0, "a.html".into(), "".into(), 10.0, 20.0)];
663 assert_eq!(
664 lookup_iframe_offset(&offsets, "missing.html", -1),
665 (0.0, 0.0)
666 );
667 }
668
669 #[test]
670 fn find_element_js_contains_query_selector() {
671 let selector = "#btn";
672 let escaped = escape_js_string(selector);
673 let js = format!(
674 r#"(function() {{ const el = document.querySelector('{}'); if (!el) return null; const r = el.getBoundingClientRect(); return {{ x: r.left + r.width / 2, y: r.top + r.height / 2, url: window.location.href }}; }})()"#,
675 escaped
676 );
677 assert!(js.contains("document.querySelector"));
678 assert!(js.contains("getBoundingClientRect"));
679 }
680
681 #[test]
682 fn frame_tile_centre_is_box_midpoint() {
683 let t = FrameTile {
684 index: 4,
685 left: 100.0,
686 top: 200.0,
687 width: 60.0,
688 height: 40.0,
689 };
690 assert_eq!(t.centre(), (130.0, 220.0));
691 }
692
693 #[test]
694 fn find_tiles_js_collects_all_matches_with_rects() {
695 let escaped = escape_js_string(".rc-imageselect-tile");
696 let js = format!(
697 r#"(function() {{
698 const els = document.querySelectorAll('{}');
699 if (!els || els.length === 0) return null;
700 const tiles = [];
701 for (let i = 0; i < els.length; i++) {{
702 const r = els[i].getBoundingClientRect();
703 tiles.push({{ index: i, left: r.left, top: r.top, width: r.width, height: r.height }});
704 }}
705 return {{ tiles: tiles }};
706 }})()"#,
707 escaped
708 );
709 assert!(js.contains("querySelectorAll"));
710 assert!(js.contains("getBoundingClientRect"));
711 assert!(js.contains("width: r.width"));
712 assert!(js.contains("index: i"));
713 }
714
715 #[test]
716 fn verify_token_js_contains_input_selector() {
717 let name = "g-recaptcha-response";
718 let escaped = escape_js_string(name);
719 let js = format!(
720 r#"!!document.querySelector('input[name="{}"][value]:not([value=""])')"#,
721 escaped
722 );
723 assert!(js.contains("input[name="));
724 assert!(js.contains("value]:not([value=\"\"])"));
725 }
726
727 #[test]
728 fn verify_token_escapes_quotes() {
729 let name = r#"token"value"#;
730 let escaped = escape_js_string(name);
731 assert!(escaped.contains("\\\""));
732 for (i, ch) in escaped.char_indices() {
733 if ch == '"' {
734 assert!(
735 i > 0 && escaped.as_bytes()[i - 1] == b'\\',
736 "quote at {} not escaped",
737 i
738 );
739 }
740 }
741 }
742
743 #[test]
744 fn next_poll_sleep_returns_interval_when_deadline_far() {
745 let now = Instant::now();
746 let deadline = now + Duration::from_secs(10);
747 let interval = Duration::from_millis(100);
748 let s = next_poll_sleep(now, deadline, interval).unwrap();
749 assert_eq!(s, Duration::from_millis(100));
750 }
751
752 #[test]
753 fn next_poll_sleep_clamps_to_remaining_when_close_to_deadline() {
754 let now = Instant::now();
755 let deadline = now + Duration::from_millis(40);
756 let interval = Duration::from_millis(100);
757 let s = next_poll_sleep(now, deadline, interval).unwrap();
758 assert!(s <= Duration::from_millis(40));
760 assert!(s >= Duration::from_millis(30));
761 }
762
763 #[test]
764 fn next_poll_sleep_returns_none_at_deadline() {
765 let now = Instant::now();
766 let deadline = now;
767 assert!(next_poll_sleep(now, deadline, Duration::from_millis(100)).is_none());
768 }
769
770 #[test]
771 fn next_poll_sleep_returns_none_past_deadline() {
772 let now = Instant::now();
773 let deadline = now - Duration::from_millis(1);
774 assert!(next_poll_sleep(now, deadline, Duration::from_millis(100)).is_none());
775 }
776
777 #[test]
778 fn next_poll_sleep_zero_interval_still_yields_zero_sleep() {
779 let now = Instant::now();
783 let deadline = now + Duration::from_millis(50);
784 let s = next_poll_sleep(now, deadline, Duration::ZERO).unwrap();
785 assert_eq!(s, Duration::ZERO);
786 }
787
788 #[test]
789 fn default_retry_constants_are_sane() {
790 assert!(DEFAULT_FRAME_RETRY_INTERVAL > Duration::ZERO);
794 assert!(DEFAULT_FRAME_RETRY_TIMEOUT > DEFAULT_FRAME_RETRY_INTERVAL);
795 let max_polls =
797 DEFAULT_FRAME_RETRY_TIMEOUT.as_millis() / DEFAULT_FRAME_RETRY_INTERVAL.as_millis() + 1;
798 assert!(
799 max_polls <= 200,
800 "default retry would issue {max_polls} CDP calls per attempt — too chatty",
801 );
802 }
803
804 #[test]
805 fn verify_token_escapes_null_and_newline() {
806 let name = "token\0value\n";
807 let escaped = escape_js_string(name);
808 assert!(escaped.contains("\\0"));
809 assert!(escaped.contains("\\n"));
810 assert!(!escaped.contains('\0'));
811 assert!(!escaped.contains('\n'));
812 }
813
814 #[test]
815 fn escape_js_string_empty() {
816 assert_eq!(escape_js_string(""), "");
817 }
818
819 #[test]
820 fn escape_js_string_unicode_untouched() {
821 let input = "emoji: 🎉 café ñ";
823 assert_eq!(escape_js_string(input), input);
824 }
825
826 #[test]
827 fn escape_js_string_preserves_length_hint() {
828 let input = "a".repeat(1000);
829 let out = escape_js_string(&input);
830 assert_eq!(out, input); }
832
833 #[test]
834 fn lookup_iframe_offset_matches_by_id() {
835 let offsets = vec![
836 (0, "a.html".into(), "iframe-0".into(), 10.0, 20.0),
837 ];
838 assert_eq!(lookup_iframe_offset(&offsets, "iframe-0", -1), (10.0, 20.0));
839 }
840
841 #[test]
842 fn lookup_iframe_offset_index_mismatch_falls_back_to_first_match() {
843 let offsets = vec![
844 (0, "a.html".into(), "".into(), 10.0, 20.0),
845 (1, "b.html".into(), "".into(), 30.0, 40.0),
846 ];
847 assert_eq!(lookup_iframe_offset(&offsets, "a.html", 99), (0.0, 0.0));
850 assert_eq!(lookup_iframe_offset(&offsets, "a.html", -1), (10.0, 20.0));
851 }
852
853 #[test]
854 fn lookup_iframe_offset_negative_beyond_minus_one_treated_as_fallback() {
855 let offsets = vec![(0, "x".into(), "".into(), 5.0, 6.0)];
858 assert_eq!(lookup_iframe_offset(&offsets, "x", -5), (5.0, 6.0));
859 }
860
861 #[test]
862 fn next_poll_sleep_interval_larger_than_remaining() {
863 let now = Instant::now();
864 let deadline = now + Duration::from_millis(30);
865 let interval = Duration::from_millis(100);
866 let s = next_poll_sleep(now, deadline, interval).unwrap();
867 assert_eq!(s, Duration::from_millis(30));
868 }
869
870 #[test]
871 fn next_poll_sleep_very_small_remaining() {
872 let now = Instant::now();
873 let deadline = now + Duration::from_nanos(1);
874 let s = next_poll_sleep(now, deadline, Duration::from_millis(100)).unwrap();
875 assert_eq!(s, Duration::from_nanos(1));
876 }
877}