Skip to content

Commit f5cb00c

Browse files
authored
fix: human scroll when mouse click (#56)
1 parent b3fbb55 commit f5cb00c

File tree

9 files changed

+167
-30
lines changed

9 files changed

+167
-30
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ To use kdriver, add the following to your `build.gradle.kts`:
4747

4848
```kotlin
4949
dependencies {
50-
implementation("dev.kdriver:core:0.5.1")
50+
implementation("dev.kdriver:core:0.5.2")
5151
}
5252
```
5353

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ plugins {
66

77
allprojects {
88
group = "dev.kdriver"
9-
version = "0.5.1"
9+
version = "0.5.2"
1010
project.ext.set("url", "https://github.com/cdpdriver/kdriver")
1111
project.ext.set("license.name", "Apache 2.0")
1212
project.ext.set("license.url", "https://www.apache.org/licenses/LICENSE-2.0.txt")

core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -231,33 +231,69 @@ open class DefaultElement(
231231
// Execute position query atomically in a single JavaScript call
232232
// This prevents race conditions where the element could be detached
233233
// between getting position and dispatching mouse events
234+
val scrollData = try {
235+
apply<ScrollData?>(
236+
jsFunction = """
237+
function() {
238+
if (!this || !this.isConnected) return null;
239+
240+
const rect = this.getBoundingClientRect();
241+
if (rect.width === 0 || rect.height === 0) return null;
242+
243+
// Check if element is visible in viewport
244+
const viewportHeight = window.innerHeight;
245+
const viewportWidth = window.innerWidth;
246+
const elementCenterY = rect.top + rect.height / 2;
247+
const elementCenterX = rect.left + rect.width / 2;
248+
249+
// Calculate if we need to scroll
250+
const needsScrollY = elementCenterY < 0 || elementCenterY > viewportHeight;
251+
const needsScrollX = elementCenterX < 0 || elementCenterX > viewportWidth;
252+
253+
// Calculate scroll distances to center the element
254+
const scrollY = needsScrollY ? elementCenterY - viewportHeight / 2 : 0;
255+
const scrollX = needsScrollX ? elementCenterX - viewportWidth / 2 : 0;
256+
257+
return {
258+
x: rect.left + rect.width / 2,
259+
y: rect.top + rect.height / 2,
260+
scrollX: scrollX,
261+
scrollY: scrollY,
262+
needsScroll: needsScrollY || needsScrollX
263+
};
264+
}
265+
""".trimIndent()
266+
)
267+
} catch (e: EvaluateException) {
268+
logger.warn("Could not get coordinates for $this: ${e.jsError}")
269+
return
270+
}
271+
272+
if (scrollData == null) {
273+
logger.warn("Could not find location for $this, not clicking")
274+
return
275+
}
276+
277+
// Scroll element into view naturally if needed (P3 - Anti-detection)
278+
if (scrollData.needsScroll) {
279+
logger.debug("Scrolling by (${scrollData.scrollX}, ${scrollData.scrollY}) to bring $this into view")
280+
tab.scrollTo(scrollData.scrollX, scrollData.scrollY)
281+
}
282+
283+
// Get updated coordinates after scrolling
234284
val coordinates = try {
235285
apply<CoordinateResult?>(
236286
jsFunction = """
237287
function() {
238288
if (!this || !this.isConnected) return null;
239-
240-
// Scroll element into view if not visible (P0 - CRITICAL FIX)
241-
// This ensures mouseClick() works even when elements are off-viewport
242-
this.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
243-
244-
// Wait for scroll to complete using requestAnimationFrame
245-
return new Promise(resolve => {
246-
requestAnimationFrame(() => {
247-
const rect = this.getBoundingClientRect();
248-
if (rect.width === 0 || rect.height === 0) {
249-
resolve(null);
250-
} else {
251-
resolve({
252-
x: rect.left + rect.width / 2,
253-
y: rect.top + rect.height / 2
254-
});
255-
}
256-
});
257-
});
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+
};
258295
}
259-
""".trimIndent(),
260-
awaitPromise = true
296+
""".trimIndent()
261297
)
262298
} catch (e: EvaluateException) {
263299
logger.warn("Could not get coordinates for $this: ${e.jsError}")
@@ -360,12 +396,14 @@ open class DefaultElement(
360396
focus()
361397

362398
// Set selection range to the beginning and get initial value length atomically
363-
val initialLength = apply<Int>("""
399+
val initialLength = apply<Int>(
400+
"""
364401
(el) => {
365402
el.setSelectionRange(0, 0);
366403
return el.value.length;
367404
}
368-
""".trimIndent()) ?: 0
405+
""".trimIndent()
406+
) ?: 0
369407

370408
// Delete each character using CDP Input.dispatchKeyEvent (P3 - Anti-detection)
371409
// This generates isTrusted: true events unlike JavaScript KeyboardEvent dispatch
@@ -390,12 +428,14 @@ open class DefaultElement(
390428
)
391429

392430
// Actually remove the character from the input value and get remaining length
393-
remaining = apply<Int>("""
431+
remaining = apply<Int>(
432+
"""
394433
(el) => {
395434
el.value = el.value.slice(1);
396435
return el.value.length;
397436
}
398-
""".trimIndent()) ?: 0
437+
""".trimIndent()
438+
) ?: 0
399439

400440
// Random delay between deletions (50-100ms) for natural variation
401441
if (remaining > 0) {
@@ -404,12 +444,14 @@ open class DefaultElement(
404444
}
405445

406446
// Dispatch input event to notify the page of the change
407-
apply<String?>("""
447+
apply<String?>(
448+
"""
408449
(el) => {
409450
el.dispatchEvent(new Event('input', { bubbles: true }));
410451
return null;
411452
}
412-
""".trimIndent())
453+
""".trimIndent()
454+
)
413455
}
414456

415457
override suspend fun rawApply(
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package dev.kdriver.core.dom
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* Result from atomic scroll calculation operation.
7+
* Used by mouseClick() to determine if scrolling is needed and by how much.
8+
*/
9+
@Serializable
10+
data class ScrollData(
11+
val x: Double,
12+
val y: Double,
13+
val scrollX: Double,
14+
val scrollY: Double,
15+
val needsScroll: Boolean,
16+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package dev.kdriver.core.dom
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* Viewport dimensions and scroll position data.
7+
* Used for calculating natural scroll gestures.
8+
*/
9+
@Serializable
10+
data class ViewportData(
11+
val width: Double,
12+
val height: Double,
13+
val scrollX: Double = 0.0,
14+
val scrollY: Double = 0.0,
15+
)

core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dev.kdriver.core.tab
22

33
import dev.kdriver.cdp.CDPException
4+
import dev.kdriver.cdp.Serialization
45
import dev.kdriver.cdp.domain.*
56
import dev.kdriver.cdp.domain.Input
67
import dev.kdriver.core.browser.Browser
@@ -22,6 +23,7 @@ import kotlinx.io.buffered
2223
import kotlinx.io.files.Path
2324
import kotlinx.io.files.SystemFileSystem
2425
import kotlinx.serialization.json.JsonElement
26+
import kotlinx.serialization.json.decodeFromJsonElement
2527
import kotlin.io.encoding.Base64
2628
import kotlin.io.encoding.ExperimentalEncodingApi
2729
import kotlin.math.abs
@@ -216,6 +218,47 @@ open class DefaultTab(
216218
delay((yDistance / speed).seconds)
217219
}
218220

221+
override suspend fun scrollTo(scrollX: Double, scrollY: Double, speed: Int?) {
222+
if (scrollX == 0.0 && scrollY == 0.0) {
223+
return
224+
}
225+
226+
// Get current viewport dimensions for scroll origin
227+
val viewportJson = rawEvaluate(
228+
"""
229+
({
230+
width: window.innerWidth,
231+
height: window.innerHeight
232+
})
233+
""".trimIndent()
234+
)!!
235+
val viewportData = Serialization.json.decodeFromJsonElement<dev.kdriver.core.dom.ViewportData>(viewportJson)
236+
237+
val originX = viewportData.width / 2
238+
val originY = viewportData.height / 2
239+
240+
// Use provided speed or add natural variation (P3 - Anti-detection)
241+
val scrollSpeed = speed ?: kotlin.random.Random.nextInt(600, 1200)
242+
243+
// Use negative distances because CDP's synthesizeScrollGesture uses inverted Y-axis
244+
// (positive yDistance scrolls UP, but we want positive scrollY to scroll DOWN)
245+
input.synthesizeScrollGesture(
246+
x = originX,
247+
y = originY,
248+
xDistance = -scrollX, // Negative because positive xDistance scrolls left
249+
yDistance = -scrollY, // Negative because positive yDistance scrolls up
250+
speed = scrollSpeed,
251+
preventFling = true,
252+
gestureSourceType = Input.GestureSourceType.MOUSE
253+
)
254+
255+
// Add a small delay for the scroll animation to complete (P3 - Anti-detection)
256+
// Calculate duration based on distance and speed
257+
val distance = kotlin.math.sqrt(scrollX * scrollX + scrollY * scrollY)
258+
val duration = (distance / scrollSpeed * 1000).toLong() // Convert to milliseconds
259+
sleep(duration + kotlin.random.Random.nextLong(50, 150))
260+
}
261+
219262
override suspend fun waitForReadyState(
220263
until: ReadyState,
221264
timeout: Long,

core/src/commonMain/kotlin/dev/kdriver/core/tab/Tab.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,16 @@ interface Tab : Connection {
198198
*/
199199
suspend fun scrollUp(amount: Int = 25, speed: Int = 800)
200200

201+
/**
202+
* Scrolls the page naturally using CDP's synthesizeScrollGesture (P3 - Anti-detection).
203+
* This creates smooth, human-like scrolling instead of instant jumps.
204+
*
205+
* @param scrollX The horizontal distance to scroll in pixels (positive scrolls right, negative scrolls left)
206+
* @param scrollY The vertical distance to scroll in pixels (positive scrolls down, negative scrolls up)
207+
* @param speed Swipe speed in pixels per second. If null, uses random variation between 600-1200 for natural behavior
208+
*/
209+
suspend fun scrollTo(scrollX: Double, scrollY: Double, speed: Int? = null)
210+
201211
/**
202212
* Waits for the document's ready state to reach a specified state.
203213
*

docs/home/quickstart.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ To install, add the dependency to your `build.gradle.kts`:
1212

1313
```kotlin
1414
dependencies {
15-
implementation("dev.kdriver:core:0.5.1")
15+
implementation("dev.kdriver:core:0.5.2")
1616
}
1717
```
1818

opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/OpenTelemetryTab.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,17 @@ class OpenTelemetryTab(
154154
)
155155
}
156156

157+
override suspend fun scrollTo(scrollX: Double, scrollY: Double, speed: Int?) =
158+
tab.scrollTo(scrollX, scrollY, speed).also {
159+
Span.current().addEvent(
160+
"kdriver.tab.scrollTo", Attributes.builder()
161+
.put("scrollX", scrollX)
162+
.put("scrollY", scrollY)
163+
.put("speed", speed?.toLong() ?: 0L)
164+
.build()
165+
)
166+
}
167+
157168
override suspend fun waitForReadyState(
158169
until: ReadyState,
159170
timeout: Long,

0 commit comments

Comments
 (0)