/** * 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. */ import type { Browser, BrowserContext, Page, Frame, CDPSession } 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'; // ============================================================================ // 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?: any) => { 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?: 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(stealth, 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(stealth, 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)); const cdp = await ensureCdp(); await humanType(page, rawKb, text, cfg, cdp); }; // --- 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)); const cdp = await ensureCdp(); await humanType(page, rawKb, value, cfg, cdp); }; // --- clear --- const humanClearFn = async (selector: string, options?: any) => { if (!await isSelectorFocused(stealth, 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(stealth, 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(stealth, page, selector)) { await humanClickFn(selector); } await sleep(rand(100, 250)); const cdp = await ensureCdp(); await humanType(page, rawKb, text, cfg, cdp); }; // --- 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; (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?: 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) => { 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); } // ============================================================================ // 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, originals, stealth); } } function patchSingleFrame( frame: Frame, page: Page, cfg: HumanConfig, originals: any, stealth: StealthEval, ): 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).pressSequentially = async (selector: string, text: string, options?: any) => { await (page as any).pressSequentially(selector, text, options); }; (frame as any).tap = async (selector: string, options?: any) => { await (page as any).tap(selector, options); }; (frame as any).clear = async (selector: string, options?: any) => { if (!await isSelectorFocused(stealth, 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 { 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 };