Files
CloakBrowser/js/src/human/scroll.ts
T
CloakHQ 9c3ed2dcba fix(humanize): fall back to window dims when viewport is null
Headed launches default to no_viewport, so page.viewport_size is None and
human scroll raised "Viewport size not available". Fall back to live
window.innerWidth/innerHeight. Covers Playwright (py sync/async, JS) and
Puppeteer paths.
2026-06-23 01:34:07 +02:00

191 lines
5.9 KiB
TypeScript

/**
* cloakbrowser-human — Human-like scrolling via mouse wheel events.
*/
import type { Page } from 'playwright-core';
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
import { RawMouse, humanMove } from './mouse.js';
interface ElementBounds {
x: number;
y: number;
width: number;
height: number;
}
function isInViewport(
bounds: ElementBounds,
viewportHeight: number,
cfg: HumanConfig,
): boolean {
const topEdge = bounds.y;
const bottomEdge = bounds.y + bounds.height;
const zoneTop = viewportHeight * cfg.scroll_target_zone[0];
const zoneBottom = viewportHeight * cfg.scroll_target_zone[1];
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
}
async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Promise<void> {
const absD = Math.abs(delta);
const sign = delta > 0 ? 1 : -1;
let sent = 0;
while (sent < absD) {
const stepSize = rand(20, 40);
const chunk = Math.min(stepSize, absD - sent);
await raw.wheel(0, Math.round(chunk) * sign);
sent += chunk;
await sleep(rand(8, 20));
}
}
/**
* Humanized scrolling that takes an arbitrary ``getBox`` callable.
*
* Used by both ``scrollToElement`` (selector-based) and the ElementHandle
* ``scrollIntoViewIfNeeded`` patch so the same accelerate → cruise →
* decelerate → overshoot behavior runs everywhere.
*/
export async function humanScrollIntoView(
page: Page,
raw: RawMouse,
getBox: () => Promise<ElementBounds | null>,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
// Headed launches default to no_viewport so the page tracks the real OS
// window; page.viewportSize() is then null. Fall back to the live window
// dimensions so humanize works headed (the stealth-relevant mode).
let viewport = page.viewportSize();
if (!viewport) {
viewport = await page.evaluate(
() => ({ width: window.innerWidth, height: window.innerHeight }),
);
}
if (!viewport || !viewport.height) throw new Error('Viewport size not available');
let box = await getBox();
if (!box) throw new Error('Element not found while scrolling into view');
if (isInViewport(box, viewport.height, cfg)) {
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));
await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg);
cursorX = scrollAreaX;
cursorY = scrollAreaY;
await sleep(randRange(cfg.scroll_pre_move_delay));
// Calculate scroll distance
const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]);
const elementCenter = box.y + box.height / 2;
const distanceToScroll = elementCenter - targetY;
const direction = distanceToScroll > 0 ? 1 : -1;
const absDistance = Math.abs(distanceToScroll);
const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2;
const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta));
const accelSteps = randIntRange(cfg.scroll_accel_steps);
const decelSteps = randIntRange(cfg.scroll_decel_steps);
let scrolled = 0;
// Scroll loop: accelerate → cruise → decelerate
for (let i = 0; i < totalClicks; i++) {
let delta: number;
let pause: number;
if (i < accelSteps) {
delta = rand(80, 100);
pause = randRange(cfg.scroll_pause_slow);
} else if (i >= totalClicks - decelSteps) {
delta = rand(60, 90);
pause = randRange(cfg.scroll_pause_slow);
} else {
delta = randRange(cfg.scroll_delta_base);
pause = randRange(cfg.scroll_pause_fast);
}
delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance;
delta = Math.round(delta) * direction;
await smoothWheel(raw, delta, cfg);
scrolled += Math.abs(delta);
await sleep(pause);
// Check visibility every 3 steps
if (i % 3 === 2 || i === totalClicks - 1) {
box = await getBox();
if (box && isInViewport(box, viewport.height, cfg)) {
break;
}
}
if (scrolled >= absDistance * 1.1) break;
}
// Optional overshoot + correction
if (Math.random() < cfg.scroll_overshoot_chance) {
const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction;
await smoothWheel(raw, overshootPx, cfg);
await sleep(randRange(cfg.scroll_settle_delay));
const corrections = randIntRange([1, 2]);
for (let c = 0; c < corrections; c++) {
const corrDelta = Math.round(rand(40, 80)) * -direction;
await smoothWheel(raw, corrDelta, cfg);
await sleep(rand(100, 250));
}
}
// Settle
await sleep(randRange(cfg.scroll_settle_delay));
box = await getBox();
if (!box) throw new Error('Element lost after scrolling into view');
return { box, cursorX, cursorY, didScroll: true };
}
/**
* Selector-based humanized scroll.
*
* ``timeout`` is forwarded to Playwright's ``boundingBox({ timeout })`` so
* callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
*
* Returns `{ box, cursorX, cursorY, didScroll }`.
*/
export async function scrollToElement(
page: Page,
raw: RawMouse,
selector: string,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
timeout?: number,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
return humanScrollIntoView(
page, raw,
() => getElementBox(page, selector, timeout),
cursorX, cursorY, cfg,
);
}
async function getElementBox(
page: Page,
selector: string,
timeout: number = 30000,
): Promise<ElementBounds | null> {
const el = page.locator(selector).first();
try {
const box = await el.boundingBox({ timeout: Math.max(1, timeout) });
return box;
} catch {
return null;
}
}