mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
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:
|
||||
|
||||
Reference in New Issue
Block a user