mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-10-18 23:07:13 -05:00
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:
7 files changed
+231
-1
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user