use serde::{Deserialize, Serialize};
use crate::page::Bbox;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SelectorKind {
Css,
Xpath,
Regex,
Jsonld,
Attr,
GlobalId,
Role,
Visual,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelectorEntry {
#[serde(rename = "type")]
pub kind: SelectorKind,
#[serde(default)]
pub value: String,
#[serde(default)]
pub regex: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub nth: Option<u32>,
#[serde(default)]
pub x: Option<f64>,
#[serde(default)]
pub y: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum SelectorResolution {
Resolved { bbox: Bbox },
Empty { reason: String },
Ambiguous { candidates: usize, reason: String },
}
pub(crate) fn candidates_js(entry: &SelectorEntry) -> Option<String> {
match entry.kind {
SelectorKind::Css | SelectorKind::Attr => {
Some(format!("Array.from(document.querySelectorAll({:?}))", entry.value))
}
SelectorKind::GlobalId => {
let base = format!("Array.from(document.querySelectorAll({:?}))", entry.value);
match &entry.name {
Some(prefix) => {
Some(format!("{base}.filter(el => (el.id || '').startsWith({prefix:?}))"))
}
None => Some(base),
}
}
SelectorKind::Xpath => Some(format!(
"(() => {{ \
const r = document.evaluate({:?}, document, null, \
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); \
const out = []; \
for (let i = 0; i < r.snapshotLength; i++) out.push(r.snapshotItem(i)); \
return out; \
}})()",
entry.value
)),
SelectorKind::Role | SelectorKind::Visual | SelectorKind::Jsonld | SelectorKind::Regex => {
None
}
}
}
pub(crate) fn visible_rects_js(candidates_expr: &str) -> String {
format!(
"(() => {{ \
const els = {candidates_expr}; \
return els.filter(el => {{ \
if (!el || !el.isConnected) return false; \
const style = window.getComputedStyle(el); \
if (!style || style.display === 'none' || style.visibility === 'hidden') return false; \
const r = el.getBoundingClientRect(); \
return r.width > 0 && r.height > 0; \
}}).map(el => {{ \
const r = el.getBoundingClientRect(); \
return {{ x: r.x, y: r.y, width: r.width, height: r.height }}; \
}}); \
}})()"
)
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub(crate) struct RawRect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl RawRect {
pub(crate) fn to_bbox(self) -> Bbox {
let x = self.x.floor().max(0.0);
let y = self.y.floor().max(0.0);
let width = (self.x + self.width - x).ceil().max(1.0);
let height = (self.y + self.height - y).ceil().max(1.0);
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "CSS pixel coordinates are always small non-negative values in practice"
)]
Bbox { x: x as u32, y: y as u32, width: width as u32, height: height as u32 }
}
}
pub(crate) fn pick_resolution(
total_matches: usize,
visible: &[RawRect],
nth: Option<u32>,
describe: impl Fn() -> String,
) -> SelectorResolution {
if visible.is_empty() {
let reason = if total_matches == 0 {
format!("{} matched no elements", describe())
} else {
format!(
"{} matched {total_matches} element(s), but none are visible \
(hidden, zero-area, or detached)",
describe()
)
};
return SelectorResolution::Empty { reason };
}
if let Some(n) = nth {
let idx = n as usize;
return match visible.get(idx) {
Some(rect) => SelectorResolution::Resolved { bbox: rect.to_bbox() },
None => SelectorResolution::Empty {
reason: format!(
"nth={n} out of range: {} visible match(es) for {}",
visible.len(),
describe()
),
},
};
}
match visible.len() {
1 => SelectorResolution::Resolved { bbox: visible[0].to_bbox() },
n => SelectorResolution::Ambiguous {
candidates: n,
reason: format!(
"{n} visible matches for {}; pass `nth` to disambiguate",
describe()
),
},
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, reason = "test harness")]
mod tests {
use super::*;
fn rect(x: f64, y: f64, w: f64, h: f64) -> RawRect {
RawRect { x, y, width: w, height: h }
}
#[test]
fn candidates_js_css_and_attr_use_plain_query_selector_all() {
let css = SelectorEntry {
kind: SelectorKind::Css,
value: "h1".into(),
regex: None,
name: None,
nth: None,
x: None,
y: None,
};
let js = candidates_js(&css).expect("css produces a candidate expression");
assert!(js.contains("querySelectorAll"));
assert!(js.contains("\"h1\""));
}
#[test]
fn candidates_js_global_id_filters_by_prefix() {
let entry = SelectorEntry {
kind: SelectorKind::GlobalId,
value: "tr.athing".into(),
regex: None,
name: Some("score_".into()),
nth: None,
x: None,
y: None,
};
let js = candidates_js(&entry).expect("global_id produces a candidate expression");
assert!(js.contains("startsWith"));
assert!(js.contains("\"score_\""));
}
#[test]
fn candidates_js_role_visual_jsonld_regex_are_none() {
for kind in
[SelectorKind::Role, SelectorKind::Visual, SelectorKind::Jsonld, SelectorKind::Regex]
{
let entry = SelectorEntry {
kind,
value: String::new(),
regex: None,
name: None,
nth: None,
x: None,
y: None,
};
assert!(candidates_js(&entry).is_none(), "{kind:?} should have no DOM candidate step");
}
}
#[test]
fn pick_resolution_zero_matches_is_empty_with_zero_reason() {
let outcome = pick_resolution(0, &[], None, || "css \"h1\"".into());
match outcome {
SelectorResolution::Empty { reason } => assert!(reason.contains("matched no elements")),
other => panic!("expected Empty, got {other:?}"),
}
}
#[test]
fn pick_resolution_matches_but_none_visible_is_empty_with_visibility_reason() {
let outcome = pick_resolution(3, &[], None, || "css \".x\"".into());
match outcome {
SelectorResolution::Empty { reason } => {
assert!(reason.contains("none are visible"), "got: {reason}");
}
other => panic!("expected Empty, got {other:?}"),
}
}
#[test]
fn pick_resolution_single_visible_is_resolved() {
let outcome = pick_resolution(1, &[rect(1.0, 2.0, 10.0, 20.0)], None, || "x".into());
match outcome {
SelectorResolution::Resolved { bbox } => {
assert_eq!(bbox, Bbox { x: 1, y: 2, width: 10, height: 20 });
}
other => panic!("expected Resolved, got {other:?}"),
}
}
#[test]
fn pick_resolution_multiple_without_nth_is_ambiguous() {
let outcome = pick_resolution(
2,
&[rect(0.0, 0.0, 5.0, 5.0), rect(10.0, 10.0, 5.0, 5.0)],
None,
|| "css \".x\"".into(),
);
match outcome {
SelectorResolution::Ambiguous { candidates, .. } => assert_eq!(candidates, 2),
other => panic!("expected Ambiguous, got {other:?}"),
}
}
#[test]
fn pick_resolution_multiple_with_nth_resolves_that_index() {
let outcome = pick_resolution(
2,
&[rect(0.0, 0.0, 5.0, 5.0), rect(10.0, 10.0, 6.0, 7.0)],
Some(1),
|| "css \".x\"".into(),
);
match outcome {
SelectorResolution::Resolved { bbox } => {
assert_eq!(bbox, Bbox { x: 10, y: 10, width: 6, height: 7 });
}
other => panic!("expected Resolved, got {other:?}"),
}
}
#[test]
fn pick_resolution_nth_out_of_range_is_empty() {
let outcome =
pick_resolution(1, &[rect(0.0, 0.0, 5.0, 5.0)], Some(5), || "css \".x\"".into());
match outcome {
SelectorResolution::Empty { reason } => assert!(reason.contains("out of range")),
other => panic!("expected Empty, got {other:?}"),
}
}
#[test]
fn raw_rect_to_bbox_rounds_outward() {
let bbox = RawRect { x: 1.2, y: 2.8, width: 10.1, height: 5.9 }.to_bbox();
assert_eq!(bbox, Bbox { x: 1, y: 2, width: 11, height: 7 });
}
}