mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: add human-like behavioral layer (humanize option)
Bezier mouse curves, per-character typing with mistype simulation, smooth micro-step scrolling, idle micro-movements between actions. Supports both sync and async Playwright APIs. Patches page, frame, context, browser, and Locator class methods. Two presets: 'default' (normal speed) and 'careful' (slower, deliberate). Configurable via HumanConfig dataclass / interface with full override support. Bug fixes (from PR review): - fill()/clear(): platform-aware select-all (Meta+a on macOS, Control+a elsewhere) - sync Locator check()/uncheck(): wrap mouse_move in RawMouse-compatible object - resolve_config(): raise error on unknown preset name - Lazy-load human.config via __getattr__ in __init__.py - humanPreset typed as 'default' | 'careful' literal union - browser.newPage() patches implicit context Tests: Python 36/36, JS Vitest 34/34, visual Python 17/17, JS 13/13
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* cloakbrowser-human — Configuration and presets.
|
||||
*
|
||||
* All numeric parameters for human-like behavior are centralized here.
|
||||
* Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious).
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HumanConfig {
|
||||
// Keyboard
|
||||
typing_delay: number;
|
||||
typing_delay_spread: number;
|
||||
typing_pause_chance: number;
|
||||
typing_pause_range: [number, number];
|
||||
shift_down_delay: [number, number];
|
||||
shift_up_delay: [number, number];
|
||||
key_hold: [number, number];
|
||||
field_switch_delay: [number, number];
|
||||
mistype_chance: number;
|
||||
mistype_delay_notice: [number, number];
|
||||
mistype_delay_correct: [number, number];
|
||||
|
||||
|
||||
// Mouse — movement
|
||||
mouse_steps_divisor: number;
|
||||
mouse_min_steps: number;
|
||||
mouse_max_steps: number;
|
||||
mouse_wobble_max: number;
|
||||
mouse_overshoot_chance: number;
|
||||
mouse_overshoot_px: [number, number];
|
||||
mouse_burst_size: [number, number];
|
||||
mouse_burst_pause: [number, number];
|
||||
|
||||
// Mouse — clicks
|
||||
click_aim_delay_input: [number, number];
|
||||
click_aim_delay_button: [number, number];
|
||||
click_hold_input: [number, number];
|
||||
click_hold_button: [number, number];
|
||||
click_input_x_range: [number, number];
|
||||
|
||||
// Mouse — idle
|
||||
idle_drift_px: number;
|
||||
idle_pause_range: [number, number];
|
||||
|
||||
// Scroll
|
||||
scroll_delta_base: [number, number];
|
||||
scroll_delta_variance: number;
|
||||
scroll_pause_fast: [number, number];
|
||||
scroll_pause_slow: [number, number];
|
||||
scroll_accel_steps: [number, number];
|
||||
scroll_decel_steps: [number, number];
|
||||
scroll_overshoot_chance: number;
|
||||
scroll_overshoot_px: [number, number];
|
||||
scroll_settle_delay: [number, number];
|
||||
scroll_target_zone: [number, number];
|
||||
scroll_pre_move_delay: [number, number];
|
||||
|
||||
// Initial cursor position
|
||||
initial_cursor_x: [number, number];
|
||||
initial_cursor_y: [number, number];
|
||||
|
||||
|
||||
// Idle micro-movements between actions (opt-in, adds latency)
|
||||
idle_between_actions: boolean;
|
||||
idle_between_duration: [number, number];
|
||||
}
|
||||
|
||||
export type HumanPreset = 'default' | 'careful';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default preset
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_CONFIG: HumanConfig = {
|
||||
// Keyboard
|
||||
typing_delay: 70,
|
||||
typing_delay_spread: 40,
|
||||
typing_pause_chance: 0.1,
|
||||
typing_pause_range: [400, 1000],
|
||||
shift_down_delay: [30, 70],
|
||||
shift_up_delay: [20, 50],
|
||||
key_hold: [15, 35],
|
||||
field_switch_delay: [800, 1500],
|
||||
// Mistype (typo simulation)
|
||||
mistype_chance: 0.02,
|
||||
mistype_delay_notice: [100, 300],
|
||||
mistype_delay_correct: [50, 150],
|
||||
|
||||
// Mouse — movement
|
||||
mouse_steps_divisor: 8,
|
||||
mouse_min_steps: 25,
|
||||
mouse_max_steps: 80,
|
||||
mouse_wobble_max: 1.5,
|
||||
mouse_overshoot_chance: 0.15,
|
||||
mouse_overshoot_px: [3, 6],
|
||||
mouse_burst_size: [3, 5],
|
||||
mouse_burst_pause: [8, 18],
|
||||
|
||||
// Mouse — clicks
|
||||
click_aim_delay_input: [60, 140],
|
||||
click_aim_delay_button: [80, 200],
|
||||
click_hold_input: [40, 100],
|
||||
click_hold_button: [60, 150],
|
||||
click_input_x_range: [0.05, 0.30],
|
||||
|
||||
// Mouse — idle
|
||||
idle_drift_px: 3,
|
||||
idle_pause_range: [300, 1000],
|
||||
|
||||
// Scroll
|
||||
scroll_delta_base: [80, 130],
|
||||
scroll_delta_variance: 0.2,
|
||||
scroll_pause_fast: [30, 80],
|
||||
scroll_pause_slow: [80, 200],
|
||||
scroll_accel_steps: [2, 3],
|
||||
scroll_decel_steps: [2, 3],
|
||||
scroll_overshoot_chance: 0.1,
|
||||
scroll_overshoot_px: [50, 150],
|
||||
scroll_settle_delay: [300, 600],
|
||||
scroll_target_zone: [0.20, 0.80],
|
||||
scroll_pre_move_delay: [100, 300],
|
||||
|
||||
// Initial cursor position (as if coming from the address bar area)
|
||||
initial_cursor_x: [400, 700],
|
||||
initial_cursor_y: [45, 60],
|
||||
|
||||
// Idle micro-movements between actions (off by default)
|
||||
idle_between_actions: false,
|
||||
idle_between_duration: [0.3, 0.8],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Careful preset — everything slower and more deliberate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CAREFUL_CONFIG: HumanConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
|
||||
// Keyboard — slower typing
|
||||
typing_delay: 100,
|
||||
typing_delay_spread: 50,
|
||||
typing_pause_chance: 0.15,
|
||||
typing_pause_range: [500, 1200],
|
||||
shift_down_delay: [40, 90],
|
||||
shift_up_delay: [30, 70],
|
||||
key_hold: [20, 45],
|
||||
field_switch_delay: [1000, 2000],
|
||||
mistype_chance: 0.03,
|
||||
mistype_delay_notice: [150, 400],
|
||||
mistype_delay_correct: [80, 200],
|
||||
|
||||
// Mouse — slower, more precise
|
||||
mouse_overshoot_chance: 0.10,
|
||||
mouse_burst_pause: [12, 25],
|
||||
|
||||
// Mouse — clicks (longer aiming and holding)
|
||||
click_aim_delay_input: [80, 180],
|
||||
click_aim_delay_button: [120, 280],
|
||||
click_hold_input: [60, 140],
|
||||
click_hold_button: [80, 200],
|
||||
|
||||
// Scroll — slower
|
||||
scroll_pause_fast: [100, 200],
|
||||
scroll_pause_slow: [250, 600],
|
||||
scroll_settle_delay: [400, 800],
|
||||
scroll_pre_move_delay: [150, 400],
|
||||
|
||||
// Idle between actions enabled for careful preset
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [0.4, 1.0],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preset map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRESETS: Record<HumanPreset, HumanConfig> = {
|
||||
default: DEFAULT_CONFIG,
|
||||
careful: CAREFUL_CONFIG,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a preset name or partial config into a full HumanConfig.
|
||||
* If `preset` is a string, returns the corresponding built-in config.
|
||||
* Any keys in `overrides` replace the preset values.
|
||||
*/
|
||||
export function resolveConfig(
|
||||
preset: HumanPreset = 'default',
|
||||
overrides?: Partial<HumanConfig>,
|
||||
): HumanConfig {
|
||||
const base = PRESETS[preset];
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
`Unknown humanize preset "${preset}". Valid presets: ${Object.keys(PRESETS).join(', ')}`
|
||||
);
|
||||
}
|
||||
if (!overrides) return { ...base };
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: random number in range
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Random float in [min, max]. */
|
||||
export function rand(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
/** Random integer in [min, max] (inclusive). */
|
||||
export function randInt(min: number, max: number): number {
|
||||
return Math.floor(rand(min, max + 1));
|
||||
}
|
||||
|
||||
/** Random value from a [min, max] tuple. */
|
||||
export function randRange(range: [number, number]): number {
|
||||
return rand(range[0], range[1]);
|
||||
}
|
||||
|
||||
/** Random integer from a [min, max] tuple. */
|
||||
export function randIntRange(range: [number, number]): number {
|
||||
return randInt(range[0], range[1]);
|
||||
}
|
||||
|
||||
/** Sleep for `ms` milliseconds. */
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* Human-like behavioral layer for cloakbrowser (JS/TS).
|
||||
*
|
||||
* Activated via humanize: true in launch() / launchContext().
|
||||
* Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
|
||||
*
|
||||
* Patches all interaction methods:
|
||||
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
|
||||
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext, Page, Frame } from 'playwright-core';
|
||||
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { scrollToElement } from './scroll.js';
|
||||
|
||||
export { HumanConfig, resolveConfig } from './config.js';
|
||||
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
export { humanType } from './keyboard.js';
|
||||
export { scrollToElement } from './scroll.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
|
||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
|
||||
class CursorState {
|
||||
x = 0;
|
||||
y = 0;
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
async function isInputElement(page: Page, selector: string): Promise<boolean> {
|
||||
return page.evaluate((sel: string) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| el.getAttribute('contenteditable') === 'true';
|
||||
}, selector).catch(() => false);
|
||||
}
|
||||
|
||||
async function isSelectorFocused(page: Page, selector: string): Promise<boolean> {
|
||||
return page.evaluate((sel: string) => {
|
||||
const el = document.querySelector(sel);
|
||||
return el === document.activeElement;
|
||||
}, selector).catch(() => false);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Page-level patching
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Replace page methods with human-like implementations.
|
||||
*/
|
||||
function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
const originals = {
|
||||
click: page.click.bind(page),
|
||||
dblclick: page.dblclick.bind(page),
|
||||
hover: page.hover.bind(page),
|
||||
type: page.type.bind(page),
|
||||
fill: page.fill.bind(page),
|
||||
check: page.check.bind(page),
|
||||
uncheck: page.uncheck.bind(page),
|
||||
selectOption: page.selectOption.bind(page),
|
||||
press: page.press.bind(page),
|
||||
goto: page.goto.bind(page),
|
||||
isChecked: page.isChecked.bind(page),
|
||||
mouseMove: page.mouse.move.bind(page.mouse),
|
||||
mouseClick: page.mouse.click.bind(page.mouse),
|
||||
mouseDblclick: page.mouse.dblclick.bind(page.mouse),
|
||||
mouseWheel: page.mouse.wheel.bind(page.mouse),
|
||||
mouseDown: page.mouse.down.bind(page.mouse),
|
||||
mouseUp: page.mouse.up.bind(page.mouse),
|
||||
keyboardType: page.keyboard.type.bind(page.keyboard),
|
||||
keyboardDown: page.keyboard.down.bind(page.keyboard),
|
||||
keyboardUp: page.keyboard.up.bind(page.keyboard),
|
||||
keyboardPress: page.keyboard.press.bind(page.keyboard),
|
||||
keyboardInsertText: page.keyboard.insertText.bind(page.keyboard),
|
||||
};
|
||||
|
||||
(page as any)._original = originals;
|
||||
(page as any)._humanCfg = cfg;
|
||||
|
||||
const raw: RawMouse = {
|
||||
move: originals.mouseMove,
|
||||
down: originals.mouseDown,
|
||||
up: originals.mouseUp,
|
||||
wheel: originals.mouseWheel,
|
||||
};
|
||||
|
||||
const rawKb: RawKeyboard = {
|
||||
down: originals.keyboardDown,
|
||||
up: originals.keyboardUp,
|
||||
type: originals.keyboardType,
|
||||
insertText: originals.keyboardInsertText,
|
||||
};
|
||||
|
||||
async function ensureCursorInit(): Promise<void> {
|
||||
if (!cursor.initialized) {
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
|
||||
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
|
||||
await originals.mouseMove(cursor.x, cursor.y);
|
||||
cursor.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- goto ---
|
||||
const humanGoto = async (url: string, options?: any) => {
|
||||
const response = await originals.goto(url, options);
|
||||
// Patch any new frames after navigation
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals);
|
||||
return response;
|
||||
};
|
||||
|
||||
// --- click ---
|
||||
const humanClickFn = async (selector: string, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(page, selector);
|
||||
const target = clickTarget(box, isInput, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
await humanClick(raw, isInput, cfg);
|
||||
};
|
||||
|
||||
// --- dblclick ---
|
||||
const humanDblclickFn = async (selector: string, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(page, selector);
|
||||
const target = clickTarget(box, isInput, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
};
|
||||
|
||||
// --- hover ---
|
||||
const humanHoverFn = async (selector: string, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const target = clickTarget(box, false, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
};
|
||||
|
||||
// --- type ---
|
||||
const humanTypeFn = async (selector: string, text: string, options?: any) => {
|
||||
await sleep(randRange(cfg.field_switch_delay));
|
||||
await humanClickFn(selector);
|
||||
await sleep(rand(100, 250));
|
||||
await humanType(page, rawKb, text, cfg);
|
||||
};
|
||||
|
||||
// --- fill (clears existing content first) ---
|
||||
const humanFillFn = async (selector: string, value: string, options?: any) => {
|
||||
await sleep(randRange(cfg.field_switch_delay));
|
||||
await humanClickFn(selector);
|
||||
await sleep(rand(100, 250));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
await sleep(rand(50, 150));
|
||||
await humanType(page, rawKb, value, cfg);
|
||||
};
|
||||
|
||||
// --- clear ---
|
||||
const humanClearFn = async (selector: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
};
|
||||
|
||||
// --- check ---
|
||||
const humanCheckFn = async (selector: string, options?: any) => {
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
}
|
||||
const checked = await originals.isChecked(selector).catch(() => false);
|
||||
if (!checked) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
};
|
||||
|
||||
// --- uncheck ---
|
||||
const humanUncheckFn = async (selector: string, options?: any) => {
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
}
|
||||
const checked = await originals.isChecked(selector).catch(() => true);
|
||||
if (checked) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
};
|
||||
|
||||
// --- selectOption ---
|
||||
const humanSelectOptionFn = async (selector: string, values: any, options?: any) => {
|
||||
await humanHoverFn(selector);
|
||||
await sleep(rand(100, 300));
|
||||
return originals.selectOption(selector, values, options);
|
||||
};
|
||||
|
||||
// --- press (checks focus first — avoids redundant mouse moves) ---
|
||||
const humanPressFn = async (selector: string, key: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(key);
|
||||
};
|
||||
|
||||
// --- pressSequentially ---
|
||||
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
await sleep(rand(100, 250));
|
||||
await humanType(page, rawKb, text, cfg);
|
||||
};
|
||||
|
||||
// --- tap ---
|
||||
const humanTapFn = async (selector: string, options?: any) => {
|
||||
await humanClickFn(selector, options);
|
||||
};
|
||||
|
||||
// Assign page-level patches
|
||||
(page as any).goto = humanGoto;
|
||||
(page as any).click = humanClickFn;
|
||||
(page as any).dblclick = humanDblclickFn;
|
||||
(page as any).hover = humanHoverFn;
|
||||
(page as any).type = humanTypeFn;
|
||||
(page as any).fill = humanFillFn;
|
||||
(page as any).check = humanCheckFn;
|
||||
(page as any).uncheck = humanUncheckFn;
|
||||
(page as any).selectOption = humanSelectOptionFn;
|
||||
(page as any).press = humanPressFn;
|
||||
|
||||
// --- mouse patches ---
|
||||
page.mouse.move = async (x: number, y: number, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||
cursor.x = x;
|
||||
cursor.y = y;
|
||||
};
|
||||
|
||||
page.mouse.click = async (x: number, y: number, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||
cursor.x = x;
|
||||
cursor.y = y;
|
||||
await humanClick(raw, false, cfg);
|
||||
};
|
||||
|
||||
// --- keyboard patches ---
|
||||
page.keyboard.type = async (text: string, options?: any) => {
|
||||
await humanType(page, rawKb, text, cfg);
|
||||
};
|
||||
|
||||
// Store helpers for frame patching
|
||||
(page as any)._humanCursor = cursor;
|
||||
(page as any)._humanRaw = raw;
|
||||
(page as any)._humanRawKb = rawKb;
|
||||
(page as any)._humanOriginals = originals;
|
||||
(page as any)._humanClickFn = humanClickFn;
|
||||
(page as any)._humanHoverFn = humanHoverFn;
|
||||
(page as any)._humanClearFn = humanClearFn;
|
||||
(page as any)._humanPressFn = humanPressFn;
|
||||
(page as any)._humanPressSequentiallyFn = humanPressSequentiallyFn;
|
||||
(page as any)._humanTapFn = humanTapFn;
|
||||
(page as any)._ensureCursorInit = ensureCursorInit;
|
||||
|
||||
// Initialize cursor immediately so it doesn't visibly jump from (0,0)
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
|
||||
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
|
||||
originals.mouseMove(cursor.x, cursor.y).then(() => {
|
||||
cursor.initialized = true;
|
||||
}).catch(() => {});
|
||||
|
||||
// --- Patch Frame-level methods (for sub-frames) ---
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Frame-level patching
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Patch Frame methods so Locator-based calls go through humanization.
|
||||
* All 11 methods patched: click, dblclick, hover, type, fill, check, uncheck,
|
||||
* selectOption, press, clear, dragAndDrop.
|
||||
*/
|
||||
function patchFrames(
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
): void {
|
||||
for (const frame of iterFrames(page)) {
|
||||
patchSingleFrame(frame, page, cfg, originals);
|
||||
}
|
||||
}
|
||||
|
||||
function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void {
|
||||
if ((frame as any)._humanPatched) return;
|
||||
(frame as any)._humanPatched = true;
|
||||
|
||||
// Save originals for methods that need fallback
|
||||
const origFrameSelectOption = frame.selectOption.bind(frame);
|
||||
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
|
||||
|
||||
(frame as any).click = async (selector: string, options?: any) => {
|
||||
await (page as any).click(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).dblclick = async (selector: string, options?: any) => {
|
||||
await (page as any).dblclick(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).hover = async (selector: string, options?: any) => {
|
||||
await (page as any).hover(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).type = async (selector: string, text: string, options?: any) => {
|
||||
await (page as any).type(selector, text, options);
|
||||
};
|
||||
|
||||
(frame as any).fill = async (selector: string, value: string, options?: any) => {
|
||||
await (page as any).fill(selector, value, options);
|
||||
};
|
||||
|
||||
(frame as any).check = async (selector: string, options?: any) => {
|
||||
await (page as any).check(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).uncheck = async (selector: string, options?: any) => {
|
||||
await (page as any).uncheck(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).selectOption = async (selector: string, values: any, options?: any) => {
|
||||
await (page as any).hover(selector);
|
||||
await sleep(rand(100, 300));
|
||||
return origFrameSelectOption(selector, values, options);
|
||||
};
|
||||
|
||||
(frame as any).press = async (selector: string, key: string, options?: any) => {
|
||||
await (page as any).press(selector, key, options);
|
||||
};
|
||||
|
||||
(frame as any).clear = async (selector: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
await (page as any).click(selector);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
};
|
||||
|
||||
(frame as any).dragAndDrop = async (source: string, target: string, options?: any) => {
|
||||
const srcBox = await frame.locator(source).boundingBox().catch(() => null);
|
||||
const tgtBox = await frame.locator(target).boundingBox().catch(() => null);
|
||||
|
||||
if (srcBox && tgtBox) {
|
||||
const sx = srcBox.x + srcBox.width / 2;
|
||||
const sy = srcBox.y + srcBox.height / 2;
|
||||
const tx = tgtBox.x + tgtBox.width / 2;
|
||||
const ty = tgtBox.y + tgtBox.height / 2;
|
||||
|
||||
await page.mouse.move(sx, sy);
|
||||
await sleep(rand(100, 200));
|
||||
await originals.mouseDown();
|
||||
await sleep(rand(80, 150));
|
||||
await page.mouse.move(tx, ty);
|
||||
await sleep(rand(80, 150));
|
||||
await originals.mouseUp();
|
||||
} else {
|
||||
return origFrameDragAndDrop(source, target, options);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function* iterFrames(page: Page): Generator<Frame> {
|
||||
try {
|
||||
const mainFrame = page.mainFrame();
|
||||
yield mainFrame;
|
||||
for (const child of mainFrame.childFrames()) {
|
||||
yield child;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Context-level patching
|
||||
// ============================================================================
|
||||
|
||||
function patchContext(context: BrowserContext, cfg: HumanConfig): void {
|
||||
const cursor = new CursorState();
|
||||
for (const page of context.pages()) {
|
||||
patchPage(page, cfg, cursor);
|
||||
}
|
||||
context.on('page', (page: Page) => {
|
||||
if (!(page as any)._original) {
|
||||
patchPage(page, cfg, new CursorState());
|
||||
}
|
||||
});
|
||||
|
||||
const origNewPage = context.newPage.bind(context);
|
||||
(context as any).newPage = async () => {
|
||||
const page = await origNewPage();
|
||||
if (!(page as any)._original) {
|
||||
patchPage(page, cfg, new CursorState());
|
||||
}
|
||||
return page;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Browser-level patching
|
||||
// ============================================================================
|
||||
|
||||
export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
|
||||
for (const context of browser.contexts()) {
|
||||
patchContext(context, cfg);
|
||||
}
|
||||
|
||||
const origNewContext = browser.newContext.bind(browser);
|
||||
(browser as any).newContext = async (options?: any) => {
|
||||
const context = await origNewContext(options);
|
||||
patchContext(context, cfg);
|
||||
return context;
|
||||
};
|
||||
|
||||
const origNewPage = browser.newPage.bind(browser);
|
||||
(browser as any).newPage = async (options?: any) => {
|
||||
const page = await origNewPage(options);
|
||||
if (!(page as any)._original) {
|
||||
const ctx = page.context();
|
||||
if (!(ctx as any)._humanPatched) {
|
||||
patchContext(ctx, cfg);
|
||||
(ctx as any)._humanPatched = true;
|
||||
}
|
||||
patchPage(page, cfg, new CursorState());
|
||||
}
|
||||
return page;
|
||||
};
|
||||
}
|
||||
|
||||
export { patchContext, patchPage };
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* cloakbrowser-human — Human-like keyboard input.
|
||||
*/
|
||||
|
||||
import type { Page } from 'playwright-core';
|
||||
import { RawKeyboard } from './mouse.js';
|
||||
import { HumanConfig, rand, randRange, sleep } from './config.js';
|
||||
|
||||
const SHIFT_SYMBOLS = new Set([
|
||||
'@', '#', '!', '$', '%', '^', '&', '*', '(', ')',
|
||||
'_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~',
|
||||
]);
|
||||
|
||||
const NEARBY_KEYS: Record<string, string> = {
|
||||
a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf',
|
||||
f: 'dgrtcv', g: 'fhtyb', h: 'gjybn', i: 'ujko', j: 'hkunm',
|
||||
k: 'jloi', l: 'kop', m: 'njk', n: 'bhjm', o: 'iklp',
|
||||
p: 'ol', q: 'wa', r: 'edft', s: 'awedxz', t: 'rfgy',
|
||||
u: 'yhji', v: 'cfgb', w: 'qase', x: 'zsdc', y: 'tghu',
|
||||
z: 'asx',
|
||||
'1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt',
|
||||
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
|
||||
};
|
||||
|
||||
function getNearbyKey(ch: string): string {
|
||||
const lower = ch.toLowerCase();
|
||||
if (lower in NEARBY_KEYS) {
|
||||
const neighbors = NEARBY_KEYS[lower];
|
||||
const wrong = neighbors[Math.floor(Math.random() * neighbors.length)];
|
||||
return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong;
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
export async function humanType(
|
||||
page: Page,
|
||||
raw: RawKeyboard,
|
||||
text: string,
|
||||
cfg: HumanConfig,
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
|
||||
// Mistype chance — press wrong key, notice, backspace, then correct
|
||||
if (Math.random() < cfg.mistype_chance && /[a-zA-Z0-9]/.test(ch)) {
|
||||
const wrong = getNearbyKey(ch);
|
||||
await typeNormalChar(raw, wrong, cfg);
|
||||
await sleep(randRange(cfg.mistype_delay_notice));
|
||||
await raw.down('Backspace');
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await raw.up('Backspace');
|
||||
await sleep(randRange(cfg.mistype_delay_correct));
|
||||
}
|
||||
|
||||
if (isUpperCase(ch)) {
|
||||
await typeShiftedChar(raw, ch, cfg);
|
||||
} else if (SHIFT_SYMBOLS.has(ch)) {
|
||||
await typeShiftSymbol(page, raw, ch, cfg);
|
||||
} else {
|
||||
await typeNormalChar(raw, ch, cfg);
|
||||
}
|
||||
|
||||
if (i < text.length - 1) {
|
||||
await interCharDelay(cfg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
||||
await raw.down(ch);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await raw.up(ch);
|
||||
}
|
||||
|
||||
async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
||||
await raw.down('Shift');
|
||||
await sleep(randRange(cfg.shift_down_delay));
|
||||
await raw.down(ch);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await raw.up(ch);
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await raw.up('Shift');
|
||||
}
|
||||
|
||||
async function typeShiftSymbol(page: Page, raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
||||
await raw.down('Shift');
|
||||
await sleep(randRange(cfg.shift_down_delay));
|
||||
await raw.insertText(ch);
|
||||
await page.evaluate((key: string) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}, ch);
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await raw.up('Shift');
|
||||
}
|
||||
|
||||
function isUpperCase(ch: string): boolean {
|
||||
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
||||
}
|
||||
|
||||
async function interCharDelay(cfg: HumanConfig): Promise<void> {
|
||||
if (Math.random() < cfg.typing_pause_chance) {
|
||||
await sleep(randRange(cfg.typing_pause_range));
|
||||
} else {
|
||||
const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread;
|
||||
await sleep(Math.max(10, delay));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* cloakbrowser-human — Human-like mouse movement and clicking.
|
||||
*/
|
||||
|
||||
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw interface — original Playwright methods, bypassing the wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RawMouse {
|
||||
move: (x: number, y: number) => Promise<void>;
|
||||
down: (options?: any) => Promise<void>;
|
||||
up: (options?: any) => Promise<void>;
|
||||
wheel: (deltaX: number, deltaY: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface RawKeyboard {
|
||||
down: (key: string) => Promise<void>;
|
||||
up: (key: string) => Promise<void>;
|
||||
type: (text: string) => Promise<void>;
|
||||
insertText: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Easing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function easeInOut(t: number): number {
|
||||
return t < 0.5
|
||||
? 4 * t * t * t
|
||||
: 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bezier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
|
||||
const u = 1 - t;
|
||||
const uu = u * u;
|
||||
const uuu = uu * u;
|
||||
const tt = t * t;
|
||||
const ttt = tt * t;
|
||||
return {
|
||||
x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
|
||||
y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
function randomControlPoints(start: Point, end: Point): [Point, Point] {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const px = -dy / (dist || 1);
|
||||
const py = dx / (dist || 1);
|
||||
const bias1 = rand(-0.3, 0.3) * dist;
|
||||
const bias2 = rand(-0.3, 0.3) * dist;
|
||||
return [
|
||||
{ x: start.x + dx * 0.25 + px * bias1, y: start.y + dy * 0.25 + py * bias1 },
|
||||
{ x: start.x + dx * 0.75 + px * bias2, y: start.y + dy * 0.75 + py * bias2 },
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Human mouse movement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function humanMove(
|
||||
raw: RawMouse,
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<void> {
|
||||
const dist = Math.hypot(endX - startX, endY - startY);
|
||||
if (dist < 1) return;
|
||||
|
||||
const steps = Math.max(
|
||||
cfg.mouse_min_steps,
|
||||
Math.min(cfg.mouse_max_steps, Math.round(dist / cfg.mouse_steps_divisor)),
|
||||
);
|
||||
|
||||
const start: Point = { x: startX, y: startY };
|
||||
const end: Point = { x: endX, y: endY };
|
||||
const [cp1, cp2] = randomControlPoints(start, end);
|
||||
|
||||
let burstCounter = 0;
|
||||
const burstSize = randIntRange(cfg.mouse_burst_size);
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const progress = i / steps;
|
||||
const easedT = easeInOut(progress);
|
||||
const pt = bezier(start, cp1, cp2, end, easedT);
|
||||
|
||||
const wobbleAmp = Math.sin(Math.PI * progress) * cfg.mouse_wobble_max;
|
||||
const wx = pt.x + (Math.random() - 0.5) * 2 * wobbleAmp;
|
||||
const wy = pt.y + (Math.random() - 0.5) * 2 * wobbleAmp;
|
||||
|
||||
await raw.move(Math.round(wx), Math.round(wy));
|
||||
|
||||
burstCounter++;
|
||||
if (burstCounter >= burstSize && i < steps) {
|
||||
await sleep(randRange(cfg.mouse_burst_pause));
|
||||
burstCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.random() < cfg.mouse_overshoot_chance) {
|
||||
const overshootDist = randRange(cfg.mouse_overshoot_px);
|
||||
const angle = Math.atan2(endY - startY, endX - startX);
|
||||
const ovX = Math.round(endX + Math.cos(angle) * overshootDist);
|
||||
const ovY = Math.round(endY + Math.sin(angle) * overshootDist);
|
||||
await raw.move(ovX, ovY);
|
||||
await sleep(rand(30, 70));
|
||||
const corrX = Math.round(endX + (Math.random() - 0.5) * 4);
|
||||
const corrY = Math.round(endY + (Math.random() - 0.5) * 4);
|
||||
await raw.move(corrX, corrY);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Human click
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function clickTarget(
|
||||
box: { x: number; y: number; width: number; height: number },
|
||||
isInput: boolean,
|
||||
cfg: HumanConfig,
|
||||
): Point {
|
||||
if (isInput) {
|
||||
const xFrac = randRange(cfg.click_input_x_range);
|
||||
const yFrac = rand(0.30, 0.70);
|
||||
return {
|
||||
x: Math.round(box.x + box.width * xFrac),
|
||||
y: Math.round(box.y + box.height * yFrac),
|
||||
};
|
||||
}
|
||||
const xFrac = rand(0.35, 0.65);
|
||||
const yFrac = rand(0.35, 0.65);
|
||||
return {
|
||||
x: Math.round(box.x + box.width * xFrac),
|
||||
y: Math.round(box.y + box.height * yFrac),
|
||||
};
|
||||
}
|
||||
|
||||
export async function humanClick(
|
||||
raw: RawMouse,
|
||||
isInput: boolean,
|
||||
cfg: HumanConfig,
|
||||
): Promise<void> {
|
||||
const aimDelay = isInput
|
||||
? randRange(cfg.click_aim_delay_input)
|
||||
: randRange(cfg.click_aim_delay_button);
|
||||
await sleep(aimDelay);
|
||||
|
||||
const holdTime = isInput
|
||||
? randRange(cfg.click_hold_input)
|
||||
: randRange(cfg.click_hold_button);
|
||||
await raw.down();
|
||||
await sleep(holdTime);
|
||||
await raw.up();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Human idle / drift
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function humanIdle(
|
||||
raw: RawMouse,
|
||||
seconds: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<void> {
|
||||
const endTime = Date.now() + seconds * 1000;
|
||||
let x = cx;
|
||||
let y = cy;
|
||||
while (Date.now() < endTime) {
|
||||
const dx = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
|
||||
const dy = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
|
||||
x += dx;
|
||||
y += dy;
|
||||
await raw.move(Math.round(x), Math.round(y));
|
||||
await sleep(randRange(cfg.idle_pause_range));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollToElement(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
selector: string,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error('Viewport size not available');
|
||||
|
||||
let box = await getElementBox(page, selector);
|
||||
if (!box) {
|
||||
await sleep(200);
|
||||
box = await getElementBox(page, selector);
|
||||
if (!box) throw new Error(`Element not found: ${selector}`);
|
||||
}
|
||||
|
||||
if (isInViewport(box, viewport.height, cfg)) {
|
||||
return { box, cursorX, cursorY };
|
||||
}
|
||||
|
||||
// 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 getElementBox(page, selector);
|
||||
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 getElementBox(page, selector);
|
||||
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
|
||||
|
||||
return { box, cursorX, cursorY };
|
||||
}
|
||||
|
||||
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
|
||||
const el = page.locator(selector).first();
|
||||
try {
|
||||
const box = await el.boundingBox({ timeout: 2000 });
|
||||
return box;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,17 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
// Human-like behavioral patching
|
||||
if (options.humanize) {
|
||||
const { patchBrowser } = await import('./human/index.js');
|
||||
const { resolveConfig } = await import('./human/config.js');
|
||||
const cfg = resolveConfig(
|
||||
(options.humanPreset as any) ?? 'default',
|
||||
options.humanConfig as any,
|
||||
);
|
||||
patchBrowser(browser, cfg);
|
||||
}
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
@@ -103,6 +114,17 @@ export async function launchContext(
|
||||
await browser.close();
|
||||
};
|
||||
|
||||
// Human-like behavioral patching
|
||||
if (options.humanize) {
|
||||
const { patchContext } = await import('./human/index.js');
|
||||
const { resolveConfig } = await import('./human/config.js');
|
||||
const cfg = resolveConfig(
|
||||
(options.humanPreset as any) ?? 'default',
|
||||
options.humanConfig as any,
|
||||
);
|
||||
patchContext(context, cfg);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -153,6 +175,17 @@ export async function launchPersistentContext(
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
// Human-like behavioral patching
|
||||
if (options.humanize) {
|
||||
const { patchContext } = await import('./human/index.js');
|
||||
const { resolveConfig } = await import('./human/config.js');
|
||||
const cfg = resolveConfig(
|
||||
(options.humanPreset as any) ?? 'default',
|
||||
options.humanConfig as any,
|
||||
);
|
||||
patchContext(context, cfg);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ export interface LaunchOptions {
|
||||
geoip?: boolean;
|
||||
/** Raw options passed directly to playwright/puppeteer launch(). */
|
||||
launchOptions?: Record<string, unknown>;
|
||||
/** Enable human-like mouse, keyboard, and scroll behavior. */
|
||||
humanize?: boolean;
|
||||
/** Human behavior preset: 'default' or 'careful'. */
|
||||
humanPreset?: 'default' | 'careful';
|
||||
/** Override individual human behavior parameters. */
|
||||
humanConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LaunchContextOptions extends LaunchOptions {
|
||||
|
||||
Reference in New Issue
Block a user