/** * 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 { if (!this.cdp) { this.cdp = await this.page.context().newCDPSession(this.page); } return this.cdp; } private async createWorld(): Promise { 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 { 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 { 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 { 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 { 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 => { 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 { 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 { 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 { 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 { 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[0]) => { const context = await origNewContext(options); patchContext(context, cfg); return context; }; const origNewPage = browser.newPage.bind(browser); (browser as any).newPage = async (options?: Parameters[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 };