Files
wehub-resource-sync 0af812490d
CI / python (push) Failing after 0s
CI / javascript (push) Failing after 1s
CI / dotnet (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:12:36 +08:00

3051 lines
121 KiB
Python

"""Human-like behavioral layer for cloakbrowser.
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
import time
from typing import Any, Optional
from .config import HumanConfig, HumanPreset, resolve_config, merge_config
from .config import rand, rand_range, sleep_ms, async_sleep_ms
from .mouse import RawMouse, human_move, human_click, click_target, human_idle
from .keyboard import RawKeyboard, human_type
from .scroll import scroll_to_element, human_scroll_into_view
from .mouse_async import AsyncRawMouse, async_human_move, async_human_click, async_human_idle
from .keyboard_async import AsyncRawKeyboard, async_human_type
from .scroll_async import async_scroll_to_element, async_human_scroll_into_view
from .actionability import (
ensure_actionable, ensure_stable, check_pointer_events,
ensure_actionable_handle, check_pointer_events_handle,
ActionabilityError, ElementNotAttachedError, ElementNotVisibleError,
ElementNotStableError, ElementNotEnabledError, ElementNotEditableError,
ElementNotReceivingEventsError,
CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK,
)
from .actionability_async import (
async_ensure_actionable, async_ensure_stable, async_check_pointer_events,
async_ensure_actionable_handle, async_check_pointer_events_handle,
)
_SELECT_ALL = "Meta+a" if sys.platform == "darwin" else "Control+a"
__all__ = [
"patch_browser", "patch_context", "patch_page",
"patch_browser_async", "patch_context_async", "patch_page_async",
"HumanConfig", "resolve_config", "merge_config",
"human_move", "human_click", "click_target", "human_idle",
"human_type", "scroll_to_element", "human_scroll_into_view",
]
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")
def __init__(self) -> None:
self.x: float = 0
self.y: float = 0
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) => {
const el = document.querySelector(sel);
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
}""",
selector,
)
except Exception:
return False
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) => {
const el = document.querySelector(sel);
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
}""",
selector,
)
except Exception:
return False
def _is_selector_focused(page: Any, selector: str) -> bool:
"""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) => {
const el = document.querySelector(sel);
return el === document.activeElement;
}""",
selector,
)
except Exception:
return False
async def _async_is_selector_focused(page: Any, selector: str) -> bool:
"""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) => {
const el = document.querySelector(sel);
return el === document.activeElement;
}""",
selector,
)
except Exception:
return False
# ============================================================================
# Locator class-level patching (sync)
# ============================================================================
_locator_sync_patched = False
def _patch_locator_class_sync():
"""Patch all Locator interaction methods to go through humanized page methods."""
global _locator_sync_patched
if _locator_sync_patched:
return
_locator_sync_patched = True
from playwright.sync_api._generated import Locator
_orig_fill = Locator.fill
_orig_click = Locator.click
_orig_type = Locator.type
_orig_dblclick = Locator.dblclick
_orig_hover = Locator.hover
_orig_check = Locator.check
_orig_uncheck = Locator.uncheck
_orig_set_checked = Locator.set_checked
_orig_select_option = Locator.select_option
_orig_press = Locator.press
_orig_press_sequentially = Locator.press_sequentially
_orig_tap = Locator.tap
_orig_drag_to = Locator.drag_to
_orig_clear = Locator.clear
_orig_scroll_into_view = getattr(Locator, 'scroll_into_view_if_needed', None)
def _get_selector(self):
return self._impl_obj._selector
def _is_humanized(self):
return hasattr(self.page, '_original')
def _get_cfg(self):
return getattr(self.page, '_human_cfg', None)
def _route_target(self):
"""Resolution target for a humanized locator action (#428).
Main-frame locators return the Page (unchanged behavior). A locator that
belongs to a *sub-frame* returns that frame's humanized wrapper so the
selector resolves in the frame's own document. Returns ``None`` when the
owning sub-frame is known but not yet humanized, so the caller falls back
to the native Playwright locator method (frame-correct, un-humanized).
"""
page = self.page
impl_frame = getattr(getattr(self, "_impl_obj", None), "_frame", None)
if impl_frame is None:
return page
try:
if impl_frame is page.main_frame._impl_obj:
return page
for f in page.frames:
if getattr(f, "_impl_obj", None) is impl_frame:
return f if getattr(f, "_human_patched", False) else None
# Frame not among page.frames (unknown/detached) -> legacy Page path.
return page
except Exception:
return page
def _forward_kwargs(kwargs):
out = {}
if "timeout" in kwargs:
out["timeout"] = kwargs["timeout"]
if "human_config" in kwargs:
out["human_config"] = kwargs["human_config"]
if "force" in kwargs:
out["force"] = kwargs["force"]
return out
def _humanized_fill(self, value, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_fill(self, value, **kwargs)
else:
tgt.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
else:
_orig_fill(self, value, **kwargs)
def _humanized_click(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_click(self, **kwargs)
else:
tgt.click(_get_selector(self), **_forward_kwargs(kwargs))
else:
_orig_click(self, **kwargs)
def _humanized_type(self, text, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_type(self, text, **kwargs)
else:
tgt.type(_get_selector(self), text, **_forward_kwargs(kwargs))
else:
_orig_type(self, text, **kwargs)
def _humanized_dblclick(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_dblclick(self, **kwargs)
else:
tgt.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
else:
_orig_dblclick(self, **kwargs)
def _humanized_hover(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_hover(self, **kwargs)
else:
tgt.hover(_get_selector(self), **_forward_kwargs(kwargs))
else:
_orig_hover(self, **kwargs)
def _humanized_scroll_into_view_if_needed(self, **kwargs):
if _is_humanized(self):
page = self.page
cfg = _get_cfg(self)
cursor = getattr(page, '_human_cursor', None)
raw = getattr(page, '_human_raw_mouse', None)
call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None
if call_cfg is None or cursor is None or raw is None:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
return _orig_scroll_into_view(self, **native_kwargs)
return
timeout = kwargs.get("timeout", 30000)
try:
_, nx, ny, _ = human_scroll_into_view(
page, raw,
lambda: self.bounding_box(timeout=timeout),
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
_orig_scroll_into_view(self, **native_kwargs)
elif _orig_scroll_into_view is not None:
_orig_scroll_into_view(self, **kwargs)
def _humanized_check(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_check(self, **kwargs)
return
fwd = _forward_kwargs(kwargs)
cfg = _get_cfg(self)
if cfg and cfg.idle_between_actions:
raw = type("_R", (), {"move": self.page._original.mouse_move})()
human_idle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), 0, 0, cfg)
checked = self.is_checked()
if not checked:
tgt.click(_get_selector(self), **fwd)
else:
_orig_check(self, **kwargs)
def _humanized_uncheck(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_uncheck(self, **kwargs)
return
fwd = _forward_kwargs(kwargs)
cfg = _get_cfg(self)
if cfg and cfg.idle_between_actions:
raw = type("_R", (), {"move": self.page._original.mouse_move})()
human_idle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), 0, 0, cfg)
checked = self.is_checked()
if checked:
tgt.click(_get_selector(self), **fwd)
else:
_orig_uncheck(self, **kwargs)
def _humanized_set_checked(self, checked, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_set_checked(self, checked, **kwargs)
return
fwd = _forward_kwargs(kwargs)
current = self.is_checked()
if current != checked:
tgt.click(_get_selector(self), **fwd)
else:
_orig_set_checked(self, checked, **kwargs)
def _humanized_select_option(self, value=None, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_select_option(self, value, **kwargs)
return
fwd = _forward_kwargs(kwargs)
selector = _get_selector(self)
tgt.hover(selector, **fwd)
sleep_ms(rand(100, 300))
_orig_select_option(self, value, **kwargs)
else:
_orig_select_option(self, value, **kwargs)
def _humanized_press(self, key, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_press(self, key, **kwargs)
return
fwd = _forward_kwargs(kwargs)
selector = _get_selector(self)
if not _is_selector_focused(tgt, selector):
tgt.click(selector, **fwd)
sleep_ms(rand(50, 150))
self.page.keyboard.press(key)
else:
_orig_press(self, key, **kwargs)
def _humanized_press_sequentially(self, text, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_press_sequentially(self, text, **kwargs)
return
fwd = _forward_kwargs(kwargs)
selector = _get_selector(self)
if not _is_selector_focused(tgt, selector):
tgt.click(selector, **fwd)
sleep_ms(rand(50, 150))
self.page.keyboard.type(text)
else:
_orig_press_sequentially(self, text, **kwargs)
def _humanized_tap(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_tap(self, **kwargs)
else:
tgt.click(_get_selector(self), **_forward_kwargs(kwargs))
else:
_orig_tap(self, **kwargs)
def _humanized_drag_to(self, target, **kwargs):
if _is_humanized(self):
page = self.page
originals = getattr(page, '_original', None)
src_box = self.bounding_box()
tgt_box = target.bounding_box()
if src_box and tgt_box and originals:
sx = src_box['x'] + src_box['width'] / 2
sy = src_box['y'] + src_box['height'] / 2
tx = tgt_box['x'] + tgt_box['width'] / 2
ty = tgt_box['y'] + tgt_box['height'] / 2
page.mouse.move(sx, sy)
sleep_ms(rand(100, 200))
originals.mouse_down()
sleep_ms(rand(80, 150))
page.mouse.move(tx, ty)
sleep_ms(rand(80, 150))
originals.mouse_up()
else:
_orig_drag_to(self, target, **kwargs)
else:
_orig_drag_to(self, target, **kwargs)
def _humanized_clear(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
_orig_clear(self, **kwargs)
return
fwd = _forward_kwargs(kwargs)
selector = _get_selector(self)
if not _is_selector_focused(tgt, selector):
tgt.click(selector, **fwd)
sleep_ms(rand(50, 100))
self.page.keyboard.press(_SELECT_ALL)
sleep_ms(rand(30, 80))
self.page.keyboard.press("Backspace")
else:
_orig_clear(self, **kwargs)
Locator.fill = _humanized_fill
Locator.click = _humanized_click
Locator.type = _humanized_type
Locator.dblclick = _humanized_dblclick
Locator.hover = _humanized_hover
Locator.check = _humanized_check
Locator.uncheck = _humanized_uncheck
Locator.set_checked = _humanized_set_checked
Locator.select_option = _humanized_select_option
Locator.press = _humanized_press
Locator.press_sequentially = _humanized_press_sequentially
Locator.tap = _humanized_tap
Locator.drag_to = _humanized_drag_to
Locator.clear = _humanized_clear
if _orig_scroll_into_view is not None:
Locator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed
# ============================================================================
# Locator class-level patching (async)
# ============================================================================
_locator_async_patched = False
def _patch_locator_class_async():
"""Patch all async Locator interaction methods to go through humanized page methods."""
global _locator_async_patched
if _locator_async_patched:
return
_locator_async_patched = True
from playwright.async_api._generated import Locator as AsyncLocator
_orig_fill = AsyncLocator.fill
_orig_click = AsyncLocator.click
_orig_type = AsyncLocator.type
_orig_dblclick = AsyncLocator.dblclick
_orig_hover = AsyncLocator.hover
_orig_check = AsyncLocator.check
_orig_uncheck = AsyncLocator.uncheck
_orig_set_checked = AsyncLocator.set_checked
_orig_select_option = AsyncLocator.select_option
_orig_press = AsyncLocator.press
_orig_press_sequentially = AsyncLocator.press_sequentially
_orig_tap = AsyncLocator.tap
_orig_drag_to = AsyncLocator.drag_to
_orig_clear = AsyncLocator.clear
_orig_scroll_into_view = getattr(AsyncLocator, 'scroll_into_view_if_needed', None)
def _get_selector(self):
return self._impl_obj._selector
def _is_humanized(self):
return hasattr(self.page, '_original')
def _get_cfg(self):
return getattr(self.page, '_human_cfg', None)
def _route_target(self):
"""Resolution target for a humanized locator action (#428).
Main-frame locators return the Page (unchanged behavior). A locator that
belongs to a *sub-frame* returns that frame's humanized wrapper so the
selector resolves in the frame's own document. Returns ``None`` when the
owning sub-frame is known but not yet humanized, so the caller falls back
to the native Playwright locator method (frame-correct, un-humanized).
"""
page = self.page
impl_frame = getattr(getattr(self, "_impl_obj", None), "_frame", None)
if impl_frame is None:
return page
try:
if impl_frame is page.main_frame._impl_obj:
return page
for f in page.frames:
if getattr(f, "_impl_obj", None) is impl_frame:
return f if getattr(f, "_human_patched", False) else None
# Frame not among page.frames (unknown/detached) -> legacy Page path.
return page
except Exception:
return page
def _forward_kwargs(kwargs):
out = {}
if "timeout" in kwargs:
out["timeout"] = kwargs["timeout"]
if "human_config" in kwargs:
out["human_config"] = kwargs["human_config"]
if "force" in kwargs:
out["force"] = kwargs["force"]
return out
async def _humanized_fill(self, value, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_fill(self, value, **kwargs)
else:
await tgt.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
else:
await _orig_fill(self, value, **kwargs)
async def _humanized_click(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_click(self, **kwargs)
else:
await tgt.click(_get_selector(self), **_forward_kwargs(kwargs))
else:
await _orig_click(self, **kwargs)
async def _humanized_type(self, text, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_type(self, text, **kwargs)
else:
await tgt.type(_get_selector(self), text, **_forward_kwargs(kwargs))
else:
await _orig_type(self, text, **kwargs)
async def _humanized_dblclick(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_dblclick(self, **kwargs)
else:
await tgt.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
else:
await _orig_dblclick(self, **kwargs)
async def _humanized_hover(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_hover(self, **kwargs)
else:
await tgt.hover(_get_selector(self), **_forward_kwargs(kwargs))
else:
await _orig_hover(self, **kwargs)
async def _humanized_scroll_into_view_if_needed(self, **kwargs):
if _is_humanized(self):
page = self.page
cfg = _get_cfg(self)
cursor = getattr(page, '_human_cursor', None)
raw = getattr(page, '_human_raw_mouse', None)
call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None
if call_cfg is None or cursor is None or raw is None:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
await _orig_scroll_into_view(self, **native_kwargs)
return
timeout = kwargs.get("timeout", 30000)
async def _get_box():
return await self.bounding_box(timeout=timeout)
try:
_, nx, ny, _ = await async_human_scroll_into_view(
page, raw, _get_box,
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
await _orig_scroll_into_view(self, **native_kwargs)
elif _orig_scroll_into_view is not None:
await _orig_scroll_into_view(self, **kwargs)
async def _humanized_check(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_check(self, **kwargs)
return
fwd = _forward_kwargs(kwargs)
cfg = _get_cfg(self)
if cfg and cfg.idle_between_actions:
raw = type("_R", (), {"move": self.page._original.mouse_move})()
await async_human_idle(
raw,
rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]),
0, 0, cfg,
)
checked = await self.is_checked()
if not checked:
await tgt.click(_get_selector(self), **fwd)
else:
await _orig_check(self, **kwargs)
async def _humanized_uncheck(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_uncheck(self, **kwargs)
return
fwd = _forward_kwargs(kwargs)
cfg = _get_cfg(self)
if cfg and cfg.idle_between_actions:
raw = type("_R", (), {"move": self.page._original.mouse_move})()
await async_human_idle(
raw,
rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]),
0, 0, cfg,
)
checked = await self.is_checked()
if checked:
await tgt.click(_get_selector(self), **fwd)
else:
await _orig_uncheck(self, **kwargs)
async def _humanized_set_checked(self, checked, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_set_checked(self, checked, **kwargs)
return
fwd = _forward_kwargs(kwargs)
current = await self.is_checked()
if current != checked:
await tgt.click(_get_selector(self), **fwd)
else:
await _orig_set_checked(self, checked, **kwargs)
async def _humanized_select_option(self, value=None, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_select_option(self, value, **kwargs)
return
fwd = _forward_kwargs(kwargs)
selector = _get_selector(self)
await tgt.hover(selector, **fwd)
await async_sleep_ms(rand(100, 300))
await _orig_select_option(self, value, **kwargs)
else:
await _orig_select_option(self, value, **kwargs)
async def _humanized_press(self, key, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_press(self, key, **kwargs)
return
fwd = _forward_kwargs(kwargs)
selector = _get_selector(self)
if not await _async_is_selector_focused(tgt, selector):
await tgt.click(selector, **fwd)
await async_sleep_ms(rand(50, 150))
await self.page.keyboard.press(key)
else:
await _orig_press(self, key, **kwargs)
async def _humanized_press_sequentially(self, text, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_press_sequentially(self, text, **kwargs)
return
fwd = _forward_kwargs(kwargs)
selector = _get_selector(self)
if not await _async_is_selector_focused(tgt, selector):
await tgt.click(selector, **fwd)
await async_sleep_ms(rand(50, 150))
await self.page.keyboard.type(text)
else:
await _orig_press_sequentially(self, text, **kwargs)
async def _humanized_tap(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_tap(self, **kwargs)
else:
await tgt.click(_get_selector(self), **_forward_kwargs(kwargs))
else:
await _orig_tap(self, **kwargs)
async def _humanized_drag_to(self, target, **kwargs):
if _is_humanized(self):
page = self.page
originals = getattr(page, '_original', None)
src_box = await self.bounding_box()
tgt_box = await target.bounding_box()
if src_box and tgt_box and originals:
sx = src_box['x'] + src_box['width'] / 2
sy = src_box['y'] + src_box['height'] / 2
tx = tgt_box['x'] + tgt_box['width'] / 2
ty = tgt_box['y'] + tgt_box['height'] / 2
await page.mouse.move(sx, sy)
await async_sleep_ms(rand(100, 200))
await originals.mouse_down()
await async_sleep_ms(rand(80, 150))
await page.mouse.move(tx, ty)
await async_sleep_ms(rand(80, 150))
await originals.mouse_up()
else:
await _orig_drag_to(self, target, **kwargs)
else:
await _orig_drag_to(self, target, **kwargs)
async def _humanized_clear(self, **kwargs):
if _is_humanized(self):
tgt = _route_target(self)
if tgt is None:
await _orig_clear(self, **kwargs)
return
fwd = _forward_kwargs(kwargs)
selector = _get_selector(self)
if not await _async_is_selector_focused(tgt, selector):
await tgt.click(selector, **fwd)
await async_sleep_ms(rand(50, 100))
await self.page.keyboard.press(_SELECT_ALL)
await async_sleep_ms(rand(30, 80))
await self.page.keyboard.press("Backspace")
else:
await _orig_clear(self, **kwargs)
AsyncLocator.fill = _humanized_fill
AsyncLocator.click = _humanized_click
AsyncLocator.type = _humanized_type
AsyncLocator.dblclick = _humanized_dblclick
AsyncLocator.hover = _humanized_hover
AsyncLocator.check = _humanized_check
AsyncLocator.uncheck = _humanized_uncheck
AsyncLocator.set_checked = _humanized_set_checked
AsyncLocator.select_option = _humanized_select_option
AsyncLocator.press = _humanized_press
AsyncLocator.press_sequentially = _humanized_press_sequentially
AsyncLocator.tap = _humanized_tap
AsyncLocator.drag_to = _humanized_drag_to
AsyncLocator.clear = _humanized_clear
if _orig_scroll_into_view is not None:
AsyncLocator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed
# ============================================================================
# SYNC patching
# ============================================================================
def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
"""Replace page methods with human-like implementations (sync)."""
originals = type("Originals", (), {
"click": page.click,
"type": page.type,
"fill": page.fill,
"goto": page.goto,
"hover": page.hover,
"dblclick": page.dblclick,
"select_option": page.select_option,
"mouse_move": page.mouse.move,
"mouse_click": page.mouse.click,
"mouse_wheel": page.mouse.wheel,
"mouse_down": page.mouse.down,
"mouse_up": page.mouse.up,
"keyboard_type": page.keyboard.type,
"keyboard_down": page.keyboard.down,
"keyboard_up": page.keyboard.up,
"keyboard_press": page.keyboard.press,
"keyboard_insert_text": page.keyboard.insert_text,
})()
page._original = originals
page._human_cfg = cfg
page._human_cursor = cursor
# --- 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,
"up": originals.mouse_up,
"wheel": originals.mouse_wheel,
})()
raw_keyboard: RawKeyboard = type("_RawKeyboard", (), {
"down": originals.keyboard_down,
"up": originals.keyboard_up,
"type": originals.keyboard_type,
"insert_text": originals.keyboard_insert_text,
})()
page._human_raw_mouse = raw_mouse
def _ensure_cursor_init() -> None:
if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
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:
_ensure_cursor_init()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
force = kwargs.get("force", False)
skip_checks = kwargs.pop("_skip_checks", False)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force and not skip_checks:
ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
if call_cfg.idle_between_actions:
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy, did_scroll = scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
is_input = _is_input_element(page, selector)
if not force and did_scroll:
ensure_stable(page, selector, timeout=_remaining_ms())
box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
target = click_target(box, is_input, call_cfg)
if not force:
check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
human_click(raw_mouse, is_input, call_cfg)
def _human_dblclick(selector: str, **kwargs: Any) -> None:
_ensure_cursor_init()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
force = kwargs.get("force", False)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
if call_cfg.idle_between_actions:
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy, did_scroll = scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
is_input = _is_input_element(page, selector)
if not force and did_scroll:
ensure_stable(page, selector, timeout=_remaining_ms())
box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
target = click_target(box, is_input, call_cfg)
if not force:
check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
raw_mouse.down(click_count=2)
sleep_ms(rand(30, 60))
raw_mouse.up(click_count=2)
def _human_hover(selector: str, **kwargs: Any) -> None:
_ensure_cursor_init()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
force = kwargs.get("force", False)
skip_checks = kwargs.pop("_skip_checks", False)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force and not skip_checks:
ensure_actionable(page, selector, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
if call_cfg.idle_between_actions:
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy, did_scroll = scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
if not force and did_scroll:
ensure_stable(page, selector, timeout=_remaining_ms())
box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
target = click_target(box, False, call_cfg)
if not force:
check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
def _human_type(selector: str, text: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
force = kwargs.get("force", False)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
sleep_ms(rand_range(call_cfg.field_switch_delay))
_human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
sleep_ms(rand(100, 250))
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
force = kwargs.get("force", False)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
sleep_ms(rand_range(call_cfg.field_switch_delay))
_human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
sleep_ms(rand(100, 250))
originals.keyboard_press(_SELECT_ALL)
sleep_ms(rand(30, 80))
originals.keyboard_press("Backspace")
sleep_ms(rand(50, 150))
human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)
def _human_check(selector: str, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
checked = page.is_checked(selector)
except Exception:
checked = False
if not checked:
_human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
def _human_uncheck(selector: str, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
checked = page.is_checked(selector)
except Exception:
checked = True
if checked:
_human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
def _human_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
_human_hover(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
sleep_ms(rand(100, 300))
pw_kwargs = {k: v for k, v in kwargs.items() if k not in ("human_config", "force")}
return originals.select_option(selector, value, **pw_kwargs)
def _human_press(selector: str, key: str, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
if not _is_selector_focused(page, selector):
_human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
sleep_ms(rand(50, 150))
originals.keyboard_press(key)
def _human_mouse_move(x: float, y: float, **kwargs: Any) -> None:
_ensure_cursor_init()
human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg)
cursor.x = x
cursor.y = y
def _human_mouse_click(x: float, y: float, **kwargs: Any) -> None:
_ensure_cursor_init()
human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg)
cursor.x = x
cursor.y = y
human_click(raw_mouse, False, cfg)
def _human_keyboard_type(text: str, **kwargs: Any) -> None:
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
page.goto = _human_goto
page.click = _human_click
page.dblclick = _human_dblclick
page.hover = _human_hover
page.type = _human_type
page.fill = _human_fill
page.check = _human_check
page.uncheck = _human_uncheck
page.select_option = _human_select_option
page.press = _human_press
page.mouse.move = _human_mouse_move
page.mouse.click = _human_mouse_click
page.keyboard.type = _human_keyboard_type
# --- Patch Frame-level methods (for sub-frames) ---
_patch_frames_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals)
# --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) ---
_patch_page_element_handles_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session)
# 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])
try:
originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
except Exception:
pass
# --- Patch Locator class (class-level, runs once) ---
_patch_locator_class_sync()
# ============================================================================
# SYNC ElementHandle patching
# ============================================================================
def _is_input_element_handle_sync(el: Any) -> bool:
"""Check if an ElementHandle is an input/textarea/contenteditable (sync)."""
try:
return el.evaluate(
"""(node) => {
const tag = node.tagName ? node.tagName.toLowerCase() : '';
return tag === 'input' || tag === 'textarea'
|| node.getAttribute && node.getAttribute('contenteditable') === 'true';
}"""
)
except Exception:
return False
def _patch_single_element_handle_sync(
el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
stealth: Any, cdp_session: Any,
) -> None:
"""Patch all interaction methods on a sync Playwright ElementHandle."""
if getattr(el, '_human_patched', False):
return
el._human_patched = True
# Save originals
_orig_click = el.click
_orig_dblclick = el.dblclick
_orig_hover = el.hover
_orig_type = el.type
_orig_fill = el.fill
_orig_press = el.press
_orig_select_option = el.select_option
_orig_check = el.check
_orig_uncheck = el.uncheck
_orig_set_checked = getattr(el, 'set_checked', None)
_orig_tap = el.tap
_orig_focus = el.focus
_orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)
# Nested selectors
_orig_qs = el.query_selector
_orig_qsa = el.query_selector_all
_orig_wfs = el.wait_for_selector
def _patched_qs(selector: str, **kwargs: Any) -> Any:
child = _orig_qs(selector, **kwargs)
if child is not None:
_patch_single_element_handle_sync(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return child
def _patched_qsa(selector: str, **kwargs: Any) -> Any:
children = _orig_qsa(selector, **kwargs)
for child in children:
_patch_single_element_handle_sync(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return children
def _patched_wfs(selector: str, **kwargs: Any) -> Any:
child = _orig_wfs(selector, **kwargs)
if child is not None:
_patch_single_element_handle_sync(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return child
el.query_selector = _patched_qs
el.query_selector_all = _patched_qsa
el.wait_for_selector = _patched_wfs
# Helper: move cursor to element. Accepts optional ``call_cfg`` so per-call
# ``human_config`` overrides on type/fill carry through to mouse timing.
# Also scrolls into view first so off-screen elements don't silently fall
# back to the unpatched native method (#129, #172 follow-up).
def _move_to_element(call_cfg: HumanConfig = cfg):
if not cursor.initialized:
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
# Scroll into view first — best-effort. If the element can't be located
# we fall through to bounding_box() below which returns None and lets
# the caller fall back to the original Playwright method.
try:
_, nx, ny, _ = human_scroll_into_view(
page, raw_mouse, lambda: el.bounding_box(),
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
pass
box = el.bounding_box()
if not box:
return None
is_inp = _is_input_element_handle_sync(el)
target = click_target(box, is_inp, call_cfg)
if call_cfg.idle_between_actions:
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
return {'box': box, 'is_inp': is_inp}
# --- el.click() ---
def _human_el_click(**kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_click(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], call_cfg)
# --- el.dblclick() ---
def _human_el_dblclick(**kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_dblclick(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
raw_mouse.down(click_count=2)
sleep_ms(rand(30, 60))
raw_mouse.up(click_count=2)
# --- el.hover() ---
def _human_el_hover(**kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_hover(**kwargs)
# --- el.type() ---
def _human_el_type(text: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_type(text, **kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], call_cfg)
sleep_ms(rand(100, 250))
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
# --- el.fill() ---
def _human_el_fill(value: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_fill(value, **kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], call_cfg)
sleep_ms(rand(100, 250))
originals.keyboard_press(_SELECT_ALL)
sleep_ms(rand(30, 80))
originals.keyboard_press("Backspace")
sleep_ms(rand(50, 150))
human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)
# --- el.scroll_into_view_if_needed() ---
# Playwright's native version snaps the page — a strong bot signal.
# Replace with the same accelerate → cruise → decelerate → overshoot wheel
# sequence used by page.click(). Falls back to the native method if the
# element is detached or scrolling fails.
def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
if not cursor.initialized:
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
try:
originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
except Exception:
pass
try:
_, nx, ny, _ = human_scroll_into_view(
page, raw_mouse, lambda: el.bounding_box(),
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
_orig_scroll_into_view(**native_kwargs)
# --- el.press() ---
def _human_el_press(key: str, **kwargs: Any) -> None:
sleep_ms(rand(20, 60))
originals.keyboard_down(key)
sleep_ms(rand_range(cfg.key_hold))
originals.keyboard_up(key)
# --- el.select_option() ---
def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
info = _move_to_element()
if info is None:
return _orig_select_option(value, **kwargs)
human_click(raw_mouse, False, cfg)
sleep_ms(rand(100, 300))
return _orig_select_option(value, **kwargs)
# --- el.check() ---
def _human_el_check(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
if el.is_checked():
return
except Exception:
pass
info = _move_to_element()
if info is None:
return _orig_check(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.uncheck() ---
def _human_el_uncheck(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
if not el.is_checked():
return
except Exception:
pass
info = _move_to_element()
if info is None:
return _orig_uncheck(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.set_checked() ---
def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
current = el.is_checked()
if current == checked:
return
except Exception:
pass
info = _move_to_element()
if info is None and _orig_set_checked:
return _orig_set_checked(checked, **kwargs)
if info:
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.tap() ---
def _human_el_tap(**kwargs: Any) -> None:
info = _move_to_element()
if info is None:
return _orig_tap(**kwargs)
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.focus() ---
# FIX: move cursor humanly but use programmatic focus (no click side-effects).
# Stock Playwright el.focus() never clicks — it just calls element.focus() in JS.
# Clicking would trigger onclick, submit forms, navigate links, etc.
def _human_el_focus() -> None:
_move_to_element() # human-like cursor movement (Bézier)
_orig_focus() # programmatic focus, no click side-effects
el.click = _human_el_click
el.dblclick = _human_el_dblclick
el.hover = _human_el_hover
el.type = _human_el_type
el.fill = _human_el_fill
el.press = _human_el_press
el.select_option = _human_el_select_option
el.check = _human_el_check
el.uncheck = _human_el_uncheck
if _orig_set_checked is not None:
el.set_checked = _human_el_set_checked
el.tap = _human_el_tap
el.focus = _human_el_focus
if _orig_scroll_into_view is not None:
el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed
def _patch_page_element_handles_sync(
page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
stealth: Any, cdp_session: Any,
) -> None:
"""Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (sync)."""
_orig_qs = page.query_selector
_orig_qsa = page.query_selector_all
_orig_wfs = page.wait_for_selector
def _patched_qs(selector: str, **kwargs: Any) -> Any:
el = _orig_qs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return el
def _patched_qsa(selector: str, **kwargs: Any) -> Any:
els = _orig_qsa(selector, **kwargs)
for el in els:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return els
def _patched_wfs(selector: str, **kwargs: Any) -> Any:
el = _orig_wfs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return el
page.query_selector = _patched_qs
page.query_selector_all = _patched_qsa
page.wait_for_selector = _patched_wfs
def _patch_frames_sync(
page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
) -> None:
for frame in _iter_frames(page):
_patch_single_frame_sync(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
orig_main_frame = getattr(page, "_original_main_frame", None)
if orig_main_frame is None:
try:
_orig_goto = originals.goto
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)
return response
page.goto = _frame_aware_goto
page._original_main_frame = True
except Exception:
pass
def _patch_single_frame_sync(
frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
) -> None:
if getattr(frame, "_human_patched", False):
return
frame._human_patched = True
# Frame-scoped resolution: humanize via ``frame.locator(selector)`` so the
# selector resolves in the sub-frame's own document, while the mouse stays
# page-level (bounding_box() is already page/viewport-space). Mirrors the JS
# wrapper's patchSingleFrame/moveToFrameSelector. #428.
_orig_frame_click = frame.click
_orig_frame_dblclick = frame.dblclick
_orig_frame_hover = frame.hover
_orig_frame_type = frame.type
_orig_frame_fill = frame.fill
_orig_frame_check = frame.check
_orig_frame_uncheck = frame.uncheck
_orig_frame_select_option = frame.select_option
_orig_frame_press = frame.press
_orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None)
stealth_world = getattr(page, '_stealth_world', None)
cdp_session = None
if stealth_world is not None:
try:
cdp_session = stealth_world.get_cdp_session()
except Exception:
pass
def _native_kwargs(kwargs: dict) -> dict:
# Native Playwright frame methods don't accept our ``human_config`` kwarg.
return {k: v for k, v in kwargs.items() if k != "human_config"}
def _deadline_remaining(kwargs: dict):
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
return lambda: max(0.0, (deadline - time.monotonic()) * 1000)
def _frame_is_input(selector: str) -> bool:
try:
return bool(frame.locator(selector).first.evaluate(
"el => { const t = el.tagName.toLowerCase();"
" return t === 'input' || t === 'textarea'"
" || el.getAttribute('contenteditable') === 'true'; }"
))
except Exception:
return False
def _frame_is_focused(selector: str) -> bool:
try:
return bool(frame.locator(selector).first.evaluate(
"el => el === document.activeElement"
))
except Exception:
return False
def _move_to_frame_selector(selector: str, kwargs: dict, input_bias: bool, remaining_ms):
call_cfg = merge_config(cfg, kwargs.get("human_config"))
if call_cfg.idle_between_actions:
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
loc = frame.locator(selector).first
try:
loc.scroll_into_view_if_needed(timeout=max(1, remaining_ms()))
except Exception:
pass
try:
box = loc.bounding_box(timeout=max(1, remaining_ms()))
except Exception:
box = None
if not box:
return None
is_input = input_bias or _frame_is_input(selector)
target = click_target(box, is_input, call_cfg)
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
return call_cfg, is_input
def _frame_click(selector: str, **kwargs: Any) -> None:
remaining_ms = _deadline_remaining(kwargs)
moved = _move_to_frame_selector(selector, kwargs, False, remaining_ms)
if moved is None:
_orig_frame_click(selector, **_native_kwargs(kwargs))
return
human_click(raw_mouse, moved[1], moved[0])
def _frame_dblclick(selector: str, **kwargs: Any) -> None:
remaining_ms = _deadline_remaining(kwargs)
moved = _move_to_frame_selector(selector, kwargs, False, remaining_ms)
if moved is None:
_orig_frame_dblclick(selector, **_native_kwargs(kwargs))
return
raw_mouse.down(click_count=2)
sleep_ms(rand(30, 60))
raw_mouse.up(click_count=2)
def _frame_hover(selector: str, **kwargs: Any) -> None:
remaining_ms = _deadline_remaining(kwargs)
moved = _move_to_frame_selector(selector, kwargs, False, remaining_ms)
if moved is None:
_orig_frame_hover(selector, **_native_kwargs(kwargs))
def _frame_type(selector: str, text: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
sleep_ms(rand_range(call_cfg.field_switch_delay))
_frame_click(selector, **kwargs)
sleep_ms(rand(100, 250))
try:
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
except Exception:
_orig_frame_type(selector, text, **_native_kwargs(kwargs))
def _frame_fill(selector: str, value: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
sleep_ms(rand_range(call_cfg.field_switch_delay))
_frame_click(selector, **kwargs)
sleep_ms(rand(100, 250))
originals.keyboard_press(_SELECT_ALL)
sleep_ms(rand(30, 80))
originals.keyboard_press("Backspace")
sleep_ms(rand(50, 150))
try:
human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)
except Exception:
_orig_frame_fill(selector, value, **_native_kwargs(kwargs))
def _frame_check(selector: str, **kwargs: Any) -> None:
try:
checked = frame.locator(selector).first.is_checked()
except Exception:
_orig_frame_check(selector, **_native_kwargs(kwargs))
return
if not checked:
try:
_frame_click(selector, **kwargs)
except Exception:
_orig_frame_check(selector, **_native_kwargs(kwargs))
def _frame_uncheck(selector: str, **kwargs: Any) -> None:
try:
checked = frame.locator(selector).first.is_checked()
except Exception:
_orig_frame_uncheck(selector, **_native_kwargs(kwargs))
return
if checked:
try:
_frame_click(selector, **kwargs)
except Exception:
_orig_frame_uncheck(selector, **_native_kwargs(kwargs))
def _frame_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any:
_frame_hover(selector, **kwargs)
sleep_ms(rand(100, 300))
return _orig_frame_select_option(selector, value, **_native_kwargs(kwargs))
def _frame_press(selector: str, key: str, **kwargs: Any) -> None:
if not _frame_is_focused(selector):
_frame_click(selector, **kwargs)
sleep_ms(rand(50, 150))
originals.keyboard_press(key)
def _frame_clear(selector: str, **kwargs: Any) -> None:
if not _frame_is_focused(selector):
_frame_click(selector, **kwargs)
sleep_ms(rand(50, 100))
originals.keyboard_press(_SELECT_ALL)
sleep_ms(rand(30, 80))
originals.keyboard_press("Backspace")
def _frame_drag_and_drop(source: str, target: str, **kwargs: Any) -> None:
try:
src_box = frame.locator(source).bounding_box()
tgt_box = frame.locator(target).bounding_box()
except Exception:
src_box = tgt_box = None
if src_box and tgt_box:
sx = src_box['x'] + src_box['width'] / 2
sy = src_box['y'] + src_box['height'] / 2
tx = tgt_box['x'] + tgt_box['width'] / 2
ty = tgt_box['y'] + tgt_box['height'] / 2
page.mouse.move(sx, sy)
sleep_ms(rand(100, 200))
originals.mouse_down()
sleep_ms(rand(80, 150))
page.mouse.move(tx, ty)
sleep_ms(rand(80, 150))
originals.mouse_up()
elif _orig_frame_drag_and_drop:
_orig_frame_drag_and_drop(source, target, **kwargs)
frame.click = _frame_click
frame.dblclick = _frame_dblclick
frame.hover = _frame_hover
frame.type = _frame_type
frame.fill = _frame_fill
frame.check = _frame_check
frame.uncheck = _frame_uncheck
frame.select_option = _frame_select_option
frame.press = _frame_press
frame.clear = _frame_clear
frame.drag_and_drop = _frame_drag_and_drop
# --- Patch frame-level ElementHandle selectors ---
_patch_frame_element_handles_sync(
frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session
)
def _patch_frame_element_handles_sync(
frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
stealth: Any, cdp_session: Any,
) -> None:
"""Patch frame.query_selector, query_selector_all, wait_for_selector (sync)."""
_orig_qs = frame.query_selector
_orig_qsa = frame.query_selector_all
_orig_wfs = frame.wait_for_selector
def _patched_qs(selector: str, **kwargs: Any) -> Any:
el = _orig_qs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return el
def _patched_qsa(selector: str, **kwargs: Any) -> Any:
els = _orig_qsa(selector, **kwargs)
for el in els:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return els
def _patched_wfs(selector: str, **kwargs: Any) -> Any:
el = _orig_wfs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return el
frame.query_selector = _patched_qs
frame.query_selector_all = _patched_qsa
frame.wait_for_selector = _patched_wfs
def _iter_frames(page: Any):
try:
main = page.main_frame
yield main
for child in main.child_frames:
yield child
except Exception:
pass
def patch_context(context: Any, cfg: HumanConfig) -> None:
cursor = _CursorState()
for page in context.pages:
patch_page(page, cfg, cursor)
context.on("page", lambda p: patch_page(p, cfg, _CursorState()) if not hasattr(p, '_original') else None)
orig_new_page = context.new_page
def _patched_new_page(**kwargs: Any) -> Any:
page = orig_new_page(**kwargs)
if not hasattr(page, '_original'):
patch_page(page, cfg, _CursorState())
return page
context.new_page = _patched_new_page
def patch_browser(browser: Any, cfg: HumanConfig) -> None:
for context in browser.contexts:
patch_context(context, cfg)
orig_new_context = browser.new_context
def _patched_new_context(**kwargs: Any) -> Any:
context = orig_new_context(**kwargs)
patch_context(context, cfg)
return context
browser.new_context = _patched_new_context
orig_new_page = browser.new_page
def _patched_new_page(**kwargs: Any) -> Any:
page = orig_new_page(**kwargs)
if not hasattr(page, '_original'):
patch_page(page, cfg, _CursorState())
return page
browser.new_page = _patched_new_page
# ============================================================================
# ASYNC patching
# ============================================================================
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,
"fill": page.fill,
"goto": page.goto,
"hover": page.hover,
"dblclick": page.dblclick,
"select_option": page.select_option,
"mouse_move": page.mouse.move,
"mouse_click": page.mouse.click,
"mouse_wheel": page.mouse.wheel,
"mouse_down": page.mouse.down,
"mouse_up": page.mouse.up,
"keyboard_type": page.keyboard.type,
"keyboard_down": page.keyboard.down,
"keyboard_up": page.keyboard.up,
"keyboard_press": page.keyboard.press,
"keyboard_insert_text": page.keyboard.insert_text,
})()
page._original = originals
page._human_cfg = cfg
page._human_cursor = cursor
# --- Stealth infrastructure (lazy-initialized, async) ---
stealth = _AsyncIsolatedWorld(page)
page._stealth_world = stealth
cdp_session_holder: list[Any] = [None] # mutable container for closure
page._cdp_session_holder = cdp_session_holder # expose for frame-level patching
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,
"up": originals.mouse_up,
"wheel": originals.mouse_wheel,
})()
raw_keyboard: AsyncRawKeyboard = type("_AsyncRawKeyboard", (), {
"down": originals.keyboard_down,
"up": originals.keyboard_up,
"type": originals.keyboard_type,
"insert_text": originals.keyboard_insert_text,
})()
page._human_raw_mouse = raw_mouse
async def _ensure_cursor_init() -> None:
if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
await originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
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:
await _ensure_cursor_init()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
force = kwargs.get("force", False)
skip_checks = kwargs.pop("_skip_checks", False)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force and not skip_checks:
await async_ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
if call_cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy, did_scroll = await async_scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
is_input = await _async_is_input_element(page, selector)
if not force and did_scroll:
await async_ensure_stable(page, selector, timeout=_remaining_ms())
box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
target = click_target(box, is_input, call_cfg)
if not force:
await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
await async_human_click(raw_mouse, is_input, call_cfg)
async def _human_dblclick(selector: str, **kwargs: Any) -> None:
await _ensure_cursor_init()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
force = kwargs.get("force", False)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
if call_cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy, did_scroll = await async_scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
is_input = await _async_is_input_element(page, selector)
if not force and did_scroll:
await async_ensure_stable(page, selector, timeout=_remaining_ms())
box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
target = click_target(box, is_input, call_cfg)
if not force:
await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
await raw_mouse.down(click_count=2)
await async_sleep_ms(rand(30, 60))
await raw_mouse.up(click_count=2)
async def _human_hover(selector: str, **kwargs: Any) -> None:
await _ensure_cursor_init()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
force = kwargs.get("force", False)
skip_checks = kwargs.pop("_skip_checks", False)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force and not skip_checks:
await async_ensure_actionable(page, selector, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
if call_cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy, did_scroll = await async_scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
if not force and did_scroll:
await async_ensure_stable(page, selector, timeout=_remaining_ms())
box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
target = click_target(box, False, call_cfg)
if not force:
await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
async def _human_type(selector: str, text: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
force = kwargs.get("force", False)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
await async_sleep_ms(rand(100, 250))
cdp = await _ensure_cdp()
await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)
async def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
force = kwargs.get("force", False)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
await async_sleep_ms(rand(100, 250))
await originals.keyboard_press(_SELECT_ALL)
await async_sleep_ms(rand(30, 80))
await originals.keyboard_press("Backspace")
await async_sleep_ms(rand(50, 150))
cdp = await _ensure_cdp()
await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)
async def _human_check(selector: str, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
checked = await page.is_checked(selector)
except Exception:
checked = False
if not checked:
await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
async def _human_uncheck(selector: str, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
checked = await page.is_checked(selector)
except Exception:
checked = True
if checked:
await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
async def _human_press(selector: str, key: str, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
if not await _async_is_selector_focused(page, selector):
await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
await async_sleep_ms(rand(50, 150))
await originals.keyboard_press(key)
async def _human_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
await _human_hover(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
await async_sleep_ms(rand(100, 300))
pw_kwargs = {k: v for k, v in kwargs.items() if k not in ("human_config", "force")}
return await originals.select_option(selector, value, **pw_kwargs)
async def _human_mouse_move(x: float, y: float, **kwargs: Any) -> None:
await _ensure_cursor_init()
await async_human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg)
cursor.x = x
cursor.y = y
async def _human_mouse_click(x: float, y: float, **kwargs: Any) -> None:
await _ensure_cursor_init()
await async_human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg)
cursor.x = x
cursor.y = y
await async_human_click(raw_mouse, False, cfg)
async def _human_keyboard_type(text: str, **kwargs: Any) -> None:
cdp = await _ensure_cdp()
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
page.goto = _human_goto
page.click = _human_click
page.dblclick = _human_dblclick
page.hover = _human_hover
page.type = _human_type
page.fill = _human_fill
page.check = _human_check
page.uncheck = _human_uncheck
page.select_option = _human_select_option
page.press = _human_press
page.mouse.move = _human_mouse_move
page.mouse.click = _human_mouse_click
page.keyboard.type = _human_keyboard_type
# --- Patch Frame-level methods (for sub-frames) ---
_patch_frames_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals)
# --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) ---
_patch_page_element_handles_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder)
# --- Patch async Locator class (class-level, runs once) ---
_patch_locator_class_async()
# ============================================================================
# ASYNC ElementHandle patching
# ============================================================================
async def _async_is_input_element_handle(el: Any) -> bool:
"""Check if an ElementHandle is an input/textarea/contenteditable (async)."""
try:
return await el.evaluate(
"""(node) => {
const tag = node.tagName ? node.tagName.toLowerCase() : '';
return tag === 'input' || tag === 'textarea'
|| node.getAttribute && node.getAttribute('contenteditable') === 'true';
}"""
)
except Exception:
return False
def _patch_single_element_handle_async(
el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
stealth: Any, cdp_session_holder: Any,
) -> None:
"""Patch all interaction methods on an async Playwright ElementHandle."""
if getattr(el, '_human_patched', False):
return
el._human_patched = True
# Save originals
_orig_click = el.click
_orig_dblclick = el.dblclick
_orig_hover = el.hover
_orig_type = el.type
_orig_fill = el.fill
_orig_press = el.press
_orig_select_option = el.select_option
_orig_check = el.check
_orig_uncheck = el.uncheck
_orig_set_checked = getattr(el, 'set_checked', None)
_orig_tap = el.tap
_orig_focus = el.focus
_orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)
# Nested selectors
_orig_qs = el.query_selector
_orig_qsa = el.query_selector_all
_orig_wfs = el.wait_for_selector
async def _patched_qs(selector: str, **kwargs: Any) -> Any:
child = await _orig_qs(selector, **kwargs)
if child is not None:
_patch_single_element_handle_async(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return child
async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
children = await _orig_qsa(selector, **kwargs)
for child in children:
_patch_single_element_handle_async(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return children
async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
child = await _orig_wfs(selector, **kwargs)
if child is not None:
_patch_single_element_handle_async(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return child
el.query_selector = _patched_qs
el.query_selector_all = _patched_qsa
el.wait_for_selector = _patched_wfs
# Helper: move cursor to element (async). Accepts optional ``call_cfg`` so
# per-call ``human_config`` overrides on type/fill carry through to mouse
# timing. Also scrolls into view first so off-screen elements work
# (#129, #172 follow-up).
async def _move_to_element(call_cfg: HumanConfig = cfg):
if not cursor.initialized:
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
await originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
# Scroll into view first — best-effort.
async def _get_box():
return await el.bounding_box()
try:
_, nx, ny, _ = await async_human_scroll_into_view(
page, raw_mouse, _get_box,
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
pass
box = await el.bounding_box()
if not box:
return None
is_inp = await _async_is_input_element_handle(el)
target = click_target(box, is_inp, call_cfg)
if call_cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
return {'box': box, 'is_inp': is_inp}
async def _get_cdp():
if cdp_session_holder[0] is None:
try:
cdp_session_holder[0] = await stealth.get_cdp_session()
except Exception:
pass
return cdp_session_holder[0]
# --- el.click() ---
async def _human_el_click(**kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_click(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
# --- el.dblclick() ---
async def _human_el_dblclick(**kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_dblclick(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await raw_mouse.down(click_count=2)
await async_sleep_ms(rand(30, 60))
await raw_mouse.up(click_count=2)
# --- el.hover() ---
async def _human_el_hover(**kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_hover(**kwargs)
# --- el.type() ---
async def _human_el_type(text: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_type(text, **kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
await async_sleep_ms(rand(100, 250))
cdp = await _get_cdp()
await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)
# --- el.fill() ---
async def _human_el_fill(value: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_fill(value, **kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
await async_sleep_ms(rand(100, 250))
await originals.keyboard_press(_SELECT_ALL)
await async_sleep_ms(rand(30, 80))
await originals.keyboard_press("Backspace")
await async_sleep_ms(rand(50, 150))
cdp = await _get_cdp()
await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)
# --- el.scroll_into_view_if_needed() ---
async def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
if not cursor.initialized:
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
try:
await originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
except Exception:
pass
async def _get_box():
return await el.bounding_box()
try:
_, nx, ny, _ = await async_human_scroll_into_view(
page, raw_mouse, _get_box,
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
await _orig_scroll_into_view(**native_kwargs)
# --- el.press() ---
async def _human_el_press(key: str, **kwargs: Any) -> None:
await async_sleep_ms(rand(20, 60))
await originals.keyboard_down(key)
await async_sleep_ms(rand_range(cfg.key_hold))
await originals.keyboard_up(key)
# --- el.select_option() ---
async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
info = await _move_to_element()
if info is None:
return await _orig_select_option(value, **kwargs)
await async_human_click(raw_mouse, False, cfg)
await async_sleep_ms(rand(100, 300))
return await _orig_select_option(value, **kwargs)
# --- el.check() ---
async def _human_el_check(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
if await el.is_checked():
return
except Exception:
pass
info = await _move_to_element()
if info is None:
return await _orig_check(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.uncheck() ---
async def _human_el_uncheck(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
if not await el.is_checked():
return
except Exception:
pass
info = await _move_to_element()
if info is None:
return await _orig_uncheck(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.set_checked() ---
async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
current = await el.is_checked()
if current == checked:
return
except Exception:
pass
info = await _move_to_element()
if info is None and _orig_set_checked:
return await _orig_set_checked(checked, **kwargs)
if info:
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.tap() ---
async def _human_el_tap(**kwargs: Any) -> None:
info = await _move_to_element()
if info is None:
return await _orig_tap(**kwargs)
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.focus() ---
# FIX: move cursor humanly but use programmatic focus (no click side-effects).
# Stock Playwright el.focus() never clicks — it just calls element.focus() in JS.
# Clicking would trigger onclick, submit forms, navigate links, etc.
async def _human_el_focus() -> None:
await _move_to_element() # human-like cursor movement (Bézier)
await _orig_focus() # programmatic focus, no click side-effects
el.click = _human_el_click
el.dblclick = _human_el_dblclick
el.hover = _human_el_hover
el.type = _human_el_type
el.fill = _human_el_fill
el.press = _human_el_press
el.select_option = _human_el_select_option
el.check = _human_el_check
el.uncheck = _human_el_uncheck
if _orig_set_checked is not None:
el.set_checked = _human_el_set_checked
el.tap = _human_el_tap
el.focus = _human_el_focus
if _orig_scroll_into_view is not None:
el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed
def _patch_page_element_handles_async(
page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
stealth: Any, cdp_session_holder: Any,
) -> None:
"""Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (async)."""
_orig_qs = page.query_selector
_orig_qsa = page.query_selector_all
_orig_wfs = page.wait_for_selector
async def _patched_qs(selector: str, **kwargs: Any) -> Any:
el = await _orig_qs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return el
async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
els = await _orig_qsa(selector, **kwargs)
for el in els:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return els
async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
el = await _orig_wfs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return el
page.query_selector = _patched_qs
page.query_selector_all = _patched_qsa
page.wait_for_selector = _patched_wfs
def _patch_frames_async(
page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
) -> None:
for frame in _iter_frames(page):
_patch_single_frame_async(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
orig_main_frame = getattr(page, "_original_main_frame", None)
if orig_main_frame is None:
try:
_orig_goto = originals.goto
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)
return response
page.goto = _frame_aware_goto
page._original_main_frame = True
except Exception:
pass
def _patch_single_frame_async(
frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
) -> None:
if getattr(frame, "_human_patched", False):
return
frame._human_patched = True
# Frame-scoped resolution (async): humanize via ``frame.locator(selector)`` so
# the selector resolves in the sub-frame's own document, while the mouse stays
# page-level. Mirrors the sync patcher / JS moveToFrameSelector. #428.
_orig_frame_click = frame.click
_orig_frame_dblclick = frame.dblclick
_orig_frame_hover = frame.hover
_orig_frame_type = frame.type
_orig_frame_fill = frame.fill
_orig_frame_check = frame.check
_orig_frame_uncheck = frame.uncheck
_orig_frame_select_option = frame.select_option
_orig_frame_press = frame.press
_orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None)
stealth_world = getattr(page, '_stealth_world', None)
cdp_session_holder = getattr(page, '_cdp_session_holder', [None])
async def _get_cdp():
if cdp_session_holder[0] is None and stealth_world is not None:
try:
cdp_session_holder[0] = await stealth_world.get_cdp_session()
except Exception:
cdp_session_holder[0] = None
return cdp_session_holder[0]
def _native_kwargs(kwargs: dict) -> dict:
return {k: v for k, v in kwargs.items() if k != "human_config"}
def _deadline_remaining(kwargs: dict):
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
return lambda: max(0.0, (deadline - time.monotonic()) * 1000)
async def _frame_is_input(selector: str) -> bool:
try:
return bool(await frame.locator(selector).first.evaluate(
"el => { const t = el.tagName.toLowerCase();"
" return t === 'input' || t === 'textarea'"
" || el.getAttribute('contenteditable') === 'true'; }"
))
except Exception:
return False
async def _frame_is_focused(selector: str) -> bool:
try:
return bool(await frame.locator(selector).first.evaluate(
"el => el === document.activeElement"
))
except Exception:
return False
async def _move_to_frame_selector(selector: str, kwargs: dict, input_bias: bool, remaining_ms):
call_cfg = merge_config(cfg, kwargs.get("human_config"))
if call_cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
loc = frame.locator(selector).first
try:
await loc.scroll_into_view_if_needed(timeout=max(1, remaining_ms()))
except Exception:
pass
try:
box = await loc.bounding_box(timeout=max(1, remaining_ms()))
except Exception:
box = None
if not box:
return None
is_input = input_bias or await _frame_is_input(selector)
target = click_target(box, is_input, call_cfg)
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
return call_cfg, is_input
async def _frame_click(selector: str, **kwargs: Any) -> None:
remaining_ms = _deadline_remaining(kwargs)
moved = await _move_to_frame_selector(selector, kwargs, False, remaining_ms)
if moved is None:
await _orig_frame_click(selector, **_native_kwargs(kwargs))
return
await async_human_click(raw_mouse, moved[1], moved[0])
async def _frame_dblclick(selector: str, **kwargs: Any) -> None:
remaining_ms = _deadline_remaining(kwargs)
moved = await _move_to_frame_selector(selector, kwargs, False, remaining_ms)
if moved is None:
await _orig_frame_dblclick(selector, **_native_kwargs(kwargs))
return
await raw_mouse.down(click_count=2)
await async_sleep_ms(rand(30, 60))
await raw_mouse.up(click_count=2)
async def _frame_hover(selector: str, **kwargs: Any) -> None:
remaining_ms = _deadline_remaining(kwargs)
moved = await _move_to_frame_selector(selector, kwargs, False, remaining_ms)
if moved is None:
await _orig_frame_hover(selector, **_native_kwargs(kwargs))
async def _frame_type(selector: str, text: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
await _frame_click(selector, **kwargs)
await async_sleep_ms(rand(100, 250))
try:
cdp = await _get_cdp()
await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)
except Exception:
await _orig_frame_type(selector, text, **_native_kwargs(kwargs))
async def _frame_fill(selector: str, value: str, **kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
await _frame_click(selector, **kwargs)
await async_sleep_ms(rand(100, 250))
await originals.keyboard_press(_SELECT_ALL)
await async_sleep_ms(rand(30, 80))
await originals.keyboard_press("Backspace")
await async_sleep_ms(rand(50, 150))
try:
cdp = await _get_cdp()
await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)
except Exception:
await _orig_frame_fill(selector, value, **_native_kwargs(kwargs))
async def _frame_check(selector: str, **kwargs: Any) -> None:
try:
checked = await frame.locator(selector).first.is_checked()
except Exception:
await _orig_frame_check(selector, **_native_kwargs(kwargs))
return
if not checked:
try:
await _frame_click(selector, **kwargs)
except Exception:
await _orig_frame_check(selector, **_native_kwargs(kwargs))
async def _frame_uncheck(selector: str, **kwargs: Any) -> None:
try:
checked = await frame.locator(selector).first.is_checked()
except Exception:
await _orig_frame_uncheck(selector, **_native_kwargs(kwargs))
return
if checked:
try:
await _frame_click(selector, **kwargs)
except Exception:
await _orig_frame_uncheck(selector, **_native_kwargs(kwargs))
async def _frame_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any:
await _frame_hover(selector, **kwargs)
await async_sleep_ms(rand(100, 300))
return await _orig_frame_select_option(selector, value, **_native_kwargs(kwargs))
async def _frame_press(selector: str, key: str, **kwargs: Any) -> None:
if not await _frame_is_focused(selector):
await _frame_click(selector, **kwargs)
await async_sleep_ms(rand(50, 150))
await originals.keyboard_press(key)
async def _frame_clear(selector: str, **kwargs: Any) -> None:
if not await _frame_is_focused(selector):
await _frame_click(selector, **kwargs)
await async_sleep_ms(rand(50, 100))
await originals.keyboard_press(_SELECT_ALL)
await async_sleep_ms(rand(30, 80))
await originals.keyboard_press("Backspace")
async def _frame_drag_and_drop(source: str, target: str, **kwargs: Any) -> None:
try:
src_box = await frame.locator(source).bounding_box()
tgt_box = await frame.locator(target).bounding_box()
except Exception:
src_box = tgt_box = None
if src_box and tgt_box:
sx = src_box['x'] + src_box['width'] / 2
sy = src_box['y'] + src_box['height'] / 2
tx = tgt_box['x'] + tgt_box['width'] / 2
ty = tgt_box['y'] + tgt_box['height'] / 2
await page.mouse.move(sx, sy)
await async_sleep_ms(rand(100, 200))
await originals.mouse_down()
await async_sleep_ms(rand(80, 150))
await page.mouse.move(tx, ty)
await async_sleep_ms(rand(80, 150))
await originals.mouse_up()
elif _orig_frame_drag_and_drop:
await _orig_frame_drag_and_drop(source, target, **kwargs)
frame.click = _frame_click
frame.dblclick = _frame_dblclick
frame.hover = _frame_hover
frame.type = _frame_type
frame.fill = _frame_fill
frame.check = _frame_check
frame.uncheck = _frame_uncheck
frame.select_option = _frame_select_option
frame.press = _frame_press
frame.clear = _frame_clear
frame.drag_and_drop = _frame_drag_and_drop
# --- Patch frame-level ElementHandle selectors (async) ---
_patch_frame_element_handles_async(
frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session_holder
)
def _patch_frame_element_handles_async(
frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
stealth: Any, cdp_session_holder: Any,
) -> None:
"""Patch frame.query_selector, query_selector_all, wait_for_selector (async)."""
_orig_qs = frame.query_selector
_orig_qsa = frame.query_selector_all
_orig_wfs = frame.wait_for_selector
async def _patched_qs(selector: str, **kwargs: Any) -> Any:
el = await _orig_qs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return el
async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
els = await _orig_qsa(selector, **kwargs)
for el in els:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return els
async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
el = await _orig_wfs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return el
frame.query_selector = _patched_qs
frame.query_selector_all = _patched_qsa
frame.wait_for_selector = _patched_wfs
def patch_context_async(context: Any, cfg: HumanConfig) -> None:
cursor = _CursorState()
for page in context.pages:
patch_page_async(page, cfg, cursor)
context.on("page", lambda p: patch_page_async(p, cfg, _CursorState()) if not hasattr(p, '_original') else None)
orig_new_page = context.new_page
async def _patched_new_page(**kwargs: Any) -> Any:
page = await orig_new_page(**kwargs)
if not hasattr(page, '_original'):
patch_page_async(page, cfg, _CursorState())
return page
context.new_page = _patched_new_page
def patch_browser_async(browser: Any, cfg: HumanConfig) -> None:
for context in browser.contexts:
patch_context_async(context, cfg)
orig_new_context = browser.new_context
async def _patched_new_context(**kwargs: Any) -> Any:
context = await orig_new_context(**kwargs)
patch_context_async(context, cfg)
return context
browser.new_context = _patched_new_context
orig_new_page = browser.new_page
async def _patched_new_page(**kwargs: Any) -> Any:
page = await orig_new_page(**kwargs)
if not hasattr(page, '_original'):
patch_page_async(page, cfg, _CursorState())
return page
browser.new_page = _patched_new_page