11package dev.kdriver.core.dom
22
3+ import dev.kdriver.cdp.Serialization
34import dev.kdriver.cdp.domain.*
45import dev.kdriver.core.exceptions.EvaluateException
56import dev.kdriver.core.tab.Tab
67import io.ktor.util.logging.*
78import kotlinx.io.files.Path
89import kotlinx.serialization.json.JsonElement
10+ import kotlinx.serialization.json.decodeFromJsonElement
11+ import kotlin.random.Random
912
1013/* *
1114 * Default implementation of the [Element] interface.
@@ -20,12 +23,6 @@ open class DefaultElement(
2023
2124 private var remoteObject: Runtime .RemoteObject ? = null
2225
23- // Track last mouse position for natural trajectories (P2 - Anti-detection)
24- companion object {
25- private var lastMouseX: Double? = null
26- private var lastMouseY: Double? = null
27- }
28-
2926 override val tag: String
3027 get() = node.nodeName.lowercase()
3128
@@ -136,6 +133,76 @@ open class DefaultElement(
136133 )
137134 }
138135
136+ /* *
137+ * Gets stable element coordinates by waiting for position to stabilize across multiple frames.
138+ * This prevents race conditions on slow systems where scroll or layout changes may still be in progress.
139+ *
140+ * @return Stable coordinates, or null if element is not visible/connected
141+ */
142+ private suspend fun getStableCoordinates (): CoordinateResult ? {
143+ return try {
144+ apply<CoordinateResult ?>(
145+ jsFunction = """
146+ function() {
147+ if (!this || !this.isConnected) return null;
148+
149+ return new Promise(resolve => {
150+ let lastTop = null;
151+ let lastLeft = null;
152+ let stableFrames = 0;
153+ const maxAttempts = 10;
154+ let attempts = 0;
155+
156+ const checkStable = () => {
157+ attempts++;
158+ const rect = this.getBoundingClientRect();
159+
160+ if (rect.width === 0 || rect.height === 0) {
161+ resolve(null);
162+ return;
163+ }
164+
165+ if (lastTop !== null &&
166+ Math.abs(rect.top - lastTop) < 1 &&
167+ Math.abs(rect.left - lastLeft) < 1) {
168+ stableFrames++;
169+ if (stableFrames >= 2) {
170+ resolve({
171+ x: rect.left + rect.width / 2,
172+ y: rect.top + rect.height / 2
173+ });
174+ return;
175+ }
176+ } else {
177+ stableFrames = 0;
178+ }
179+
180+ lastTop = rect.top;
181+ lastLeft = rect.left;
182+
183+ if (attempts < maxAttempts) {
184+ requestAnimationFrame(checkStable);
185+ } else {
186+ // Timeout: use current position
187+ resolve({
188+ x: rect.left + rect.width / 2,
189+ y: rect.top + rect.height / 2
190+ });
191+ }
192+ };
193+
194+ requestAnimationFrame(checkStable);
195+ });
196+ }
197+ """ .trimIndent(),
198+ awaitPromise = true
199+ )
200+ } catch (e: EvaluateException ) {
201+ logger.warn(" Could not get stable coordinates for $this : ${e.jsError} " )
202+ null
203+ }
204+ }
205+
139206 /* *
140207 * Moves the mouse to the target coordinates using a natural Bezier curve trajectory (P2 - Anti-detection).
141208 * This creates smooth, human-like mouse movements instead of instant teleportation.
@@ -144,20 +211,54 @@ open class DefaultElement(
144211 * @param targetY Target Y coordinate
145212 */
146213 private suspend fun mouseMoveWithTrajectory (targetX : Double , targetY : Double ) {
147- val startX = lastMouseX ? : kotlin.random.Random .nextDouble(100.0 , 400.0 )
148- val startY = lastMouseY ? : kotlin.random.Random .nextDouble(100.0 , 300.0 )
214+ val startX: Double
215+ val startY: Double
216+
217+ if (tab.lastMouseX != null && tab.lastMouseY != null ) {
218+ startX = tab.lastMouseX!!
219+ startY = tab.lastMouseY!!
220+ } else {
221+ // Get actual viewport dimensions to avoid placing mouse outside visible area
222+ val viewportData = try {
223+ val viewportJson = tab.rawEvaluate(
224+ """
225+ ({
226+ width: window.innerWidth,
227+ height: window.innerHeight
228+ })
229+ """ .trimIndent()
230+ )
231+ if (viewportJson != null ) {
232+ Serialization .json.decodeFromJsonElement< dev.kdriver.core.dom.ViewportData > (viewportJson)
233+ } else null
234+ } catch (e: Exception ) {
235+ null
236+ }
237+
238+ if (viewportData != null ) {
239+ // Use random position within viewport bounds, with margins
240+ val maxX = (viewportData.width - 50 ).coerceAtLeast(100.0 )
241+ val maxY = (viewportData.height - 50 ).coerceAtLeast(100.0 )
242+ startX = Random .nextDouble(50.0 , maxX)
243+ startY = Random .nextDouble(50.0 , maxY)
244+ } else {
245+ // Fallback: use target coordinates with offset if viewport query fails
246+ startX = (targetX - Random .nextDouble(50.0 , 150.0 )).coerceAtLeast(0.0 )
247+ startY = (targetY - Random .nextDouble(50.0 , 150.0 )).coerceAtLeast(0.0 )
248+ }
249+ }
149250
150251 // Don't create trajectory if we're already at the target
151252 if (startX == targetX && startY == targetY) {
152253 return
153254 }
154255
155256 // Random number of steps for natural variation (8-15 steps)
156- val steps = kotlin.random. Random .nextInt(8 , 15 )
257+ val steps = Random .nextInt(8 , 15 )
157258
158259 // Control point for quadratic Bezier curve with random offset
159- val ctrlX = (startX + targetX) / 2 + kotlin.random. Random .nextDouble(- 30.0 , 30.0 )
160- val ctrlY = (startY + targetY) / 2 + kotlin.random. Random .nextDouble(- 20.0 , 20.0 )
260+ val ctrlX = (startX + targetX) / 2 + Random .nextDouble(- 30.0 , 30.0 )
261+ val ctrlY = (startY + targetY) / 2 + Random .nextDouble(- 20.0 , 20.0 )
161262
162263 logger.debug(" Mouse trajectory from ($startX , $startY ) to ($targetX , $targetY ) via control point ($ctrlX , $ctrlY ) in $steps steps" )
163264
@@ -171,14 +272,12 @@ open class DefaultElement(
171272 tab.input.dispatchMouseEvent(type = " mouseMoved" , x = x, y = y)
172273
173274 // Random delay between steps for natural variation
174- if (i < steps) {
175- tab.sleep(kotlin.random.Random .nextLong(8 , 25 ))
176- }
275+ if (i < steps) tab.sleep(Random .nextLong(8 , 25 ))
177276 }
178277
179278 // Update last position
180- lastMouseX = targetX
181- lastMouseY = targetY
279+ tab. lastMouseX = targetX
280+ tab. lastMouseY = targetY
182281 }
183282
184283 override suspend fun mouseMove () {
@@ -212,8 +311,8 @@ open class DefaultElement(
212311 val (centerX, centerY) = coordinates
213312
214313 // Add jitter to mouse coordinates (P1 - Anti-detection)
215- val jitterX = (kotlin.random. Random .nextDouble() * 10 - 5 ) // -5 to +5 pixels
216- val jitterY = (kotlin.random. Random .nextDouble() * 6 - 3 ) // -3 to +3 pixels
314+ val jitterX = (Random .nextDouble() * 10 - 5 ) // -5 to +5 pixels
315+ val jitterY = (Random .nextDouble() * 6 - 3 ) // -3 to +3 pixels
217316 val x = centerX + jitterX
218317 val y = centerY + jitterY
219318
@@ -280,25 +379,9 @@ open class DefaultElement(
280379 tab.scrollTo(scrollData.scrollX, scrollData.scrollY)
281380 }
282381
283- // Get updated coordinates after scrolling
284- val coordinates = try {
285- apply<CoordinateResult ?>(
286- jsFunction = """
287- function() {
288- if (!this || !this.isConnected) return null;
289- const rect = this.getBoundingClientRect();
290- if (rect.width === 0 || rect.height === 0) return null;
291- return {
292- x: rect.left + rect.width / 2,
293- y: rect.top + rect.height / 2
294- };
295- }
296- """ .trimIndent()
297- )
298- } catch (e: EvaluateException ) {
299- logger.warn(" Could not get coordinates for $this : ${e.jsError} " )
300- return
301- }
382+ // Get updated coordinates after scrolling, waiting for position stability
383+ // This is critical on slow systems where scroll may not complete immediately
384+ val coordinates = getStableCoordinates()
302385
303386 if (coordinates == null ) {
304387 logger.warn(" Could not find location for $this , not clicking" )
@@ -309,8 +392,8 @@ open class DefaultElement(
309392
310393 // Add jitter to click coordinates (P1 - Anti-detection)
311394 // Humans don't click exactly at the mathematical center
312- val jitterX = (kotlin.random. Random .nextDouble() * 10 - 5 ) // -5 to +5 pixels
313- val jitterY = (kotlin.random. Random .nextDouble() * 6 - 3 ) // -3 to +3 pixels
395+ val jitterX = (Random .nextDouble() * 10 - 5 ) // -5 to +5 pixels
396+ val jitterY = (Random .nextDouble() * 6 - 3 ) // -3 to +3 pixels
314397 val x = centerX + jitterX
315398 val y = centerY + jitterY
316399
@@ -321,32 +404,92 @@ open class DefaultElement(
321404 mouseMoveWithTrajectory(x, y)
322405
323406 // Randomized delay to make it more realistic (P1 - Anti-detection)
324- tab.sleep(kotlin.random.Random .nextLong(5 , 20 ))
325-
326- // 2. Press mouse button
327- tab.input.dispatchMouseEvent(
328- type = " mousePressed" ,
329- x = x,
330- y = y,
331- button = button,
332- buttons = button.buttonsMask,
333- clickCount = clickCount,
334- modifiers = modifiers
335- )
407+ tab.sleep(Random .nextLong(5 , 20 ))
336408
337- // Randomized delay between press and release (P1 - Anti-detection)
338- tab.sleep(kotlin.random.Random .nextLong(40 , 120 ))
339-
340- // 3. Release mouse button
341- tab.input.dispatchMouseEvent(
342- type = " mouseReleased" ,
343- x = x,
344- y = y,
345- button = button,
346- buttons = button.buttonsMask,
347- clickCount = clickCount,
348- modifiers = modifiers
349- )
409+ // 2. Verify element hasn't moved during trajectory (handles React re-renders, animations, etc.)
410+ val finalCoordinates = try {
411+ apply<CoordinateResult ?>(
412+ jsFunction = """
413+ function() {
414+ if (!this || !this.isConnected) return null;
415+ const rect = this.getBoundingClientRect();
416+ if (rect.width === 0 || rect.height === 0) return null;
417+ return {
418+ x: rect.left + rect.width / 2,
419+ y: rect.top + rect.height / 2
420+ };
421+ }
422+ """ .trimIndent()
423+ )
424+ } catch (e: EvaluateException ) {
425+ null
426+ }
427+
428+ // Adjust click position if element moved significantly (>5px threshold)
429+ val finalX: Double
430+ val finalY: Double
431+ if (finalCoordinates != null ) {
432+ val (finalCenterX, finalCenterY) = finalCoordinates
433+ val deltaX = finalCenterX - centerX
434+ val deltaY = finalCenterY - centerY
435+ val moved = kotlin.math.sqrt(deltaX * deltaX + deltaY * deltaY) > 5.0
436+
437+ if (moved) {
438+ logger.debug(" Element moved during trajectory by ($deltaX , $deltaY ), adjusting click position" )
439+ finalX = finalCenterX + jitterX
440+ finalY = finalCenterY + jitterY
441+ // Move mouse to adjusted position
442+ tab.input.dispatchMouseEvent(type = " mouseMoved" , x = finalX, y = finalY)
443+ tab.lastMouseX = finalX
444+ tab.lastMouseY = finalY
445+ } else {
446+ finalX = x
447+ finalY = y
448+ }
449+ } else {
450+ // Element disappeared or error occurred, use original position
451+ finalX = x
452+ finalY = y
453+ }
454+
455+ // 3. Press and release mouse button with guaranteed cleanup
456+ // Use try-finally to ensure button state is always cleaned up even on error
457+ try {
458+ tab.input.dispatchMouseEvent(
459+ type = " mousePressed" ,
460+ x = finalX,
461+ y = finalY,
462+ button = button,
463+ buttons = button.buttonsMask,
464+ clickCount = clickCount,
465+ modifiers = modifiers
466+ )
467+
468+ // Randomized delay between press and release (P1 - Anti-detection)
469+ tab.sleep(Random .nextLong(40 , 120 ))
470+
471+ // 4. Release mouse button
472+ tab.input.dispatchMouseEvent(
473+ type = " mouseReleased" ,
474+ x = finalX,
475+ y = finalY,
476+ button = button,
477+ buttons = button.buttonsMask,
478+ clickCount = clickCount,
479+ modifiers = modifiers
480+ )
481+ } catch (e: Exception ) {
482+ // Ensure button is released even on error to prevent stuck button state
483+ runCatching {
484+ tab.input.dispatchMouseEvent(
485+ type = " mouseReleased" ,
486+ x = finalX,
487+ y = finalY,
488+ button = button
489+ )
490+ }
491+ throw e
492+ }
350493 }
351494
352495 override suspend fun focus () {
@@ -392,7 +535,6 @@ open class DefaultElement(
392535 }
393536
394537 override suspend fun clearInputByDeleting () {
395- // Focus the element first
396538 focus()
397539
398540 // Set selection range to the beginning and get initial value length atomically
@@ -402,7 +544,7 @@ open class DefaultElement(
402544 el.setSelectionRange(0, 0);
403545 return el.value.length;
404546 }
405- """ .trimIndent()
547+ """ .trimIndent()
406548 ) ? : 0
407549
408550 // Delete each character using CDP Input.dispatchKeyEvent (P3 - Anti-detection)
@@ -432,26 +574,15 @@ open class DefaultElement(
432574 """
433575 (el) => {
434576 el.value = el.value.slice(1);
577+ el.dispatchEvent(new Event('input', { bubbles: true }));
435578 return el.value.length;
436579 }
437- """ .trimIndent()
580+ """ .trimIndent()
438581 ) ? : 0
439582
440583 // Random delay between deletions (50-100ms) for natural variation
441- if (remaining > 0 ) {
442- tab.sleep(kotlin.random.Random .nextLong(50 , 100 ))
443- }
584+ if (remaining > 0 ) tab.sleep(Random .nextLong(50 , 100 ))
444585 }
445-
446- // Dispatch input event to notify the page of the change
447- apply<String ?>(
448- """
449- (el) => {
450- el.dispatchEvent(new Event('input', { bubbles: true }));
451- return null;
452- }
453- """ .trimIndent()
454- )
455586 }
456587
457588 override suspend fun rawApply (
0 commit comments