render: expose real geometry to JS behind the render feature
getBoundingClientRect, offsetWidth/Height, and offsetTop/Left now read real box geometry from the obscura-render layout cache when the render feature is compiled in. elementFromPoint inherits correct hit-testing because it already delegates to getBoundingClientRect per element. Adds a render feature on obscura-js (propagated through obscura-browser and obscura-cli), a lazily-computed layout cache and viewport in ObscuraState (feature-gated, cleared on navigation), and an op_layout_geometry op. bootstrap.js probes the op with typeof, so the default scraping build is unchanged and falls through to the synthetic rect. With --features render, block siblings stack and flex children place horizontally at correct positions, and elementFromPoint resolves the right element. Default build returns the synthetic rect unchanged. Obstacle course 33/33; obscura-js unit failures identical to main.
This commit is contained in:
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
[features]
|
||||
default = []
|
||||
stealth = ["obscura-net/stealth", "obscura-js/stealth"]
|
||||
render = ["obscura-js/render"]
|
||||
|
||||
[dependencies]
|
||||
obscura-dom = { workspace = true }
|
||||
|
||||
@@ -14,6 +14,7 @@ path = "src/worker.rs"
|
||||
[features]
|
||||
default = []
|
||||
stealth = ["obscura-browser/stealth", "obscura-net/stealth", "obscura-mcp/stealth"]
|
||||
render = ["obscura-browser/render"]
|
||||
|
||||
[dependencies]
|
||||
obscura-browser = { workspace = true }
|
||||
|
||||
@@ -9,6 +9,10 @@ default = []
|
||||
# fetch()/XHR be routed through it so JS-level requests carry the same Chrome
|
||||
# TLS fingerprint and client hints as stealth navigation.
|
||||
stealth = ["obscura-net/stealth"]
|
||||
# Builds the obscura-render layout layer and exposes real box geometry to JS
|
||||
# (getBoundingClientRect, elementFromPoint, offset*). Off by default so the
|
||||
# scraping build stays lean; the cache is feature-gated and absent otherwise.
|
||||
render = ["dep:obscura-render"]
|
||||
|
||||
[build-dependencies]
|
||||
deno_core = "0.350"
|
||||
@@ -41,3 +45,4 @@ pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
||||
hkdf = "0.12"
|
||||
# CSPRNG for getRandomValues / randomUUID (replaces Math.random).
|
||||
getrandom = "0.2"
|
||||
obscura-render = { workspace = true, optional = true }
|
||||
|
||||
41
crates/obscura-js/js/bootstrap.js
vendored
41
crates/obscura-js/js/bootstrap.js
vendored
@@ -2329,9 +2329,16 @@ class Element extends Node {
|
||||
});
|
||||
return this._dataset;
|
||||
}
|
||||
get offsetWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
|
||||
get offsetHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
|
||||
get offsetTop() { return 0; } get offsetLeft() { return 0; }
|
||||
get offsetWidth() {
|
||||
if (this._isViewportRoot()) return globalThis.innerWidth || 1280;
|
||||
return this.getBoundingClientRect().width;
|
||||
}
|
||||
get offsetHeight() {
|
||||
if (this._isViewportRoot()) return globalThis.innerHeight || 720;
|
||||
return this.getBoundingClientRect().height;
|
||||
}
|
||||
get offsetTop() { return this.getBoundingClientRect().top; }
|
||||
get offsetLeft() { return this.getBoundingClientRect().left; }
|
||||
// documentElement / body / window expose VIEWPORT geometry, not their own content box.
|
||||
// Puppeteer's #clickableBox clips boxes to document.documentElement.clientWidth/Height;
|
||||
// returning 100x20 there made every element appear off-screen and broke .click().
|
||||
@@ -2380,11 +2387,29 @@ class Element extends Node {
|
||||
toJSON() { return this; },
|
||||
};
|
||||
}
|
||||
// No layout engine, but Playwright's actionability polling needs each
|
||||
// element to occupy a stable, distinct rect so hit-testing can pick the
|
||||
// right one (issue #45). Synthesize a deterministic position from the
|
||||
// node id: every nid maps to a unique cell in a 12-column grid, sized
|
||||
// to fit a 1280x720 viewport. Stable across reads, different per node.
|
||||
// Real layout when the render feature is compiled in: ask the Rust layout
|
||||
// cache for this element's border box. The op is absent in the default
|
||||
// build, so probe with typeof and fall through to the synthetic rect below.
|
||||
if (typeof Deno.core.ops.op_layout_geometry === 'function') {
|
||||
try {
|
||||
var _geom = Deno.core.ops.op_layout_geometry(String(this._nid | 0));
|
||||
if (_geom) {
|
||||
var _r = JSON.parse(_geom);
|
||||
if (_r && typeof _r.width === 'number') {
|
||||
var _x = _r.x, _y = _r.y, _w = _r.width, _h = _r.height;
|
||||
return {
|
||||
x: _x, y: _y, width: _w, height: _h,
|
||||
top: _y, right: _x + _w, bottom: _y + _h, left: _x,
|
||||
toJSON() { return this; },
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_e) {}
|
||||
}
|
||||
// No layout engine (default build) or this node has no box: synthesize a
|
||||
// deterministic position from the node id so Playwright's actionability
|
||||
// polling still gets a stable, distinct rect for hit-testing (issue #45).
|
||||
// Every nid maps to a unique cell in a 12-column grid for a 1280x720 viewport.
|
||||
const VW = 1280, VH = 720, COLS = 12, CW = 100, CH = 20, GX = 110, GY = 30;
|
||||
const rowsPerScreen = Math.max(1, Math.floor((VH - 10) / GY));
|
||||
const cell = this._nid | 0;
|
||||
|
||||
@@ -103,6 +103,15 @@ pub struct ObscuraState {
|
||||
// drained by the Page into its network_events so the CDP layer emits
|
||||
// Network.requestWillBeSent / responseReceived for them (issue #406).
|
||||
pub js_network_events: Vec<JsNetworkEvent>,
|
||||
/// Lazy layout cache from obscura-render, plus the CSS-pixel viewport. The
|
||||
/// cache is computed on first geometry read and cleared on navigation. It is
|
||||
/// not invalidated on every DOM mutation yet (a follow-up), so geometry read
|
||||
/// mid-script may lag a frame; reading after settle is reliable.
|
||||
#[cfg(feature = "render")]
|
||||
pub layout_cache:
|
||||
Option<std::collections::HashMap<obscura_dom::tree::NodeId, obscura_render::Rect>>,
|
||||
#[cfg(feature = "render")]
|
||||
pub viewport: (f32, f32),
|
||||
}
|
||||
|
||||
impl ObscuraState {
|
||||
@@ -128,6 +137,10 @@ impl ObscuraState {
|
||||
network_response_body_counter: 0,
|
||||
fetched_urls: Vec::new(),
|
||||
js_network_events: Vec::new(),
|
||||
#[cfg(feature = "render")]
|
||||
layout_cache: None,
|
||||
#[cfg(feature = "render")]
|
||||
viewport: (1280.0, 720.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1946,32 +1959,70 @@ fn op_url_encode_query(#[string] query: &str, #[string] label: &str, special: bo
|
||||
}
|
||||
|
||||
pub fn build_extension() -> Extension {
|
||||
let mut ops = vec![
|
||||
op_dom(),
|
||||
op_console_msg(),
|
||||
op_fetch_url(),
|
||||
op_get_cookies(),
|
||||
op_set_cookie(),
|
||||
op_navigate(),
|
||||
op_sleep(),
|
||||
op_binding_called(),
|
||||
op_subtle_digest(),
|
||||
op_subtle_hmac(),
|
||||
op_subtle_aes_gcm(),
|
||||
op_subtle_aes_cbc(),
|
||||
op_subtle_aes_ctr(),
|
||||
op_subtle_pbkdf2(),
|
||||
op_subtle_hkdf(),
|
||||
op_random_bytes(),
|
||||
op_url_parse(),
|
||||
op_url_set(),
|
||||
op_url_resolve(),
|
||||
op_encoding_for_label(),
|
||||
op_text_decode(),
|
||||
op_url_encode_query(),
|
||||
];
|
||||
// Only registered when the render feature is compiled in. bootstrap.js
|
||||
// probes with typeof before calling, so the op's absence is a clean fallback.
|
||||
#[cfg(feature = "render")]
|
||||
ops.push(op_layout_geometry());
|
||||
Extension {
|
||||
name: "obscura_dom",
|
||||
ops: std::borrow::Cow::Owned(vec![
|
||||
op_dom(),
|
||||
op_console_msg(),
|
||||
op_fetch_url(),
|
||||
op_get_cookies(),
|
||||
op_set_cookie(),
|
||||
op_navigate(),
|
||||
op_sleep(),
|
||||
op_binding_called(),
|
||||
op_subtle_digest(),
|
||||
op_subtle_hmac(),
|
||||
op_subtle_aes_gcm(),
|
||||
op_subtle_aes_cbc(),
|
||||
op_subtle_aes_ctr(),
|
||||
op_subtle_pbkdf2(),
|
||||
op_subtle_hkdf(),
|
||||
op_random_bytes(),
|
||||
op_url_parse(),
|
||||
op_url_set(),
|
||||
op_url_resolve(),
|
||||
op_encoding_for_label(),
|
||||
op_text_decode(),
|
||||
op_url_encode_query(),
|
||||
]),
|
||||
ops: std::borrow::Cow::Owned(ops),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Real border-box geometry for an element from the obscura-render layout
|
||||
/// cache. The cache is computed lazily on first read and cleared on navigation
|
||||
/// (see `set_dom`). Returns JSON `{"x","y","width","height"}` in CSS pixels, or
|
||||
/// an empty string when the node has no box. Feature-gated.
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
{
|
||||
let mut gs = shared.borrow_mut();
|
||||
if gs.layout_cache.is_none() {
|
||||
if let Some(dom) = &gs.dom {
|
||||
let viewport = gs.viewport;
|
||||
gs.layout_cache = Some(obscura_render::layout_dom(dom, viewport).rects);
|
||||
}
|
||||
}
|
||||
}
|
||||
let gs = shared.borrow();
|
||||
let nid: u32 = nid_str.parse().unwrap_or(0);
|
||||
if let Some(rect) = gs
|
||||
.layout_cache
|
||||
.as_ref()
|
||||
.and_then(|c| c.get(&obscura_dom::tree::NodeId::new(nid)))
|
||||
{
|
||||
return format!(
|
||||
"{{\"x\":{},\"y\":{},\"width\":{},\"height\":{}}}",
|
||||
rect.x, rect.y, rect.width, rect.height
|
||||
);
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
@@ -188,7 +188,14 @@ impl ObscuraJsRuntime {
|
||||
}
|
||||
|
||||
pub fn set_dom(&self, dom: DomTree) {
|
||||
self.state.borrow_mut().dom = Some(dom);
|
||||
let mut gs = self.state.borrow_mut();
|
||||
gs.dom = Some(dom);
|
||||
// A new document invalidates the layout cache; it is recomputed lazily
|
||||
// on the next geometry read.
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
gs.layout_cache = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_url(&self, url: &str) {
|
||||
|
||||
Reference in New Issue
Block a user