feat(humanize): implement CDP Isolated Worlds and trusted keyboard events (fixes #110)
This commit is contained in:
+276
-13
@@ -3,14 +3,20 @@
|
||||
Activated via humanize=True in launch() / launch_async().
|
||||
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
|
||||
|
||||
Supports both sync and async Playwright APIs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from .config import HumanConfig, HumanPreset, resolve_config
|
||||
from .config import rand, rand_range, sleep_ms, async_sleep_ms
|
||||
@@ -33,6 +39,153 @@ __all__ = [
|
||||
|
||||
logger = logging.getLogger("cloakbrowser.human")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CDP Isolated World — stealth DOM evaluation
|
||||
# ============================================================================
|
||||
|
||||
class _SyncIsolatedWorld:
|
||||
"""Manages a CDP isolated execution context for DOM reads (sync).
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
__slots__ = ("_page", "_cdp", "_context_id")
|
||||
|
||||
def __init__(self, page: Any):
|
||||
self._page = page
|
||||
self._cdp: Any = None
|
||||
self._context_id: Optional[int] = None
|
||||
|
||||
def _ensure_cdp(self) -> Any:
|
||||
if self._cdp is None:
|
||||
self._cdp = self._page.context.new_cdp_session(self._page)
|
||||
return self._cdp
|
||||
|
||||
def _create_world(self) -> int:
|
||||
cdp = self._ensure_cdp()
|
||||
tree = cdp.send("Page.getFrameTree")
|
||||
frame_id = tree["frameTree"]["frame"]["id"]
|
||||
result = cdp.send("Page.createIsolatedWorld", {
|
||||
"frameId": frame_id,
|
||||
"worldName": "",
|
||||
"grantUniveralAccess": True,
|
||||
})
|
||||
self._context_id = result["executionContextId"]
|
||||
return self._context_id
|
||||
|
||||
def evaluate(self, expression: str) -> Any:
|
||||
"""Evaluate JS in isolated world. Auto-recreates on stale context."""
|
||||
if self._context_id is None:
|
||||
self._create_world()
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
result = self._cdp.send("Runtime.evaluate", {
|
||||
"expression": expression,
|
||||
"contextId": self._context_id,
|
||||
"returnByValue": True,
|
||||
})
|
||||
if "exceptionDetails" in result:
|
||||
if attempt == 0:
|
||||
self._create_world()
|
||||
continue
|
||||
return None
|
||||
return result.get("result", {}).get("value")
|
||||
except Exception:
|
||||
if attempt == 0:
|
||||
self._context_id = None
|
||||
try:
|
||||
self._create_world()
|
||||
except Exception:
|
||||
return None
|
||||
continue
|
||||
return None
|
||||
return None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Mark context as stale — call after navigation."""
|
||||
self._context_id = None
|
||||
|
||||
def get_cdp_session(self) -> Any:
|
||||
"""Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
|
||||
return self._ensure_cdp()
|
||||
|
||||
|
||||
class _AsyncIsolatedWorld:
|
||||
"""Manages a CDP isolated execution context for DOM reads (async).
|
||||
|
||||
Same as _SyncIsolatedWorld but uses await for all CDP calls.
|
||||
"""
|
||||
|
||||
__slots__ = ("_page", "_cdp", "_context_id")
|
||||
|
||||
def __init__(self, page: Any):
|
||||
self._page = page
|
||||
self._cdp: Any = None
|
||||
self._context_id: Optional[int] = None
|
||||
|
||||
async def _ensure_cdp(self) -> Any:
|
||||
if self._cdp is None:
|
||||
self._cdp = await self._page.context.new_cdp_session(self._page)
|
||||
return self._cdp
|
||||
|
||||
async def _create_world(self) -> int:
|
||||
cdp = await self._ensure_cdp()
|
||||
tree = await cdp.send("Page.getFrameTree")
|
||||
frame_id = tree["frameTree"]["frame"]["id"]
|
||||
result = await cdp.send("Page.createIsolatedWorld", {
|
||||
"frameId": frame_id,
|
||||
"worldName": "",
|
||||
"grantUniveralAccess": True,
|
||||
})
|
||||
self._context_id = result["executionContextId"]
|
||||
return self._context_id
|
||||
|
||||
async def evaluate(self, expression: str) -> Any:
|
||||
"""Evaluate JS in isolated world. Auto-recreates on stale context."""
|
||||
if self._context_id is None:
|
||||
await self._create_world()
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
result = await self._cdp.send("Runtime.evaluate", {
|
||||
"expression": expression,
|
||||
"contextId": self._context_id,
|
||||
"returnByValue": True,
|
||||
})
|
||||
if "exceptionDetails" in result:
|
||||
if attempt == 0:
|
||||
await self._create_world()
|
||||
continue
|
||||
return None
|
||||
return result.get("result", {}).get("value")
|
||||
except Exception:
|
||||
if attempt == 0:
|
||||
self._context_id = None
|
||||
try:
|
||||
await self._create_world()
|
||||
except Exception:
|
||||
return None
|
||||
continue
|
||||
return None
|
||||
return None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Mark context as stale — call after navigation."""
|
||||
self._context_id = None
|
||||
|
||||
async def get_cdp_session(self) -> Any:
|
||||
"""Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
|
||||
return await self._ensure_cdp()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Cursor state
|
||||
# ============================================================================
|
||||
|
||||
class _CursorState:
|
||||
__slots__ = ("x", "y", "initialized")
|
||||
|
||||
@@ -42,7 +195,30 @@ class _CursorState:
|
||||
self.initialized: bool = False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Stealth DOM queries — isolated world with evaluate fallback
|
||||
# ============================================================================
|
||||
|
||||
def _is_input_element(page: Any, selector: str) -> bool:
|
||||
"""Check if selector is an input element. Uses CDP isolated world when available."""
|
||||
world: Optional[_SyncIsolatedWorld] = getattr(page, '_stealth_world', None)
|
||||
if world is not None:
|
||||
try:
|
||||
escaped = json.dumps(selector)
|
||||
result = world.evaluate(
|
||||
f"(() => {{"
|
||||
f" const el = document.querySelector({escaped});"
|
||||
f" if (!el) return false;"
|
||||
f" const tag = el.tagName.toLowerCase();"
|
||||
f" return tag === 'input' || tag === 'textarea'"
|
||||
f" || el.getAttribute('contenteditable') === 'true';"
|
||||
f"}})()"
|
||||
)
|
||||
return bool(result)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: page.evaluate (detectable — should only happen if CDP fails)
|
||||
try:
|
||||
return page.evaluate(
|
||||
"""(sel) => {
|
||||
@@ -59,6 +235,24 @@ def _is_input_element(page: Any, selector: str) -> bool:
|
||||
|
||||
|
||||
async def _async_is_input_element(page: Any, selector: str) -> bool:
|
||||
"""Check if selector is an input element (async). Uses CDP isolated world when available."""
|
||||
world: Optional[_AsyncIsolatedWorld] = getattr(page, '_stealth_world', None)
|
||||
if world is not None:
|
||||
try:
|
||||
escaped = json.dumps(selector)
|
||||
result = await world.evaluate(
|
||||
f"(() => {{"
|
||||
f" const el = document.querySelector({escaped});"
|
||||
f" if (!el) return false;"
|
||||
f" const tag = el.tagName.toLowerCase();"
|
||||
f" return tag === 'input' || tag === 'textarea'"
|
||||
f" || el.getAttribute('contenteditable') === 'true';"
|
||||
f"}})()"
|
||||
)
|
||||
return bool(result)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return await page.evaluate(
|
||||
"""(sel) => {
|
||||
@@ -75,7 +269,22 @@ async def _async_is_input_element(page: Any, selector: str) -> bool:
|
||||
|
||||
|
||||
def _is_selector_focused(page: Any, selector: str) -> bool:
|
||||
"""Check if the element matching selector is currently focused."""
|
||||
"""Check if the element matching selector is currently focused.
|
||||
Uses CDP isolated world when available."""
|
||||
world: Optional[_SyncIsolatedWorld] = getattr(page, '_stealth_world', None)
|
||||
if world is not None:
|
||||
try:
|
||||
escaped = json.dumps(selector)
|
||||
result = world.evaluate(
|
||||
f"(() => {{"
|
||||
f" const el = document.querySelector({escaped});"
|
||||
f" return el === document.activeElement;"
|
||||
f"}})()"
|
||||
)
|
||||
return bool(result)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return page.evaluate(
|
||||
"""(sel) => {
|
||||
@@ -89,7 +298,22 @@ def _is_selector_focused(page: Any, selector: str) -> bool:
|
||||
|
||||
|
||||
async def _async_is_selector_focused(page: Any, selector: str) -> bool:
|
||||
"""Check if the element matching selector is currently focused (async)."""
|
||||
"""Check if the element matching selector is currently focused (async).
|
||||
Uses CDP isolated world when available."""
|
||||
world: Optional[_AsyncIsolatedWorld] = getattr(page, '_stealth_world', None)
|
||||
if world is not None:
|
||||
try:
|
||||
escaped = json.dumps(selector)
|
||||
result = await world.evaluate(
|
||||
f"(() => {{"
|
||||
f" const el = document.querySelector({escaped});"
|
||||
f" return el === document.activeElement;"
|
||||
f"}})()"
|
||||
)
|
||||
return bool(result)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return await page.evaluate(
|
||||
"""(sel) => {
|
||||
@@ -216,7 +440,6 @@ def _patch_locator_class_sync():
|
||||
def _humanized_press(self, key, **kwargs):
|
||||
if _is_humanized(self):
|
||||
selector = _get_selector(self)
|
||||
# Only click if not already focused — avoids redundant mouse moves
|
||||
if not _is_selector_focused(self.page, selector):
|
||||
self.page.click(selector)
|
||||
sleep_ms(rand(50, 150))
|
||||
@@ -516,6 +739,17 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
page._original = originals
|
||||
page._human_cfg = cfg
|
||||
|
||||
# --- Stealth infrastructure ---
|
||||
try:
|
||||
stealth = _SyncIsolatedWorld(page)
|
||||
page._stealth_world = stealth
|
||||
cdp_session = stealth.get_cdp_session()
|
||||
except Exception:
|
||||
stealth = None
|
||||
page._stealth_world = None
|
||||
cdp_session = None
|
||||
logger.debug("Could not create CDP session — stealth features disabled")
|
||||
|
||||
raw_mouse: RawMouse = type("_RawMouse", (), {
|
||||
"move": originals.mouse_move,
|
||||
"down": originals.mouse_down,
|
||||
@@ -539,6 +773,9 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
def _human_goto(url: str, **kwargs: Any) -> Any:
|
||||
response = originals.goto(url, **kwargs)
|
||||
# Invalidate isolated world after navigation (context ID becomes stale)
|
||||
if stealth is not None:
|
||||
stealth.invalidate()
|
||||
return response
|
||||
|
||||
def _human_click(selector: str, **kwargs: Any) -> None:
|
||||
@@ -593,7 +830,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
_human_click(selector)
|
||||
sleep_ms(rand(100, 250))
|
||||
human_type(page, raw_keyboard, text, cfg)
|
||||
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
|
||||
|
||||
def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
|
||||
sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
@@ -603,7 +840,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
sleep_ms(rand(30, 80))
|
||||
originals.keyboard_press("Backspace")
|
||||
sleep_ms(rand(50, 150))
|
||||
human_type(page, raw_keyboard, value, cfg)
|
||||
human_type(page, raw_keyboard, value, cfg, cdp_session=cdp_session)
|
||||
|
||||
def _human_check(selector: str, **kwargs: Any) -> None:
|
||||
try:
|
||||
@@ -646,7 +883,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
human_click(raw_mouse, False, cfg)
|
||||
|
||||
def _human_keyboard_type(text: str, **kwargs: Any) -> None:
|
||||
human_type(page, raw_keyboard, text, cfg)
|
||||
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
|
||||
|
||||
page.goto = _human_goto
|
||||
page.click = _human_click
|
||||
@@ -659,10 +896,10 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
page.press = _human_press
|
||||
page.mouse.move = _human_mouse_move
|
||||
page.mouse.click = _human_mouse_click
|
||||
page.keyboard.type = _human_keyboard_type
|
||||
page.keyboard.type = _human_keyboard_type
|
||||
# --- Patch Frame-level methods (for sub-frames) ---
|
||||
_patch_frames_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals)
|
||||
|
||||
|
||||
# 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])
|
||||
@@ -690,6 +927,10 @@ def _patch_frames_sync(
|
||||
|
||||
def _frame_aware_goto(url: str, **kwargs: Any) -> Any:
|
||||
response = _orig_goto(url, **kwargs)
|
||||
# Invalidate isolated world after navigation
|
||||
stealth_world = getattr(page, '_stealth_world', None)
|
||||
if stealth_world is not None:
|
||||
stealth_world.invalidate()
|
||||
for frame in _iter_frames(page):
|
||||
if not getattr(frame, "_human_patched", False):
|
||||
_patch_single_frame_sync(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
|
||||
@@ -709,7 +950,6 @@ def _patch_single_frame_sync(
|
||||
return
|
||||
frame._human_patched = True
|
||||
|
||||
# Save originals for methods that need fallback
|
||||
_orig_frame_select_option = frame.select_option
|
||||
_orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None)
|
||||
|
||||
@@ -841,6 +1081,7 @@ def patch_browser(browser: Any, cfg: HumanConfig) -> None:
|
||||
|
||||
|
||||
def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
"""Replace page methods with human-like implementations (async)."""
|
||||
originals = type("Originals", (), {
|
||||
"click": page.click,
|
||||
"type": page.type,
|
||||
@@ -863,6 +1104,19 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
page._original = originals
|
||||
page._human_cfg = cfg
|
||||
|
||||
# --- Stealth infrastructure (lazy-initialized, async) ---
|
||||
stealth = _AsyncIsolatedWorld(page)
|
||||
page._stealth_world = stealth
|
||||
cdp_session_holder: list[Any] = [None] # mutable container for closure
|
||||
|
||||
async def _ensure_cdp() -> Any:
|
||||
if cdp_session_holder[0] is None:
|
||||
try:
|
||||
cdp_session_holder[0] = await stealth.get_cdp_session()
|
||||
except Exception:
|
||||
logger.debug("Could not create async CDP session")
|
||||
return cdp_session_holder[0]
|
||||
|
||||
raw_mouse: AsyncRawMouse = type("_AsyncRawMouse", (), {
|
||||
"move": originals.mouse_move,
|
||||
"down": originals.mouse_down,
|
||||
@@ -886,6 +1140,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
async def _human_goto(url: str, **kwargs: Any) -> Any:
|
||||
response = await originals.goto(url, **kwargs)
|
||||
# Invalidate isolated world after navigation
|
||||
stealth.invalidate()
|
||||
return response
|
||||
|
||||
async def _human_click(selector: str, **kwargs: Any) -> None:
|
||||
@@ -940,7 +1196,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
await async_sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
await _human_click(selector)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
await async_human_type(page, raw_keyboard, text, cfg)
|
||||
cdp = await _ensure_cdp()
|
||||
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
|
||||
|
||||
async def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
|
||||
await async_sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
@@ -950,7 +1207,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
await async_sleep_ms(rand(30, 80))
|
||||
await originals.keyboard_press("Backspace")
|
||||
await async_sleep_ms(rand(50, 150))
|
||||
await async_human_type(page, raw_keyboard, value, cfg)
|
||||
cdp = await _ensure_cdp()
|
||||
await async_human_type(page, raw_keyboard, value, cfg, cdp_session=cdp)
|
||||
|
||||
async def _human_check(selector: str, **kwargs: Any) -> None:
|
||||
try:
|
||||
@@ -988,7 +1246,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
await async_human_click(raw_mouse, False, cfg)
|
||||
|
||||
async def _human_keyboard_type(text: str, **kwargs: Any) -> None:
|
||||
await async_human_type(page, raw_keyboard, text, cfg)
|
||||
cdp = await _ensure_cdp()
|
||||
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
|
||||
|
||||
page.goto = _human_goto
|
||||
page.click = _human_click
|
||||
@@ -1024,6 +1283,10 @@ def _patch_frames_async(
|
||||
|
||||
async def _frame_aware_goto(url: str, **kwargs: Any) -> Any:
|
||||
response = await _orig_goto(url, **kwargs)
|
||||
# Invalidate isolated world after navigation
|
||||
stealth_world = getattr(page, '_stealth_world', None)
|
||||
if stealth_world is not None:
|
||||
stealth_world.invalidate()
|
||||
for frame in _iter_frames(page):
|
||||
if not getattr(frame, "_human_patched", False):
|
||||
_patch_single_frame_async(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"""cloakbrowser-human — Human-like keyboard input."""
|
||||
"""cloakbrowser-human — Human-like keyboard input.
|
||||
|
||||
Stealth-aware: when a CDP session is provided, shift symbols are typed
|
||||
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
|
||||
Falls back to page.evaluate when no CDP session is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Protocol
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, sleep_ms
|
||||
|
||||
@@ -28,6 +33,25 @@ NEARBY_KEYS = {
|
||||
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
|
||||
}
|
||||
|
||||
# CDP key code for each shift symbol's physical key.
|
||||
_SHIFT_SYMBOL_CODES: dict[str, str] = {
|
||||
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
|
||||
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
|
||||
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
|
||||
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
|
||||
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
|
||||
'?': 'Slash', '~': 'Backquote',
|
||||
}
|
||||
|
||||
# Windows virtual key codes for Input.dispatchKeyEvent.
|
||||
_SHIFT_SYMBOL_KEYCODES: dict[str, int] = {
|
||||
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
|
||||
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
|
||||
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
|
||||
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
|
||||
'~': 192,
|
||||
}
|
||||
|
||||
|
||||
def _get_nearby_key(ch: str) -> str:
|
||||
"""Return a random adjacent key for the given character."""
|
||||
@@ -39,7 +63,17 @@ def _get_nearby_key(ch: str) -> str:
|
||||
return ch
|
||||
|
||||
|
||||
def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
|
||||
def human_type(
|
||||
page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type text with human-like per-character timing.
|
||||
|
||||
Args:
|
||||
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
|
||||
producing isTrusted=true events with no evaluate stack trace.
|
||||
If None, falls back to page.evaluate (detectable).
|
||||
"""
|
||||
for i, ch in enumerate(text):
|
||||
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
||||
if not ch.isascii():
|
||||
@@ -62,7 +96,7 @@ def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None
|
||||
if ch.isupper() and ch.isalpha():
|
||||
_type_shifted_char(page, raw, ch, cfg)
|
||||
elif ch in SHIFT_SYMBOLS:
|
||||
_type_shift_symbol(page, raw, ch, cfg)
|
||||
_type_shift_symbol(page, raw, ch, cfg, cdp_session)
|
||||
else:
|
||||
_type_normal_char(raw, ch, cfg)
|
||||
|
||||
@@ -86,22 +120,65 @@ def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -
|
||||
raw.up("Shift")
|
||||
|
||||
|
||||
def _type_shift_symbol(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
raw.down("Shift")
|
||||
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
raw.insert_text(ch)
|
||||
page.evaluate(
|
||||
"""(key) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}""",
|
||||
ch,
|
||||
)
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
raw.up("Shift")
|
||||
def _type_shift_symbol(
|
||||
page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type a shift symbol character.
|
||||
|
||||
Stealth path (cdp_session provided):
|
||||
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
|
||||
|
||||
Fallback path (no cdp_session):
|
||||
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
|
||||
Detectable via isTrusted=false and evaluate stack frame.
|
||||
"""
|
||||
if cdp_session is not None:
|
||||
# --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||
code = _SHIFT_SYMBOL_CODES.get(ch, '')
|
||||
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
|
||||
|
||||
raw.down("Shift")
|
||||
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
|
||||
cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyDown",
|
||||
"modifiers": 8, # Shift modifier flag
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
"text": ch,
|
||||
"unmodifiedText": ch,
|
||||
})
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
|
||||
cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyUp",
|
||||
"modifiers": 8,
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
})
|
||||
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
raw.up("Shift")
|
||||
else:
|
||||
# --- Fallback path: page.evaluate (detectable) ---
|
||||
raw.down("Shift")
|
||||
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
raw.insert_text(ch)
|
||||
page.evaluate(
|
||||
"""(key) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}""",
|
||||
ch,
|
||||
)
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
raw.up("Shift")
|
||||
|
||||
|
||||
def _inter_char_delay(cfg: HumanConfig) -> None:
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
Mirrors keyboard.py but uses ``await`` for all Playwright calls and
|
||||
``async_sleep_ms`` instead of ``sleep_ms``.
|
||||
|
||||
Stealth-aware: when a CDP session is provided, shift symbols are typed
|
||||
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Protocol
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, async_sleep_ms
|
||||
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
|
||||
from .keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES
|
||||
|
||||
|
||||
class AsyncRawKeyboard(Protocol):
|
||||
@@ -20,7 +24,17 @@ class AsyncRawKeyboard(Protocol):
|
||||
async def insert_text(self, text: str) -> None: ...
|
||||
|
||||
|
||||
async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None:
|
||||
async def async_human_type(
|
||||
page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type text with human-like per-character timing (async).
|
||||
|
||||
Args:
|
||||
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
|
||||
producing isTrusted=true events with no evaluate stack trace.
|
||||
If None, falls back to page.evaluate (detectable).
|
||||
"""
|
||||
for i, ch in enumerate(text):
|
||||
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
||||
if not ch.isascii():
|
||||
@@ -43,7 +57,7 @@ async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: Hum
|
||||
if ch.isupper() and ch.isalpha():
|
||||
await _type_shifted_char(page, raw, ch, cfg)
|
||||
elif ch in SHIFT_SYMBOLS:
|
||||
await _type_shift_symbol(page, raw, ch, cfg)
|
||||
await _type_shift_symbol(page, raw, ch, cfg, cdp_session)
|
||||
else:
|
||||
await _type_normal_char(raw, ch, cfg)
|
||||
|
||||
@@ -67,22 +81,65 @@ async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: Hum
|
||||
await raw.up("Shift")
|
||||
|
||||
|
||||
async def _type_shift_symbol(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
await raw.down("Shift")
|
||||
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
await raw.insert_text(ch)
|
||||
await page.evaluate(
|
||||
"""(key) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}""",
|
||||
ch,
|
||||
)
|
||||
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
await raw.up("Shift")
|
||||
async def _type_shift_symbol(
|
||||
page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type a shift symbol character (async).
|
||||
|
||||
Stealth path (cdp_session provided):
|
||||
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
|
||||
|
||||
Fallback path (no cdp_session):
|
||||
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
|
||||
Detectable via isTrusted=false and evaluate stack frame.
|
||||
"""
|
||||
if cdp_session is not None:
|
||||
# --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||
code = _SHIFT_SYMBOL_CODES.get(ch, '')
|
||||
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
|
||||
|
||||
await raw.down("Shift")
|
||||
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
|
||||
await cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyDown",
|
||||
"modifiers": 8, # Shift modifier flag
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
"text": ch,
|
||||
"unmodifiedText": ch,
|
||||
})
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
|
||||
await cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyUp",
|
||||
"modifiers": 8,
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
})
|
||||
|
||||
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
await raw.up("Shift")
|
||||
else:
|
||||
# --- Fallback path: page.evaluate (detectable) ---
|
||||
await raw.down("Shift")
|
||||
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
await raw.insert_text(ch)
|
||||
await page.evaluate(
|
||||
"""(key) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}""",
|
||||
ch,
|
||||
)
|
||||
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
await raw.up("Shift")
|
||||
|
||||
|
||||
async def _inter_char_delay(cfg: HumanConfig) -> None:
|
||||
|
||||
+209
-19
@@ -4,12 +4,17 @@
|
||||
* 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 } from 'playwright-core';
|
||||
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';
|
||||
@@ -23,13 +28,148 @@ 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<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;
|
||||
}
|
||||
|
||||
async function isInputElement(page: Page, selector: string): Promise<boolean> {
|
||||
|
||||
// ============================================================================
|
||||
// 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;
|
||||
@@ -39,13 +179,37 @@ async function isInputElement(page: Page, selector: string): Promise<boolean> {
|
||||
}, selector).catch(() => false);
|
||||
}
|
||||
|
||||
async function isSelectorFocused(page: Page, selector: string): Promise<boolean> {
|
||||
/**
|
||||
* 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
|
||||
// ============================================================================
|
||||
@@ -82,6 +246,21 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
(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,
|
||||
@@ -105,11 +284,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
}
|
||||
}
|
||||
|
||||
// --- goto ---
|
||||
// --- goto (invalidate isolated world on navigation) ---
|
||||
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);
|
||||
stealth.invalidate();
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -122,7 +301,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
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 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;
|
||||
@@ -139,7 +318,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
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 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;
|
||||
@@ -169,7 +348,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
await sleep(randRange(cfg.field_switch_delay));
|
||||
await humanClickFn(selector);
|
||||
await sleep(rand(100, 250));
|
||||
await humanType(page, rawKb, text, cfg);
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
};
|
||||
|
||||
// --- fill (clears existing content first) ---
|
||||
@@ -181,12 +361,13 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
await sleep(rand(50, 150));
|
||||
await humanType(page, rawKb, value, cfg);
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, value, cfg, cdp);
|
||||
};
|
||||
|
||||
// --- clear ---
|
||||
const humanClearFn = async (selector: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
@@ -226,7 +407,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
|
||||
// --- press (checks focus first — avoids redundant mouse moves) ---
|
||||
const humanPressFn = async (selector: string, key: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
@@ -235,11 +416,12 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
|
||||
// --- pressSequentially ---
|
||||
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
await sleep(rand(100, 250));
|
||||
await humanType(page, rawKb, text, cfg);
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
};
|
||||
|
||||
// --- tap ---
|
||||
@@ -277,7 +459,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
|
||||
// --- keyboard patches ---
|
||||
page.keyboard.type = async (text: string, options?: any) => {
|
||||
await humanType(page, rawKb, text, cfg);
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
};
|
||||
|
||||
// Store helpers for frame patching
|
||||
@@ -301,7 +484,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
}).catch(() => {});
|
||||
|
||||
// --- Patch Frame-level methods (for sub-frames) ---
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals);
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
|
||||
|
||||
@@ -321,13 +504,20 @@ function patchFrames(
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: StealthEval,
|
||||
): void {
|
||||
for (const frame of iterFrames(page)) {
|
||||
patchSingleFrame(frame, page, cfg, originals);
|
||||
patchSingleFrame(frame, page, cfg, originals, stealth);
|
||||
}
|
||||
}
|
||||
|
||||
function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void {
|
||||
function patchSingleFrame(
|
||||
frame: Frame,
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
originals: any,
|
||||
stealth: StealthEval,
|
||||
): void {
|
||||
if ((frame as any)._humanPatched) return;
|
||||
(frame as any)._humanPatched = true;
|
||||
|
||||
@@ -374,7 +564,7 @@ function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals:
|
||||
};
|
||||
|
||||
(frame as any).clear = async (selector: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await (page as any).click(selector);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
|
||||
+104
-18
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* cloakbrowser-human — Human-like keyboard input.
|
||||
*
|
||||
* Stealth-aware: when a CDPSession is provided, shift symbols are typed
|
||||
* via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
|
||||
* Falls back to page.evaluate when no CDPSession is available.
|
||||
*/
|
||||
|
||||
import type { Page } from 'playwright-core';
|
||||
import type { Page, CDPSession } from 'playwright-core';
|
||||
import { RawKeyboard } from './mouse.js';
|
||||
import { HumanConfig, rand, randRange, sleep } from './config.js';
|
||||
|
||||
@@ -22,6 +26,31 @@ const NEARBY_KEYS: Record<string, string> = {
|
||||
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
|
||||
};
|
||||
|
||||
/**
|
||||
* CDP key code for each shift symbol's physical key.
|
||||
* Used by Input.dispatchKeyEvent to produce isTrusted=true events.
|
||||
*/
|
||||
const SHIFT_SYMBOL_CODES: Record<string, string> = {
|
||||
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
|
||||
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
|
||||
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
|
||||
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
|
||||
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
|
||||
'?': 'Slash', '~': 'Backquote',
|
||||
};
|
||||
|
||||
/**
|
||||
* Windows virtual key codes for shift symbols.
|
||||
* Input.dispatchKeyEvent uses these to match real keyboard behavior.
|
||||
*/
|
||||
const SHIFT_SYMBOL_KEYCODES: Record<string, number> = {
|
||||
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
|
||||
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
|
||||
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
|
||||
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
|
||||
'~': 192,
|
||||
};
|
||||
|
||||
function isAscii(ch: string): boolean {
|
||||
const code = ch.codePointAt(0);
|
||||
return code !== undefined && code < 128;
|
||||
@@ -37,11 +66,24 @@ function getNearbyKey(ch: string): string {
|
||||
return ch;
|
||||
}
|
||||
|
||||
function isUpperCase(ch: string): boolean {
|
||||
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type text with human-like per-character timing, mistype simulation,
|
||||
* and realistic shift handling.
|
||||
*
|
||||
* @param cdpSession - If provided, shift symbols use CDP Input.dispatchKeyEvent
|
||||
* producing isTrusted=true events with no evaluate stack trace.
|
||||
* If null/undefined, falls back to page.evaluate (detectable).
|
||||
*/
|
||||
export async function humanType(
|
||||
page: Page,
|
||||
raw: RawKeyboard,
|
||||
text: string,
|
||||
cfg: HumanConfig,
|
||||
cdpSession?: CDPSession | null,
|
||||
): Promise<void> {
|
||||
const chars = [...text]; // Handle emoji surrogate pairs correctly
|
||||
|
||||
@@ -72,7 +114,7 @@ export async function humanType(
|
||||
if (isUpperCase(ch)) {
|
||||
await typeShiftedChar(raw, ch, cfg);
|
||||
} else if (SHIFT_SYMBOLS.has(ch)) {
|
||||
await typeShiftSymbol(page, raw, ch, cfg);
|
||||
await typeShiftSymbol(page, raw, ch, cfg, cdpSession);
|
||||
} else {
|
||||
await typeNormalChar(raw, ch, cfg);
|
||||
}
|
||||
@@ -99,23 +141,67 @@ async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig):
|
||||
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');
|
||||
}
|
||||
/**
|
||||
* Type a shift symbol character.
|
||||
*
|
||||
* Stealth path (cdpSession provided):
|
||||
* Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
|
||||
*
|
||||
* Fallback path (no cdpSession):
|
||||
* Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
|
||||
* Detectable via isTrusted=false and evaluate stack frame.
|
||||
*/
|
||||
async function typeShiftSymbol(
|
||||
page: Page,
|
||||
raw: RawKeyboard,
|
||||
ch: string,
|
||||
cfg: HumanConfig,
|
||||
cdpSession?: CDPSession | null,
|
||||
): Promise<void> {
|
||||
if (cdpSession) {
|
||||
// --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||
const code = SHIFT_SYMBOL_CODES[ch] || '';
|
||||
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
|
||||
|
||||
function isUpperCase(ch: string): boolean {
|
||||
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
||||
await raw.down('Shift');
|
||||
await sleep(randRange(cfg.shift_down_delay));
|
||||
|
||||
await cdpSession.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
modifiers: 8, // Shift modifier flag
|
||||
key: ch,
|
||||
code,
|
||||
windowsVirtualKeyCode: keyCode,
|
||||
text: ch,
|
||||
unmodifiedText: ch,
|
||||
});
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
|
||||
await cdpSession.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
modifiers: 8,
|
||||
key: ch,
|
||||
code,
|
||||
windowsVirtualKeyCode: keyCode,
|
||||
});
|
||||
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await raw.up('Shift');
|
||||
} else {
|
||||
// --- Fallback path: page.evaluate (detectable) ---
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
async function interCharDelay(cfg: HumanConfig): Promise<void> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
# tests/test_stealth_reproduction_110.py
|
||||
"""
|
||||
Exact reproduction of issue #110 detection vectors.
|
||||
Proves all three leaks (isInputElement, isSelectorFocused, typeShiftSymbol)
|
||||
are fixed with CDP isolated worlds.
|
||||
"""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestIssue110Reproduction:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_reproduction_from_issue(self):
|
||||
"""Exact detection script from issue #110 — must produce zero detections."""
|
||||
from cloakbrowser import launch_async
|
||||
|
||||
browser = await launch_async(headless=True, humanize=True)
|
||||
page = await browser.new_page()
|
||||
|
||||
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# === EXACT detection from issue #110 ===
|
||||
await page.evaluate("""
|
||||
() => {
|
||||
window.__detections = {
|
||||
evaluateQS: [],
|
||||
untrustedKeydown: []
|
||||
};
|
||||
|
||||
// Detection 1: querySelector from evaluate context
|
||||
const origQS = document.querySelector.bind(document);
|
||||
document.querySelector = function(sel) {
|
||||
try { throw new Error(); } catch (e) {
|
||||
if (e.stack.includes(':302:')) {
|
||||
window.__detections.evaluateQS.push(sel);
|
||||
}
|
||||
}
|
||||
return origQS(sel);
|
||||
};
|
||||
|
||||
// Detection 2: untrusted keyboard events
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!e.isTrusted) {
|
||||
window.__detections.untrustedKeydown.push(e.key);
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
""")
|
||||
|
||||
# === Trigger all three vectors from issue ===
|
||||
|
||||
# Vector 1: isInputElement — click triggers querySelector check
|
||||
await page.click('#searchInput')
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# Vector 2+3: typeShiftSymbol — type text with shift symbols
|
||||
await page.keyboard.type('Hello!@#$%^&*()')
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# === Verify: zero detections ===
|
||||
detections = await page.evaluate('() => window.__detections')
|
||||
|
||||
qs_leaks = detections['evaluateQS']
|
||||
untrusted = detections['untrustedKeydown']
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Issue #110 Reproduction Results:")
|
||||
print(f" querySelector from evaluate: {len(qs_leaks)} detections")
|
||||
print(f" Untrusted keyboard events: {len(untrusted)} detections")
|
||||
print(f"{'='*60}")
|
||||
|
||||
assert len(qs_leaks) == 0, (
|
||||
f"LEAK: querySelector called from evaluate context: {qs_leaks}"
|
||||
)
|
||||
assert len(untrusted) == 0, (
|
||||
f"LEAK: Untrusted keyboard events detected: {untrusted}"
|
||||
)
|
||||
|
||||
await browser.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_21_shift_symbols_trusted(self):
|
||||
"""Every single shift symbol must produce isTrusted=true."""
|
||||
from cloakbrowser import launch_async
|
||||
|
||||
browser = await launch_async(headless=True, humanize=True)
|
||||
page = await browser.new_page()
|
||||
|
||||
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await page.evaluate("""
|
||||
() => {
|
||||
window.__keyResults = { trusted: [], untrusted: [] };
|
||||
const input = document.querySelector('#searchInput');
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const list = e.isTrusted ? 'trusted' : 'untrusted';
|
||||
window.__keyResults[list].push(e.key);
|
||||
}, true);
|
||||
}
|
||||
""")
|
||||
|
||||
await page.click('#searchInput')
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# Type ALL 21 shift symbols
|
||||
all_shift = '!@#$%^&*()_+{}|:"<>?~'
|
||||
await page.keyboard.type(all_shift)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
results = await page.evaluate('() => window.__keyResults')
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"All 21 Shift Symbols Test:")
|
||||
print(f" Trusted: {results['trusted']}")
|
||||
print(f" Untrusted: {results['untrusted']}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Every shift symbol must be trusted
|
||||
for sym in all_shift:
|
||||
assert sym in results['trusted'], f"'{sym}' NOT in trusted events"
|
||||
assert sym not in results['untrusted'], f"'{sym}' IS in untrusted events"
|
||||
|
||||
await browser.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_uses_isolated_world(self):
|
||||
"""clear() calls isSelectorFocused — must not leak evaluate."""
|
||||
from cloakbrowser import launch_async
|
||||
|
||||
browser = await launch_async(headless=True, humanize=True)
|
||||
page = await browser.new_page()
|
||||
|
||||
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await page.evaluate("""
|
||||
() => {
|
||||
window.__evalLeaks = [];
|
||||
const origQS = document.querySelector.bind(document);
|
||||
document.querySelector = function(sel) {
|
||||
try { throw new Error(); } catch (e) {
|
||||
if (e.stack.includes(':302:')) {
|
||||
window.__evalLeaks.push(sel);
|
||||
}
|
||||
}
|
||||
return origQS(sel);
|
||||
};
|
||||
}
|
||||
""")
|
||||
|
||||
# fill → click + type (isInputElement + isSelectorFocused)
|
||||
await page.locator('#searchInput').fill('some text')
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# clear → isSelectorFocused check
|
||||
await page.locator('#searchInput').clear()
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
leaks = await page.evaluate('() => window.__evalLeaks')
|
||||
assert len(leaks) == 0, f"clear() leaked via evaluate: {leaks}"
|
||||
|
||||
val = await page.locator('#searchInput').input_value()
|
||||
assert val == '', f"clear() didn't clear: '{val}'"
|
||||
|
||||
await browser.close()
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user