mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
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.
191 lines
5.9 KiB
TypeScript
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;
|
|
}
|
|
}
|