diff --git a/README.md b/README.md index 45bfcc983..d7cbb76eb 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ To use kdriver, add the following to your `build.gradle.kts`: ```kotlin dependencies { - implementation("dev.kdriver:core:0.5.1") + implementation("dev.kdriver:core:0.5.2") } ``` diff --git a/build.gradle.kts b/build.gradle.kts index 4091dcd5d..461e786d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { allprojects { group = "dev.kdriver" - version = "0.5.1" + version = "0.5.2" project.ext.set("url", "https://github.com/cdpdriver/kdriver") project.ext.set("license.name", "Apache 2.0") project.ext.set("license.url", "https://www.apache.org/licenses/LICENSE-2.0.txt") diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt b/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt index 9290b7a69..faed2036e 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt @@ -231,33 +231,69 @@ open class DefaultElement( // Execute position query atomically in a single JavaScript call // This prevents race conditions where the element could be detached // between getting position and dispatching mouse events + val scrollData = try { + apply( + jsFunction = """ + function() { + if (!this || !this.isConnected) return null; + + const rect = this.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + + // Check if element is visible in viewport + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + const elementCenterY = rect.top + rect.height / 2; + const elementCenterX = rect.left + rect.width / 2; + + // Calculate if we need to scroll + const needsScrollY = elementCenterY < 0 || elementCenterY > viewportHeight; + const needsScrollX = elementCenterX < 0 || elementCenterX > viewportWidth; + + // Calculate scroll distances to center the element + const scrollY = needsScrollY ? elementCenterY - viewportHeight / 2 : 0; + const scrollX = needsScrollX ? elementCenterX - viewportWidth / 2 : 0; + + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + scrollX: scrollX, + scrollY: scrollY, + needsScroll: needsScrollY || needsScrollX + }; + } + """.trimIndent() + ) + } catch (e: EvaluateException) { + logger.warn("Could not get coordinates for $this: ${e.jsError}") + return + } + + if (scrollData == null) { + logger.warn("Could not find location for $this, not clicking") + return + } + + // Scroll element into view naturally if needed (P3 - Anti-detection) + if (scrollData.needsScroll) { + logger.debug("Scrolling by (${scrollData.scrollX}, ${scrollData.scrollY}) to bring $this into view") + tab.scrollTo(scrollData.scrollX, scrollData.scrollY) + } + + // Get updated coordinates after scrolling val coordinates = try { apply( jsFunction = """ function() { if (!this || !this.isConnected) return null; - - // Scroll element into view if not visible (P0 - CRITICAL FIX) - // This ensures mouseClick() works even when elements are off-viewport - this.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' }); - - // Wait for scroll to complete using requestAnimationFrame - return new Promise(resolve => { - requestAnimationFrame(() => { - const rect = this.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) { - resolve(null); - } else { - resolve({ - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2 - }); - } - }); - }); + const rect = this.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2 + }; } - """.trimIndent(), - awaitPromise = true + """.trimIndent() ) } catch (e: EvaluateException) { logger.warn("Could not get coordinates for $this: ${e.jsError}") @@ -360,12 +396,14 @@ open class DefaultElement( focus() // Set selection range to the beginning and get initial value length atomically - val initialLength = apply(""" + val initialLength = apply( + """ (el) => { el.setSelectionRange(0, 0); return el.value.length; } - """.trimIndent()) ?: 0 + """.trimIndent() + ) ?: 0 // Delete each character using CDP Input.dispatchKeyEvent (P3 - Anti-detection) // This generates isTrusted: true events unlike JavaScript KeyboardEvent dispatch @@ -390,12 +428,14 @@ open class DefaultElement( ) // Actually remove the character from the input value and get remaining length - remaining = apply(""" + remaining = apply( + """ (el) => { el.value = el.value.slice(1); return el.value.length; } - """.trimIndent()) ?: 0 + """.trimIndent() + ) ?: 0 // Random delay between deletions (50-100ms) for natural variation if (remaining > 0) { @@ -404,12 +444,14 @@ open class DefaultElement( } // Dispatch input event to notify the page of the change - apply(""" + apply( + """ (el) => { el.dispatchEvent(new Event('input', { bubbles: true })); return null; } - """.trimIndent()) + """.trimIndent() + ) } override suspend fun rawApply( diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt b/core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt new file mode 100644 index 000000000..860011f02 --- /dev/null +++ b/core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt @@ -0,0 +1,16 @@ +package dev.kdriver.core.dom + +import kotlinx.serialization.Serializable + +/** + * Result from atomic scroll calculation operation. + * Used by mouseClick() to determine if scrolling is needed and by how much. + */ +@Serializable +data class ScrollData( + val x: Double, + val y: Double, + val scrollX: Double, + val scrollY: Double, + val needsScroll: Boolean, +) diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt b/core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt new file mode 100644 index 000000000..7ec350adb --- /dev/null +++ b/core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt @@ -0,0 +1,15 @@ +package dev.kdriver.core.dom + +import kotlinx.serialization.Serializable + +/** + * Viewport dimensions and scroll position data. + * Used for calculating natural scroll gestures. + */ +@Serializable +data class ViewportData( + val width: Double, + val height: Double, + val scrollX: Double = 0.0, + val scrollY: Double = 0.0, +) diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt b/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt index 60719dc0c..95c7e0d96 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt @@ -1,6 +1,7 @@ package dev.kdriver.core.tab import dev.kdriver.cdp.CDPException +import dev.kdriver.cdp.Serialization import dev.kdriver.cdp.domain.* import dev.kdriver.cdp.domain.Input import dev.kdriver.core.browser.Browser @@ -22,6 +23,7 @@ import kotlinx.io.buffered import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs @@ -216,6 +218,47 @@ open class DefaultTab( delay((yDistance / speed).seconds) } + override suspend fun scrollTo(scrollX: Double, scrollY: Double, speed: Int?) { + if (scrollX == 0.0 && scrollY == 0.0) { + return + } + + // Get current viewport dimensions for scroll origin + val viewportJson = rawEvaluate( + """ + ({ + width: window.innerWidth, + height: window.innerHeight + }) + """.trimIndent() + )!! + val viewportData = Serialization.json.decodeFromJsonElement(viewportJson) + + val originX = viewportData.width / 2 + val originY = viewportData.height / 2 + + // Use provided speed or add natural variation (P3 - Anti-detection) + val scrollSpeed = speed ?: kotlin.random.Random.nextInt(600, 1200) + + // Use negative distances because CDP's synthesizeScrollGesture uses inverted Y-axis + // (positive yDistance scrolls UP, but we want positive scrollY to scroll DOWN) + input.synthesizeScrollGesture( + x = originX, + y = originY, + xDistance = -scrollX, // Negative because positive xDistance scrolls left + yDistance = -scrollY, // Negative because positive yDistance scrolls up + speed = scrollSpeed, + preventFling = true, + gestureSourceType = Input.GestureSourceType.MOUSE + ) + + // Add a small delay for the scroll animation to complete (P3 - Anti-detection) + // Calculate duration based on distance and speed + val distance = kotlin.math.sqrt(scrollX * scrollX + scrollY * scrollY) + val duration = (distance / scrollSpeed * 1000).toLong() // Convert to milliseconds + sleep(duration + kotlin.random.Random.nextLong(50, 150)) + } + override suspend fun waitForReadyState( until: ReadyState, timeout: Long, diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/tab/Tab.kt b/core/src/commonMain/kotlin/dev/kdriver/core/tab/Tab.kt index bae0a56c5..a25a715b4 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/tab/Tab.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/tab/Tab.kt @@ -198,6 +198,16 @@ interface Tab : Connection { */ suspend fun scrollUp(amount: Int = 25, speed: Int = 800) + /** + * Scrolls the page naturally using CDP's synthesizeScrollGesture (P3 - Anti-detection). + * This creates smooth, human-like scrolling instead of instant jumps. + * + * @param scrollX The horizontal distance to scroll in pixels (positive scrolls right, negative scrolls left) + * @param scrollY The vertical distance to scroll in pixels (positive scrolls down, negative scrolls up) + * @param speed Swipe speed in pixels per second. If null, uses random variation between 600-1200 for natural behavior + */ + suspend fun scrollTo(scrollX: Double, scrollY: Double, speed: Int? = null) + /** * Waits for the document's ready state to reach a specified state. * diff --git a/docs/home/quickstart.md b/docs/home/quickstart.md index a82294455..72941ac4f 100644 --- a/docs/home/quickstart.md +++ b/docs/home/quickstart.md @@ -12,7 +12,7 @@ To install, add the dependency to your `build.gradle.kts`: ```kotlin dependencies { - implementation("dev.kdriver:core:0.5.1") + implementation("dev.kdriver:core:0.5.2") } ``` diff --git a/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/OpenTelemetryTab.kt b/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/OpenTelemetryTab.kt index a212bd7e3..ca6c13104 100644 --- a/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/OpenTelemetryTab.kt +++ b/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/OpenTelemetryTab.kt @@ -154,6 +154,17 @@ class OpenTelemetryTab( ) } + override suspend fun scrollTo(scrollX: Double, scrollY: Double, speed: Int?) = + tab.scrollTo(scrollX, scrollY, speed).also { + Span.current().addEvent( + "kdriver.tab.scrollTo", Attributes.builder() + .put("scrollX", scrollX) + .put("scrollY", scrollY) + .put("speed", speed?.toLong() ?: 0L) + .build() + ) + } + override suspend fun waitForReadyState( until: ReadyState, timeout: Long,