feat(humanize): implement CDP Isolated Worlds and trusted keyboard events (fixes #110)

This commit is contained in:
lilos
2026-04-06 01:43:46 +02:00
parent eb4efef329
commit ccda93669e
8 changed files with 3471 additions and 89 deletions
+276 -13
View File
@@ -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)
+97 -20
View File
@@ -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:
+76 -19
View File
@@ -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: