Files
CloakBrowser/js/src/human/index.ts
T
CloakHQ 58ccdb683c fix(humanize): use shared deadline for timeout budget in frame and ElementHandle methods (#307)
Frame-level methods (click, dblclick, hover, dragAndDrop) passed the raw
timeout to each sequential operation independently, causing 3x actual
wait time when elements don't exist. ElementHandle methods had a similar
2x issue between actionability and pointer-events checks.

Port the deadline + remainingMs() pattern already used by page-level
methods. Also fix bot detection test selector after site added a hidden
duplicate submit button.
2026-05-24 23:12:07 +02:00

932 lines
35 KiB
TypeScript

/**
* 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.
*
* Stealth-aware (fixes #110):
* - isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate
* - Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events
* - Falls back to page.evaluate only when CDP session is unavailable
*
* Patches all interaction methods:
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
*
* ELEMENTHANDLE-LEVEL:
* click, dblclick, hover, type, fill, press, selectOption,
* check, uncheck, setChecked, tap, focus
* + $, $$, waitForSelector (nested elements are also patched)
*
* page.$(), page.$$(), page.waitForSelector() and Frame equivalents
* return patched ElementHandles automatically.
*/
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
import { HumanConfig, HumanActionOptions, resolveConfig, mergeConfig, rand, randRange, sleep } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement, humanScrollIntoView } from './scroll.js';
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
import {
ensureActionable, ensureStable, checkPointerEvents,
CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK,
type CheckName,
} from './actionability.js';
export { HumanConfig, resolveConfig, mergeConfig } from './config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement, humanScrollIntoView } from './scroll.js';
export { patchSingleElementHandle } from './elementhandle.js';
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
// ============================================================================
// CDP Isolated World — stealth DOM evaluation
// ============================================================================
/**
* Manages a CDP isolated execution context for DOM reads.
* Produces clean Error.stack traces (no 'eval at evaluate :302:')
* and is invisible to querySelector monkey-patches in the main world.
*
* Context ID is invalidated on navigation and auto-recreated on next call.
*/
class StealthEval {
private cdp: CDPSession | null = null;
private contextId: number | null = null;
private page: Page;
constructor(page: Page) {
this.page = page;
}
private async ensureCdp(): Promise<CDPSession> {
if (!this.cdp) {
this.cdp = await this.page.context().newCDPSession(this.page);
}
return this.cdp;
}
private async createWorld(): Promise<number> {
const cdp = await this.ensureCdp();
const tree = await cdp.send('Page.getFrameTree');
const frameId = tree.frameTree.frame.id;
const result = await cdp.send('Page.createIsolatedWorld', {
frameId,
worldName: '',
grantUniveralAccess: true,
});
const ctxId = result.executionContextId;
this.contextId = ctxId;
return ctxId;
}
/**
* Evaluate a JS expression in the isolated world.
* Auto-recreates the world if the context was invalidated (navigation).
* Returns the result value, or undefined on failure.
*/
async evaluate(expression: string): Promise<any> {
if (this.contextId === null) {
await this.createWorld();
}
for (let attempt = 0; attempt < 2; attempt++) {
try {
const cdp = await this.ensureCdp();
const result = await cdp.send('Runtime.evaluate', {
expression,
contextId: this.contextId!,
returnByValue: true,
});
if (result.exceptionDetails) {
// Context was likely invalidated by navigation
if (attempt === 0) {
await this.createWorld();
continue;
}
return undefined;
}
return result.result?.value;
} catch {
if (attempt === 0) {
this.contextId = null;
try {
await this.createWorld();
} catch {
return undefined;
}
continue;
}
return undefined;
}
}
return undefined;
}
/** Mark context as stale — call after navigation. */
invalidate(): void {
this.contextId = null;
}
/** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */
async getCdpSession(): Promise<CDPSession> {
return this.ensureCdp();
}
}
// ============================================================================
// Cursor state
// ============================================================================
class CursorState {
x = 0;
y = 0;
initialized = false;
}
// ============================================================================
// Stealth DOM queries — isolated world with evaluate fallback
// ============================================================================
/**
* Check if selector matches an input/textarea/contenteditable element.
* Uses CDP Isolated World when available — invisible to main world.
*/
async function isInputElement(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
})()
`);
return !!result;
} catch {
// Fall through to page.evaluate
}
}
// Fallback: page.evaluate (detectable — should only happen if CDP fails)
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);
}
/**
* Check if the element matching selector is currently focused.
* Uses CDP Isolated World when available — invisible to main world.
*/
async function isSelectorFocused(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
return el === document.activeElement;
})()
`);
return !!result;
} catch {
// Fall through to page.evaluate
}
}
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;
// --- Stealth infrastructure ---
const stealth = new StealthEval(page);
(page as any)._stealth = stealth;
// CDP session for shift symbol typing (lazy-initialized, reuses stealth's session)
let cdpSession: CDPSession | null = null;
const ensureCdp = async (): Promise<CDPSession | null> => {
if (!cdpSession) {
try {
cdpSession = await stealth.getCdpSession();
} catch {}
}
return cdpSession;
};
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 (invalidate isolated world on navigation) ---
const humanGoto = async (url: string, options?: {
referer?: string;
timeout?: number;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
}) => {
const response = await originals.goto(url, options);
stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
return response;
};
// --- click ---
const humanClickFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => {
await ensureCursorInit();
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const skipChecks = (options as any)?._skipChecks ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force && !skipChecks) {
await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force);
}
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
}
const target = clickTarget(finalBox, isInput, callCfg);
if (!force) {
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
await humanClick(raw, isInput, callCfg);
};
// --- dblclick ---
const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit();
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
}
const target = clickTarget(finalBox, isInput, callCfg);
if (!force) {
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
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?: HumanActionOptions & { _skipChecks?: boolean }) => {
await ensureCursorInit();
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const skipChecks = (options as any)?._skipChecks ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force && !skipChecks) await ensureActionable(page, selector, CHECKS_HOVER, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
cursor.x = cursorX;
cursor.y = cursorY;
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
}
const target = clickTarget(finalBox, false, callCfg);
if (!force) {
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
};
// --- type ---
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force);
await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
await sleep(rand(100, 250));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, callCfg, cdp);
};
// --- fill (clears existing content first) ---
const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force);
await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
const cdp = await ensureCdp();
await humanType(page, rawKb, value, callCfg, cdp);
};
// --- clear ---
const humanClearFn = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
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?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const checked = await originals.isChecked(selector).catch(() => false);
if (!checked) {
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
};
// --- uncheck ---
const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const checked = await originals.isChecked(selector).catch(() => true);
if (checked) {
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
};
// --- selectOption ---
const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
await humanHoverFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
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?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
await sleep(rand(50, 150));
await originals.keyboardPress(key);
};
// --- pressSequentially ---
const humanPressSequentiallyFn = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
await sleep(rand(100, 250));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, callCfg, cdp);
};
// --- tap ---
const humanTapFn = async (selector: string, options?: HumanActionOptions) => {
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;
(page as any).pressSequentially = humanPressSequentiallyFn;
(page as any).tap = humanTapFn;
(page as any).clear = humanClearFn;
// --- mouse patches ---
page.mouse.move = async (x: number, y: number, options?: {
steps?: number;
}) => {
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?: {
button?: 'left' | 'right' | 'middle';
clickCount?: number;
delay?: number;
}) => {
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?: { delay?: number }) => {
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
// 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, stealth);
// --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) ---
patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth);
}
// ============================================================================
// Frame-level patching
// ============================================================================
/**
* Patch Frame methods so Locator-based calls go through humanization.
* All 13 methods patched: click, dblclick, hover, type, fill, check, uncheck,
* selectOption, press, pressSequentially, tap, clear, dragAndDrop.
*/
function patchFrames(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
// Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
}
}
function firstFrameLocator(frame: Frame, selector: string): any {
const locator = frame.locator(selector) as any;
return typeof locator.first === 'function' ? locator.first() : locator;
}
async function isFrameInputElement(frame: Frame, selector: string): Promise<boolean> {
return firstFrameLocator(frame, selector).evaluate((el: Element) => {
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
}).catch(() => false);
}
async function isFrameSelectorFocused(frame: Frame, selector: string): Promise<boolean> {
return firstFrameLocator(frame, selector).evaluate((el: Element) => el === document.activeElement)
.catch(() => false);
}
function patchSingleFrame(
frame: Frame,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
if ((frame as any)._humanPatched) return;
(frame as any)._humanPatched = true;
// Save originals for methods that need fallback
const origFrameClick = frame.click.bind(frame);
const origFrameDblclick = frame.dblclick.bind(frame);
const origFrameHover = frame.hover.bind(frame);
const origFrameType = frame.type.bind(frame);
const origFrameFill = frame.fill.bind(frame);
const origFrameCheck = frame.check.bind(frame);
const origFrameUncheck = frame.uncheck.bind(frame);
const origFrameSelectOption = frame.selectOption.bind(frame);
const origFramePress = frame.press.bind(frame);
const origFramePressSequentially = (frame as any).pressSequentially?.bind(frame);
const origFrameTap = (frame as any).tap?.bind(frame);
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
const moveToFrameSelector = async (
selector: string,
options: HumanActionOptions | undefined,
inputBias: boolean,
remainingMs: () => number,
) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const locator = firstFrameLocator(frame, selector);
if (typeof locator.scrollIntoViewIfNeeded === 'function') {
await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, remainingMs()) }).catch(() => undefined);
}
const box = await locator.boundingBox({ timeout: Math.max(1, remainingMs()) }).catch(() => null);
if (!box) return null;
const isInput = inputBias || await isFrameInputElement(frame, selector);
const target = clickTarget(box, isInput, callCfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
return { callCfg, isInput };
};
const frameClick = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameClick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
await humanClick(raw, moved.isInput, moved.callCfg);
};
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
const frameHover = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameHover(selector, { ...options, timeout: Math.max(1, remainingMs()) });
};
(frame as any).click = frameClick;
(frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameDblclick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
};
(frame as any).hover = frameHover;
(frame as any).type = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await sleep(randRange(callCfg.field_switch_delay));
await frameClick(selector, options);
await sleep(rand(100, 250));
const cdp = await getFrameCdp();
await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFrameType(selector, text, options));
};
(frame as any).fill = async (selector: string, value: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await sleep(randRange(callCfg.field_switch_delay));
await frameClick(selector, options);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
const cdp = await getFrameCdp();
await humanType(page, rawKb, value, callCfg, cdp).catch(() => origFrameFill(selector, value, options));
};
(frame as any).check = async (selector: string, options?: HumanActionOptions) => {
const locator = firstFrameLocator(frame, selector);
if (typeof locator.isChecked !== 'function') return origFrameCheck(selector, options);
const checked = await locator.isChecked();
if (!checked) await frameClick(selector, options).catch(() => origFrameCheck(selector, options));
};
(frame as any).uncheck = async (selector: string, options?: HumanActionOptions) => {
const locator = firstFrameLocator(frame, selector);
if (typeof locator.isChecked !== 'function') return origFrameUncheck(selector, options);
const checked = await locator.isChecked();
if (checked) await frameClick(selector, options).catch(() => origFrameUncheck(selector, options));
};
(frame as any).selectOption = async (selector: string, values: any, options?: HumanActionOptions) => {
await frameHover(selector, options);
await sleep(rand(100, 300));
return origFrameSelectOption(selector, values, options);
};
(frame as any).press = async (selector: string, key: string, options?: HumanActionOptions) => {
if (!await isFrameSelectorFocused(frame, selector)) {
await frameClick(selector, options);
}
await sleep(rand(50, 150));
await originals.keyboardPress(key);
};
(frame as any).pressSequentially = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (!await isFrameSelectorFocused(frame, selector)) {
await frameClick(selector, options);
}
await sleep(rand(100, 250));
const cdp = await getFrameCdp();
await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFramePressSequentially?.(selector, text, options));
};
(frame as any).tap = async (selector: string, options?: HumanActionOptions) => {
await frameClick(selector, options).catch(() => origFrameTap?.(selector, options));
};
(frame as any).clear = async (selector: string, options?: HumanActionOptions) => {
if (!await isFrameSelectorFocused(frame, selector)) {
await frameClick(selector, options);
}
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?: {
force?: boolean;
noWaitAfter?: boolean;
sourcePosition?: { x: number; y: number };
strict?: boolean;
targetPosition?: { x: number; y: number };
timeout?: number;
trial?: boolean;
}) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(1, deadline - Date.now());
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: remainingMs() }).catch(() => null);
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: remainingMs() }).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, timeout: Math.max(1, remainingMs()) });
}
};
}
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?: Parameters<typeof origNewContext>[0]) => {
const context = await origNewContext(options);
patchContext(context, cfg);
return context;
};
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = async (options?: Parameters<typeof origNewPage>[0]) => {
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 };