use crate::behavior;
use crate::frame::FrameTile;
use crate::Page;
use tracing::warn;
pub(crate) const GRID_TILE_PROBES: &[&str] = &[
".rc-imageselect-tile",
".task-image",
".image-task",
".hcaptcha-checkbox-img",
".rc-imageselect-table td",
".hcaptcha-table td",
".captcha-grid > .tile",
"#widget .icon-item",
"#widget .color-item",
"#widget .cell",
"#widget .grid > .cell",
];
pub(crate) const GRID_VERIFY_PROBES: &[&str] = &[
"#recaptcha-verify-button",
".rc-button-default",
".button-submit",
"[data-pp=\"submit\"]",
"button[type=\"submit\"]",
"#widget #submit",
"#widget button",
];
pub(crate) async fn locate_grid_tiles(page: &Page) -> Option<(Vec<FrameTile>, &'static str)> {
for sel in GRID_TILE_PROBES {
if let Ok(tiles) = crate::frame::find_tiles_in_frames(page, sel).await {
if !tiles.is_empty() {
return Some((tiles, sel));
}
}
}
None
}
pub(crate) async fn click_grid_tiles_trusted(page: &Page, indices: &[usize]) -> usize {
let Some((tiles, _sel)) = locate_grid_tiles(page).await else {
return 0;
};
click_located_tiles(page, &tiles, indices).await
}
pub(crate) async fn click_located_tiles(
page: &Page,
tiles: &[FrameTile],
indices: &[usize],
) -> usize {
let by_index: std::collections::HashMap<usize, FrameTile> =
tiles.iter().map(|t| (t.index, *t)).collect();
let mut clicked = 0usize;
let mut last: Option<(f64, f64)> = None;
for &idx in indices {
let Some(tile) = by_index.get(&idx) else {
continue;
};
let (cx, cy) = tile.centre();
if let Some((px, py)) = last {
if let Err(e) = behavior::mouse_move_human(page, px, py, cx, cy).await {
warn!("grid-click inter-tile move failed ({e}); clicking tile without a realistic approach path");
}
}
if behavior::click_realistic(page, cx, cy).await.is_ok() {
clicked += 1;
}
last = Some((cx, cy));
behavior::random_pause(120, 360).await;
}
if clicked > 0 && !click_verify_button(page).await {
warn!(
"grid-click selected {clicked} tile(s) but no verify/submit button was found to click; \
the selection was NOT submitted, the solve will not advance from this grid"
);
}
clicked
}
pub(crate) async fn click_verify_button(page: &Page) -> bool {
for sel in GRID_VERIFY_PROBES {
if let Ok(Some((x, y))) = crate::frame::find_element_centre_in_frames(page, sel).await {
if behavior::click_realistic(page, x, y).await.is_ok() {
return true;
}
}
}
false
}
#[cfg(feature = "vision")]
pub(crate) fn grid_region(tiles: &[FrameTile]) -> Option<(f64, f64, f64, f64)> {
let first = tiles.first()?;
let mut l = first.left;
let mut t = first.top;
let mut r = first.left + first.width;
let mut b = first.top + first.height;
for tile in &tiles[1..] {
l = l.min(tile.left);
t = t.min(tile.top);
r = r.max(tile.left + tile.width);
b = b.max(tile.top + tile.height);
}
Some((l, t, (r - l).max(0.0), (b - t).max(0.0)))
}
#[cfg(feature = "vision")]
fn count_clusters(vals: impl Iterator<Item = f64>, tol: f64) -> usize {
let mut xs: Vec<f64> = vals.collect();
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
if xs.is_empty() {
return 0;
}
let mut clusters = 1usize;
for w in xs.windows(2) {
if (w[1] - w[0]).abs() > tol {
clusters += 1;
}
}
clusters
}
#[cfg(feature = "vision")]
pub(crate) fn infer_grid_dims(tiles: &[FrameTile]) -> Option<(usize, usize)> {
if tiles.is_empty() {
return None;
}
let min_w = tiles.iter().map(|t| t.width).fold(f64::INFINITY, f64::min);
let min_h = tiles.iter().map(|t| t.height).fold(f64::INFINITY, f64::min);
let col_tol = (min_w / 2.0).max(1.0);
let row_tol = (min_h / 2.0).max(1.0);
let cols = count_clusters(tiles.iter().map(|t| t.left + t.width / 2.0), col_tol);
let rows = count_clusters(tiles.iter().map(|t| t.top + t.height / 2.0), row_tol);
if cols == 0 || rows == 0 {
return None;
}
Some((cols, rows))
}
#[cfg(feature = "vision")]
pub(crate) fn scale_rect_to_image(
rect: (f64, f64, f64, f64),
css_vw: f64,
css_vh: f64,
img_w: u32,
img_h: u32,
) -> Option<(u32, u32, u32, u32)> {
if css_vw <= 0.0 || css_vh <= 0.0 || img_w == 0 || img_h == 0 {
return None;
}
let sx = img_w as f64 / css_vw;
let sy = img_h as f64 / css_vh;
let (l, t, w, h) = rect;
let x0 = (l * sx).clamp(0.0, img_w as f64);
let y0 = (t * sy).clamp(0.0, img_h as f64);
let x1 = ((l + w) * sx).clamp(0.0, img_w as f64);
let y1 = ((t + h) * sy).clamp(0.0, img_h as f64);
let cw = (x1 - x0).max(0.0) as u32;
let ch = (y1 - y0).max(0.0) as u32;
if cw == 0 || ch == 0 {
return None;
}
Some((x0 as u32, y0 as u32, cw, ch))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_lists_are_nonempty_and_cover_both_providers() {
assert!(
GRID_TILE_PROBES.contains(&".rc-imageselect-tile"),
"reCAPTCHA tiles"
);
assert!(GRID_TILE_PROBES.contains(&".task-image"), "hCaptcha tiles");
assert!(!GRID_VERIFY_PROBES.is_empty());
assert!(GRID_VERIFY_PROBES.contains(&"#recaptcha-verify-button"));
}
#[test]
fn probe_lists_have_no_duplicates() {
for (i, a) in GRID_TILE_PROBES.iter().enumerate() {
for b in &GRID_TILE_PROBES[i + 1..] {
assert_ne!(a, b, "duplicate tile probe {a}");
}
}
for (i, a) in GRID_VERIFY_PROBES.iter().enumerate() {
for b in &GRID_VERIFY_PROBES[i + 1..] {
assert_ne!(a, b, "duplicate verify probe {a}");
}
}
}
}
#[cfg(all(test, feature = "vision"))]
mod geom_tests {
use super::*;
fn tile(index: usize, left: f64, top: f64, w: f64, h: f64) -> FrameTile {
FrameTile {
index,
left,
top,
width: w,
height: h,
}
}
#[test]
fn grid_region_unions_all_tiles() {
let tiles = vec![
tile(0, 10.0, 10.0, 50.0, 50.0),
tile(1, 70.0, 10.0, 50.0, 50.0),
tile(2, 10.0, 70.0, 50.0, 50.0),
];
assert_eq!(grid_region(&tiles), Some((10.0, 10.0, 110.0, 110.0)));
}
#[test]
fn grid_region_empty_is_none() {
assert_eq!(grid_region(&[]), None);
}
#[test]
fn infer_grid_dims_3x3() {
let mut tiles = Vec::new();
let mut idx = 0;
for row in 0..3 {
for col in 0..3 {
tiles.push(tile(
idx,
col as f64 * 100.0,
row as f64 * 100.0,
100.0,
100.0,
));
idx += 1;
}
}
assert_eq!(infer_grid_dims(&tiles), Some((3, 3)));
}
#[test]
fn infer_grid_dims_4x4() {
let mut tiles = Vec::new();
let mut idx = 0;
for row in 0..4 {
for col in 0..4 {
tiles.push(tile(idx, col as f64 * 80.0, row as f64 * 80.0, 80.0, 80.0));
idx += 1;
}
}
assert_eq!(infer_grid_dims(&tiles), Some((4, 4)));
}
#[test]
fn infer_grid_dims_tolerates_subpixel_jitter() {
let tiles = vec![
tile(0, 0.3, 0.0, 100.0, 100.0),
tile(1, 100.1, 0.4, 100.0, 100.0),
tile(2, 199.7, 0.0, 100.0, 100.0),
];
assert_eq!(infer_grid_dims(&tiles), Some((3, 1)));
}
#[test]
fn infer_grid_dims_ragged_last_row() {
let tiles = vec![
tile(0, 0.0, 0.0, 100.0, 100.0),
tile(1, 100.0, 0.0, 100.0, 100.0),
tile(2, 200.0, 0.0, 100.0, 100.0),
tile(3, 0.0, 100.0, 100.0, 100.0),
];
assert_eq!(infer_grid_dims(&tiles), Some((3, 2)));
}
#[test]
fn infer_grid_dims_empty_is_none() {
assert_eq!(infer_grid_dims(&[]), None);
}
#[test]
fn scale_rect_dpr1_identity() {
let r = scale_rect_to_image((100.0, 50.0, 200.0, 150.0), 1000.0, 800.0, 1000, 800);
assert_eq!(r, Some((100, 50, 200, 150)));
}
#[test]
fn scale_rect_dpr2_doubles() {
let r = scale_rect_to_image((100.0, 50.0, 200.0, 150.0), 1000.0, 800.0, 2000, 1600);
assert_eq!(r, Some((200, 100, 400, 300)));
}
#[test]
fn scale_rect_clamps_to_image_bounds() {
let r = scale_rect_to_image((900.0, 0.0, 500.0, 100.0), 1000.0, 800.0, 1000, 800);
assert_eq!(r, Some((900, 0, 100, 100)));
}
#[test]
fn scale_rect_zero_dims_is_none() {
assert_eq!(
scale_rect_to_image((0.0, 0.0, 10.0, 10.0), 0.0, 800.0, 1000, 800),
None
);
assert_eq!(
scale_rect_to_image((0.0, 0.0, 10.0, 10.0), 1000.0, 800.0, 0, 800),
None
);
}
#[test]
fn scale_rect_empty_crop_is_none() {
assert_eq!(
scale_rect_to_image((10.0, 10.0, 0.0, 50.0), 1000.0, 800.0, 1000, 800),
None
);
}
}