Skip to content

feat: optional Smooth scroll behavior #153

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Feb 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,10 @@ Toggles random mouse movements on or off.
Simulates a mouse click at the specified selector or element.

- **selector (optional):** CSS selector or ElementHandle to identify the target element.
- **options (optional):** Additional options for clicking.
- **options (optional):** Additional options for clicking. **Extends the `options` of the `move` function (below)**
- `hesitate (number):` Delay before initiating the click action in milliseconds. Default is `0`.
- `waitForClick (number):` Delay between mousedown and mouseup in milliseconds. Default is `0`.
- `moveDelay (number):` Delay after moving the mouse in milliseconds. Default is `2000`. If `randomizeMoveDelay=true`, delay is randomized from 0 to `moveDelay`.
- `randomizeMoveDelay (boolean):` Randomize delay between actions from `0` to `moveDelay`. Default is `true`.

#### `move(selector: string | ElementHandle, options?: MoveOptions): Promise<void>`

Expand All @@ -134,6 +133,8 @@ Moves the mouse to the specified selector or element.
- `maxTries (number):` Maximum number of attempts to mouse-over the element. Default is `10`.
- `moveSpeed (number):` Speed of mouse movement. Default is random.
- `overshootThreshold (number):` Distance from current location to destination that triggers overshoot to occur. (Below this distance, no overshoot will occur). Default is `500`.
- `scrollBehavior (ScrollBehavior):` Scroll behavior when target element is outside the visible window ([docs/available values](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView#behavior)). **If this is specified, will use JS scrolling instead of CDP scrolling, which is detectable**. Default is `undefined` (CDP `scrollIntoView`).
- `scrollWait (number):` Time to wait after scrolling (when scrolling occurs due to target element being outside the visible window). Default is `200`.

#### `moveTo(destination: Vector, options?: MoveToOptions): Promise<void>`

Expand Down
3 changes: 2 additions & 1 deletion src/__test__/spoof.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const cursorDefaultOptions = {
moveDelay: 0,
moveSpeed: 99,
hesitate: 0,
waitForClick: 0
waitForClick: 0,
scrollWait: 0
} as const satisfies ClickOptions

describe('Mouse movements', () => {
Expand Down
49 changes: 39 additions & 10 deletions src/spoof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ export interface MoveOptions extends BoxOptions, Pick<PathOptions, 'moveSpeed'>
* @default 500
*/
readonly overshootThreshold?: number
/**
* Scroll behavior when target element is outside the visible window.
*
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView#behavior}
*
* NOTE: If this is specified, will use JS scrolling instead of CDP scrolling, which is detectable!
*
* @default undefined (use CDP scrolling)
*/
readonly scrollBehavior?: ScrollBehavior
/**
* Time to wait after scrolling (when scrolling occurs due to target element being outside the visible window)
* @default 200
*/
readonly scrollWait?: number
}

export interface ClickOptions extends MoveOptions {
Expand Down Expand Up @@ -472,6 +487,7 @@ export const createCursor = (
maxTries: 10,
overshootThreshold: 500,
randomizeMoveDelay: true,
scrollWait: 200,
...defaultOptions?.move,
...options
} satisfies MoveOptions
Expand Down Expand Up @@ -514,19 +530,32 @@ export const createCursor = (
}

// Make sure the object is in view
const objectId = elem.remoteObject().objectId
if (objectId !== undefined) {
try {
await getCDPClient(page).send('DOM.scrollIntoViewIfNeeded', {
objectId
})
} catch (e) {
if (!(await elem.isIntersectingViewport())) {
const scrollElemIntoView = async (): Promise<void> =>
await elem.evaluate((e, scrollBehavior) => e.scrollIntoView({
block: 'center',
behavior: scrollBehavior
}), optionsResolved.scrollBehavior)

if (optionsResolved.scrollBehavior !== undefined) {
// DOM.scrollIntoViewIfNeeded is instant scroll, so do the JS scroll if scrollBehavior passed
await scrollElemIntoView()
} else {
try {
const { objectId } = elem.remoteObject()
if (objectId === undefined) throw new Error()
await getCDPClient(page).send('DOM.scrollIntoViewIfNeeded', {
objectId
})
} catch (e) {
// use regular JS scroll method as a fallback
log('Falling back to JS scroll method', e)
await elem.evaluate((e) => e.scrollIntoView({ block: 'center' }))
await new Promise((resolve) => setTimeout(resolve, 2000)) // Wait a bit until the scroll has finished
log('Falling back to JS scroll method', e)
await scrollElemIntoView()
}
}
await delay(optionsResolved.scrollWait)
}

const box = await boundingBoxWithFallback(page, elem)
const { height, width } = box
const destination = getRandomBoxPoint(box, optionsResolved)
Expand Down