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", () => {