diff --git a/cloakbrowser/human/scroll.py b/cloakbrowser/human/scroll.py
index 7905d7c..d18f509 100644
--- a/cloakbrowser/human/scroll.py
+++ b/cloakbrowser/human/scroll.py
@@ -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))
diff --git a/cloakbrowser/human/scroll_async.py b/cloakbrowser/human/scroll_async.py
index dc5ef52..6329a81 100644
--- a/cloakbrowser/human/scroll_async.py
+++ b/cloakbrowser/human/scroll_async.py
@@ -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))
diff --git a/dotnet/src/CloakBrowser/Human/HumanScroll.cs b/dotnet/src/CloakBrowser/Human/HumanScroll.cs
index c3258df..fce3b4d 100644
--- a/dotnet/src/CloakBrowser/Human/HumanScroll.cs
+++ b/dotnet/src/CloakBrowser/Human/HumanScroll.cs
@@ -17,6 +17,14 @@ public interface IRawScrollPage
/// Returns null if the dimensions can't be read.
///
Task<(int Width, int Height)?> GetLiveWindowSizeAsync();
+
+ ///
+ /// Current vertical scroll offset (window.scrollY) and the maximum
+ /// scrollable offset (scrollHeight - clientHeight). 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.
+ ///
+ Task<(double Y, double MaxY)?> GetScrollStateAsync();
}
/// Result of a humanized scroll-into-view operation.
@@ -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));
diff --git a/dotnet/src/CloakBrowser/Human/PlaywrightAdapters.cs b/dotnet/src/CloakBrowser/Human/PlaywrightAdapters.cs
index 153dd39..cc8066e 100644
--- a/dotnet/src/CloakBrowser/Human/PlaywrightAdapters.cs
+++ b/dotnet/src/CloakBrowser/Human/PlaywrightAdapters.cs
@@ -103,9 +103,31 @@ internal sealed class PlaywrightScrollPage : IRawScrollPage
}
}
+ public async Task<(double Y, double MaxY)?> GetScrollStateAsync()
+ {
+ try
+ {
+ var s = await _page.EvaluateAsync(
+ "() => { 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; }
+ }
}
diff --git a/dotnet/tests/CloakBrowser.Tests/ScrollFallbackTests.cs b/dotnet/tests/CloakBrowser.Tests/ScrollFallbackTests.cs
index b91cb1b..303b4d2 100644
--- a/dotnet/tests/CloakBrowser.Tests/ScrollFallbackTests.cs
+++ b/dotnet/tests/CloakBrowser.Tests/ScrollFallbackTests.cs
@@ -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));
+ }
+
+ /// Scroll page with a fixed viewport and configurable scroll position.
+ 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(() =>
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> getBox = () => Task.FromResult(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> getBox = () => Task.FromResult(new BoundingBox(200, 50, 50, 30));
+
+ await HumanScroll.HumanScrollIntoViewAsync(page, mouse, getBox, 0, 0, FastConfig());
+
+ Assert.True(mouse.WheelCalls > 0);
+ }
}
diff --git a/js/src/human/scroll.ts b/js/src/human/scroll.ts
index 3fe3d9d..b78b944 100644
--- a/js/src/human/scroll.ts
+++ b/js/src/human/scroll.ts
@@ -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 {
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));
diff --git a/js/tests/humanize.test.ts b/js/tests/humanize.test.ts
index 0abdef6..057ab05 100644
--- a/js/tests/humanize.test.ts
+++ b/js/tests/humanize.test.ts
@@ -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", () => {