fix(humanize): skip futile scroll for fully-visible boundary elements

A fully-visible element above/below the target zone on a page pinned at
that boundary can never reach the zone, so the scroll loop ran the full
budget (~7s/click) doing nothing. Bail when it's fully visible and the
page can't scroll toward the zone. Ported to JS, Python (sync+async),
and .NET with regression tests.
This commit is contained in:
CloakHQ committed 2026-08-14 08:42:30 +02:00
1 parent b27ccd9eae
commit 5494ac14e9
7 files changed
+231 -1

No files matched your search

+30
View File
@@ -54,6 +54,26 @@ def _get_element_box(page: Any, selector: str, timeout: float = 30000) -> Option
return None
_SCROLL_JS = (
"(() => { const e = document.scrollingElement || document.documentElement;"
" return { y: window.scrollY, maxY: Math.max(0, e.scrollHeight - e.clientHeight) }; })()"
)
def _read_scroll_state(page: Any) -> dict:
"""Current vertical scroll offset and the maximum scrollable offset."""
world = getattr(page, "_stealth_world", None)
if world is not None:
try:
return world.evaluate(_SCROLL_JS)
except Exception:
pass
try:
return page.evaluate(_SCROLL_JS)
except Exception:
return {"y": 0, "maxY": 0}
def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None:
"""Send one logical scroll as a burst of small wheel events (like real inertia)."""
abs_d = abs(delta)
@@ -113,6 +133,16 @@ def human_scroll_into_view(
if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y, False
# Already fully visible but off-center, with the page pinned at the boundary
# in the needed direction: scrolling can't help, so don't waste the budget.
fully_visible = box["y"] >= 0 and box["y"] + box["height"] <= viewport_height
if fully_visible:
zone_mid = viewport_height * (cfg.scroll_target_zone[0] + cfg.scroll_target_zone[1]) / 2
need_up = box["y"] + box["height"] / 2 < zone_mid
scroll = _read_scroll_state(page)
if (scroll["y"] <= 0) if need_up else (scroll["y"] >= scroll["maxY"]):
return box, cursor_x, cursor_y, False
# Move cursor into scroll area
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
scroll_area_y = round(viewport_height * rand(0.3, 0.7))
+25 -1
View File
@@ -14,7 +14,7 @@ from typing import Any, Awaitable, Callable, Optional, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
from .mouse_async import AsyncRawMouse, async_human_move
from .scroll import _is_in_viewport
from .scroll import _is_in_viewport, _SCROLL_JS
from .stealth_dom import build_box_js, async_eval_parsed, OK, NOT_FOUND, UNSUPPORTED, _VIEWPORT_JS
@@ -52,6 +52,20 @@ async def _get_element_box_async(
return None
async def _async_read_scroll_state(page: Any) -> dict:
"""Current vertical scroll offset and the maximum scrollable offset."""
world = getattr(page, "_stealth_world", None)
if world is not None:
try:
return await world.evaluate(_SCROLL_JS)
except Exception:
pass
try:
return await page.evaluate(_SCROLL_JS)
except Exception:
return {"y": 0, "maxY": 0}
async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig) -> None:
"""Send one logical scroll as a burst of small wheel events (like real inertia)."""
abs_d = abs(delta)
@@ -110,6 +124,16 @@ async def async_human_scroll_into_view(
if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y, False
# Already fully visible but off-center, with the page pinned at the boundary
# in the needed direction: scrolling can't help, so don't waste the budget.
fully_visible = box["y"] >= 0 and box["y"] + box["height"] <= viewport_height
if fully_visible:
zone_mid = viewport_height * (cfg.scroll_target_zone[0] + cfg.scroll_target_zone[1]) / 2
need_up = box["y"] + box["height"] / 2 < zone_mid
scroll = await _async_read_scroll_state(page)
if (scroll["y"] <= 0) if need_up else (scroll["y"] >= scroll["maxY"]):
return box, cursor_x, cursor_y, False
# Move cursor into scroll area
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
scroll_area_y = round(viewport_height * rand(0.3, 0.7))
@@ -17,6 +17,14 @@ public interface IRawScrollPage
/// Returns null if the dimensions can't be read.
/// </summary>
Task<(int Width, int Height)?> GetLiveWindowSizeAsync();
/// <summary>
/// Current vertical scroll offset (<c>window.scrollY</c>) and the maximum
/// scrollable offset (<c>scrollHeight - clientHeight</c>). Used to detect when
/// the page is pinned at a boundary and further scrolling can't help. Returns
/// null if the values can't be read.
/// </summary>
Task<(double Y, double MaxY)?> GetScrollStateAsync();
}
/// <summary>Result of a humanized scroll-into-view operation.</summary>
@@ -108,6 +116,18 @@ public static class HumanScroll
if (IsInViewport(box.Value, viewportHeight, cfg))
return new ScrollResult(box.Value, cursorX, cursorY, false);
// Already fully visible but off-center, with the page pinned at the boundary
// in the needed direction: scrolling can't help, so don't waste the budget.
bool fullyVisible = box.Value.Y >= 0 && box.Value.Y + box.Value.Height <= viewportHeight;
if (fullyVisible)
{
double zoneMid = viewportHeight * (cfg.ScrollTargetZone.Min + cfg.ScrollTargetZone.Max) / 2;
bool needUp = box.Value.Y + box.Value.Height / 2 < zoneMid;
var scroll = await page.GetScrollStateAsync().ConfigureAwait(false);
if (scroll != null && (needUp ? scroll.Value.Y <= 0 : scroll.Value.Y >= scroll.Value.MaxY))
return new ScrollResult(box.Value, cursorX, cursorY, false);
}
// Move cursor into scroll area.
double scrollAreaX = Math.Round(viewportWidth * HumanRandom.Rand(0.3, 0.7));
double scrollAreaY = Math.Round(viewportHeight * HumanRandom.Rand(0.3, 0.7));
@@ -103,9 +103,31 @@ internal sealed class PlaywrightScrollPage : IRawScrollPage
}
}
public async Task<(double Y, double MaxY)?> GetScrollStateAsync()
{
try
{
var s = await _page.EvaluateAsync<ScrollDims>(
"() => { const e = document.scrollingElement || document.documentElement;" +
" return { y: window.scrollY, maxY: Math.max(0, e.scrollHeight - e.clientHeight) }; }")
.ConfigureAwait(false);
return (s.Y, s.MaxY);
}
catch (System.Exception)
{
return null;
}
}
private struct WindowDims
{
public int Width { get; set; }
public int Height { get; set; }
}
private struct ScrollDims
{
public double Y { get; set; }
public double MaxY { get; set; }
}
}
@@ -36,6 +36,36 @@ public class ScrollFallbackTests
LiveCalls++;
return Task.FromResult(_live);
}
public Task<(double Y, double MaxY)?> GetScrollStateAsync() =>
Task.FromResult<(double, double)?>((0, 0));
}
/// <summary>Scroll page with a fixed viewport and configurable scroll position.</summary>
private sealed class ViewportPage : IRawScrollPage
{
private readonly (int, int) _size;
private readonly (double, double)? _scroll;
public ViewportPage((int, int) size, (double, double)? scroll)
{
_size = size;
_scroll = scroll;
}
public (int Width, int Height)? ViewportSize => _size;
public Task<(int Width, int Height)?> GetLiveWindowSizeAsync() =>
Task.FromResult<(int, int)?>(_size);
public Task<(double Y, double MaxY)?> GetScrollStateAsync() =>
Task.FromResult(_scroll);
}
private sealed class CountingMouse : IRawMouse
{
public int WheelCalls { get; private set; }
public Task MoveAsync(double x, double y) => Task.CompletedTask;
public Task DownAsync() => Task.CompletedTask;
public Task UpAsync() => Task.CompletedTask;
public Task WheelAsync(double dx, double dy) { WheelCalls++; return Task.CompletedTask; }
}
// Zero out the timing ranges so the scroll loop runs instantly in tests.
@@ -91,4 +121,32 @@ public class ScrollFallbackTests
await Assert.ThrowsAsync<InvalidOperationException>(() =>
HumanScroll.HumanScrollIntoViewAsync(page, raw, getBox, 0, 0, FastConfig()));
}
[Fact]
public async Task Fully_visible_above_zone_at_top_bails_without_scrolling()
{
// viewport 720 -> zone [144, 576]; element top=50 is above the zone but
// fully visible, and the page is pinned at the top (y=0). Must not scroll.
var page = new ViewportPage((1280, 720), scroll: (0, 2000));
var mouse = new CountingMouse();
Func<Task<BoundingBox?>> getBox = () => Task.FromResult<BoundingBox?>(new BoundingBox(200, 50, 50, 30));
var result = await HumanScroll.HumanScrollIntoViewAsync(page, mouse, getBox, 0, 0, FastConfig());
Assert.False(result.DidScroll);
Assert.Equal(0, mouse.WheelCalls);
}
[Fact]
public async Task Fully_visible_above_zone_with_room_still_scrolls()
{
// Same element, but the page is scrolled down (y=500) so it CAN scroll up.
var page = new ViewportPage((1280, 720), scroll: (500, 2000));
var mouse = new CountingMouse();
Func<Task<BoundingBox?>> getBox = () => Task.FromResult<BoundingBox?>(new BoundingBox(200, 50, 50, 30));
await HumanScroll.HumanScrollIntoViewAsync(page, mouse, getBox, 0, 0, FastConfig());
Assert.True(mouse.WheelCalls > 0);
}
}
+32
View File
@@ -26,6 +26,26 @@ function isInViewport(
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
}
const SCROLL_JS =
'(() => { const e = document.scrollingElement || document.documentElement;' +
' return { y: window.scrollY, maxY: Math.max(0, e.scrollHeight - e.clientHeight) }; })()';
/** Current vertical scroll offset and the maximum scrollable offset. */
async function readScrollState(page: Page): Promise<{ y: number; maxY: number }> {
const world = getWorld(page);
if (world) {
try { return await world.evaluate(SCROLL_JS); } catch { /* fall back below */ }
}
try {
return await page.evaluate(() => {
const e = document.scrollingElement || document.documentElement;
return { y: window.scrollY, maxY: Math.max(0, e.scrollHeight - e.clientHeight) };
});
} catch {
return { y: 0, maxY: 0 };
}
}
async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Promise<void> {
const absD = Math.abs(delta);
const sign = delta > 0 ? 1 : -1;
@@ -80,6 +100,18 @@ export async function humanScrollIntoView(
return { box, cursorX, cursorY, didScroll: false };
}
// Already fully visible but off-center, with the page pinned at the boundary
// in the needed direction: scrolling can't help, so don't waste the budget.
const fullyVisible = box.y >= 0 && box.y + box.height <= viewport.height;
if (fullyVisible) {
const zoneMid = viewport.height * (cfg.scroll_target_zone[0] + cfg.scroll_target_zone[1]) / 2;
const needUp = box.y + box.height / 2 < zoneMid;
const { y, maxY } = await readScrollState(page);
if (needUp ? y <= 0 : y >= maxY) {
return { box, cursorX, cursorY, didScroll: false };
}
}
// Move cursor into scroll area
const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7));
const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7));
+44
View File
@@ -1545,6 +1545,50 @@ describe("humanScrollIntoView", () => {
await humanScrollIntoView(page, raw, getBox, 0, 0, cfg);
expect(raw.wheel).toHaveBeenCalled();
}, 15000);
it("bails without scrolling when a fully-visible element is above the zone and the page is at the top (regression)", async () => {
const { humanScrollIntoView } = await import("../src/human/scroll.js");
const cfg = resolveConfig("default");
// viewport 720 -> zone [144, 576]; element top=50 is above the zone but fully visible.
const page: any = {
viewportSize: () => ({ width: 1280, height: 720 }),
evaluate: vi.fn(async () => ({ y: 0, maxY: 2000 })), // scrolled to the very top
};
const raw = {
move: vi.fn(async () => { }), down: vi.fn(async () => { }),
up: vi.fn(async () => { }), wheel: vi.fn(async () => { }),
};
const topBox = { x: 200, y: 50, width: 50, height: 30 };
const result = await humanScrollIntoView(page, raw, async () => topBox, 0, 0, cfg);
expect(result.didScroll).toBe(false);
expect(raw.wheel).not.toHaveBeenCalled();
});
it("still scrolls a fully-visible above-zone element when the page CAN scroll up (no over-bail)", async () => {
const { humanScrollIntoView } = await import("../src/human/scroll.js");
const cfg = resolveConfig("default", {
scroll_overshoot_chance: 0,
scroll_pre_move_delay: [0, 1], scroll_pause_fast: [0, 1],
scroll_pause_slow: [0, 1], scroll_settle_delay: [0, 1],
});
const page: any = {
viewportSize: () => ({ width: 1280, height: 720 }),
evaluate: vi.fn(async () => ({ y: 500, maxY: 2000 })), // room to scroll up
};
const raw = {
move: vi.fn(async () => { }), down: vi.fn(async () => { }),
up: vi.fn(async () => { }), wheel: vi.fn(async () => { }),
};
const topBox = { x: 200, y: 50, width: 50, height: 30 };
await humanScrollIntoView(page, raw, async () => topBox, 0, 0, cfg);
expect(raw.wheel).toHaveBeenCalled();
}, 15000);
});
describe("el.scrollIntoViewIfNeeded humanization", () => {