Files
lilosandevelaa123 1966ff861d fix(humanize): human-like key timing, Fitts-law mouse, notch wheels; containing-block scroller (#593) (#595)
Typing (Python, JS Playwright/Puppeteer, .NET)
- Absolute-time Typist: log-normal hold (~100 ms), key-pair dependent
  inter-key gaps (space, same/other hand, repeated letter), rollover via
  queued releases, Shift often released before the letter, no key pressed
  twice, all held keys released on error. A lower-case press of a key is
  released before Shift + the same key goes down ("xX", "1!"), so no
  keydown with repeat=true.
- Defaults moved to Aalto 136M Keystrokes: typing_delay 150, key_hold
  (60, 175), mistype_chance 0.04; careful preset updated.

Mouse
- Fitts-law stroke duration (mouse_time_base / per_bit / spread),
  log-normal run-to-run spread, frame-rate events, time-warped
  minimum-jerk profile with an early velocity peak, gentle curvature and
  wobble, no zero-movement events, notch-free overshoot correction.

Scroll
- Whole wheel notches of the persona's platform (Win 100, mac 40,
  Linux 120; scroll_notch_px override) in short flicks, so deltaY agrees
  with CDP's one-notch wheelDeltaY; overshoot measured in notches.

- scroller() in the isolated-world helpers walks the containing-block
  chain: fixed boxes skip ancestors that do not establish a containing
  block for them, absolute boxes skip static ancestors. A fixed modal in
  a scrolling panel is clicked instead of timing out with "element is
  outside of the viewport". All three helper copies stay byte-identical.

Removed
- Settings with no effect after the change: mouse_steps_divisor,
  mouse_burst_size, mouse_burst_pause, scroll_delta_base,
  scroll_delta_variance (and the .NET properties). Python drops them from
  human_config with a FutureWarning that points at the caller; JS and the .NET override
  dictionary ignore them. The standalone .NET HumanScroll wheels in whole
  notches too. READMEs updated to the new defaults.

- .NET: a "\r\n" text element is inserted as text again (it threw
  "Unknown key" from KeyDownAsync).

Tests: timing/rollover/notch/zero-move tests; five #593 cases in Python,
JS and .NET; puppeteer/stealth tests updated to the new semantics.

Co-authored-by: evelaa123 <189992797+evelaa123@users.noreply.github.com>
2026-10-14 18:30:49 +02:00

1313 lines
64 KiB
Python

"""Unified humanized action pipeline (async core, Playwright impl objects).
One implementation drives page / frame / locator / element-handle actions for
both the sync and the async Playwright API:
resolve (isolated world, Playwright selector engine, strict mode)
-> wait for element states (visible / enabled / editable / stable)
-> scroll into view with mouse-wheel bursts (page, iframes, containers)
-> Bezier move to a point inside the visible part of the element
-> hit-target check (element itself, and every ancestor <iframe>)
-> press / type
Nothing falls back to Playwright's stock actions, and failures raise
Playwright ``Error`` / ``TimeoutError``.
"""
from __future__ import annotations
import re
import sys
import asyncio
import math
import random
import time
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Tuple
from playwright._impl._errors import Error, TimeoutError
from playwright._impl._js_handle import JSHandle
from . import fields
from .config import HumanConfig, merge_config, rand, rand_int_range, rand_log, rand_range
from .keyboard import NEARBY_KEYS, SHIFT_SYMBOLS, _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES, key_gap, key_hold, physical_key
from .mouse import click_target, plan_move
from .world import HELPERS, StaleElement, Worlds, intersect
_ENTER_FRAME_SPLIT = re.compile(r"\s*>>\s*internal:control=enter-frame\s*>>\s*")
CLICK_STATES = ["visible", "enabled", "stable"]
HOVER_STATES = ["visible", "stable"]
INPUT_STATES = ["visible", "enabled", "editable"]
FOCUS_STATES = ["visible", "enabled"]
_RETRY_MS = [0, 20, 100, 100, 500]
class CursorState:
__slots__ = ("x", "y", "initialized")
def __init__(self) -> None:
self.x: float = 0
self.y: float = 0
self.initialized: bool = False
async def sleep_ms(ms: float) -> None:
if ms > 0:
await asyncio.sleep(ms / 1000.0)
# ---------------------------------------------------------------------------
# Targets
# ---------------------------------------------------------------------------
class Target:
"""What an action is aimed at: a selector in a frame, or an ElementHandle."""
__slots__ = ("frame", "selector", "strict", "handle", "description")
def __init__(self, frame: Any, selector: Optional[str] = None, strict: bool = False,
handle: Any = None) -> None:
self.frame = frame
self.selector = selector
self.strict = strict
self.handle = handle
self.description = f"locator({selector!r})" if selector is not None else "element handle"
class Resolved:
__slots__ = ("frame", "id")
def __init__(self, frame: Any, element_id: int) -> None:
self.frame = frame
self.id = element_id
class _Deadline:
def __init__(self, timeout_ms: float) -> None:
self.timeout = timeout_ms
self._end = None if not timeout_ms else time.monotonic() + timeout_ms / 1000.0
def remaining(self) -> float:
if self._end is None:
return math.inf
return max(0.0, (self._end - time.monotonic()) * 1000.0)
def left(self) -> float:
"""Remaining budget as a Playwright timeout value (0 = no limit)."""
return 0 if self._end is None else max(1.0, self.remaining())
def expired(self) -> bool:
return self._end is not None and time.monotonic() >= self._end
class _Retry(Exception):
"""Internal: the current attempt failed for ``reason``; retry until timeout."""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
class RawFallback(Exception):
"""A read / dispatch on an ElementHandle the isolated world cannot locate
(no box: hidden; or a box shared with another element). The patched method
runs Playwright's own call instead, which reads hidden elements. Also a
dispatch_event whose event_init holds a JSHandle (it lives in the page's
main world, where Playwright dispatches)."""
def _has_handle(v: Any) -> bool:
"""A JSHandle anywhere in ``v`` (public wrapper or impl object)."""
if isinstance(v, dict):
return any(_has_handle(x) for x in v.values())
if isinstance(v, (list, tuple)):
return any(_has_handle(x) for x in v)
return isinstance(getattr(v, "_impl_obj", v), JSHandle)
# ---------------------------------------------------------------------------
# Typing timeline
# ---------------------------------------------------------------------------
class _Typist:
"""Presses keys on a timeline the way a person does: each press follows
the previous *press* after a letter-dependent gap, and each key is held
for its own time, so the next key often goes down before the previous one
comes up (rollover). Shift goes down before the letter and is usually let
go just before the letter key. Releases are queued and sent when due."""
def __init__(self, human: "Human", cfg: HumanConfig, delay: Optional[float] = None) -> None:
self.h, self.cfg, self.delay = human, cfg, delay
self.prev = ""
self.last_press: Optional[float] = None
self.ups: List[Tuple[float, str, Optional[dict]]] = [] # (due, key, cdp params)
self.shift_up_due: Optional[float] = None
self.shift_down = False
async def _shift(self, down: bool) -> None:
self.shift_down = down
await (self.h.keyboard.down if down else self.h.keyboard.up)("Shift")
async def _wait(self, until: float) -> None:
await sleep_ms((until - time.monotonic()) * 1000)
async def _flush(self, until: float) -> None:
"""Send every release due before ``until`` (in time order)."""
while True:
due = [(t, k, p) for t, k, p in self.ups if t <= until]
if self.shift_up_due is not None and self.shift_up_due <= until:
due.append((self.shift_up_due, "Shift", None))
if not due:
return
t, k, p = min(due, key=lambda e: e[0])
await self._wait(t)
if k == "Shift" and p is None and self.shift_up_due is not None and t == self.shift_up_due:
self.shift_up_due = None
await self._shift(False)
else:
self.ups.remove((t, k, p))
await self._up(k, p)
async def _up(self, k: str, p: Optional[dict]) -> None:
if p is not None:
await (await self.h.worlds.session()).send("Input.dispatchKeyEvent", p)
elif len(k) == 1 and k.isupper() and not self.shift_down:
# Shift already came up: the OS reports the bare key ("a").
await self.h.keyboard.up(k.lower())
else:
await self.h.keyboard.up(k)
async def key(self, ch: str) -> None:
cfg = self.cfg
now = time.monotonic()
if self.last_press is None:
at = now
elif self.delay is not None:
at = self.last_press + self.delay / 1000
else:
at = self.last_press + key_gap(cfg, self.prev, ch) / 1000
shifted = (len(ch) == 1 and ch.isascii() and ch.isalpha() and ch.isupper()) or ch in SHIFT_SYMBOLS
text_only = len(ch) == 1 and not ch.isascii()
# A key cannot go down twice: let the earlier press of the same
# physical key ("x" before "X", "1" before "!") come up first.
key = physical_key(ch)
held = [t for t, k, _ in self.ups if physical_key(k) == key]
if held:
at = max(at, min(held) + 0.005)
if shifted:
if self.shift_up_due is not None:
self.shift_up_due = None # still down from the previous capital
await self._flush(at)
else:
lead = rand_log(cfg.shift_down_delay) / 1000
# Shift goes down after the previous key comes up.
await self._flush(at - lead)
await self._wait(at - lead)
await self._shift(True)
# Keys due to come up while Shift is going down (e.g. the "x"
# of "xX") are released before the capital is pressed.
await self._flush(at)
else:
await self._flush(at)
if self.shift_up_due is not None and ch != "Backspace":
# A lower-case key while Shift is still down: let it go first.
self.shift_up_due = None
await self._shift(False)
await self._wait(at)
self.last_press = max(at, time.monotonic())
hold = (self.delay if self.delay is not None and self.delay < key_hold(cfg) else key_hold(cfg)) / 1000
up_at = self.last_press + hold
if text_only:
# Non-ASCII text has no key: it arrives as one input, like an IME.
await self.h.keyboard.insert_text(ch)
elif ch in SHIFT_SYMBOLS:
code = _SHIFT_SYMBOL_CODES.get(ch, "")
vk = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
await (await self.h.worlds.session()).send("Input.dispatchKeyEvent", {
"type": "keyDown", "modifiers": 8, "key": ch, "code": code,
"windowsVirtualKeyCode": vk, "text": ch, "unmodifiedText": ch,
})
self.ups.append((up_at, ch, {"type": "keyUp", "modifiers": 8, "key": ch, "code": code,
"windowsVirtualKeyCode": vk}))
else:
await self.h.keyboard.down(ch)
self.ups.append((up_at, ch, None))
if shifted:
# Usually let go of Shift around the letter's release, most often
# just before it.
self.shift_up_due = max(self.last_press + 0.01, up_at + rand_range(cfg.shift_up_delay) / 1000)
if ch in SHIFT_SYMBOLS:
# The CDP keyUp of a shift symbol carries the Shift modifier.
self.shift_up_due = max(self.shift_up_due, up_at + 0.005)
self.prev = ch
async def finish(self) -> None:
"""Release everything still held, on schedule."""
await self._flush(float("inf"))
async def rest(self) -> None:
"""Hands off the keys (before a pause): release, and start the next
key without a gap carried over from the last one."""
await self.finish()
self.last_press = None
async def release_all(self) -> None:
"""Error path: never leave a key stuck down."""
ups, self.ups = self.ups, []
for _, k, p in ups:
try:
await self._up(k, p)
except Exception:
pass
self.shift_up_due = None
if self.shift_down:
try:
await self._shift(False)
except Exception:
pass
# ---------------------------------------------------------------------------
# Human
# ---------------------------------------------------------------------------
class Human:
"""Humanized input for one Playwright impl page."""
def __init__(self, page: Any, cfg: HumanConfig, cursor: CursorState) -> None:
self.page = page
self.cfg = cfg
self.cursor = cursor
self.worlds = Worlds(page)
self._typeahead_until = 0.0 # monotonic time until which the browser joins typed letters
self.mouse = page.mouse
self.keyboard = page.keyboard
self._platform: Optional[str] = None
self._handle_ids: Dict[str, Tuple[Any, int, int]] = {}
# -- configuration ------------------------------------------------------
def call_cfg(self, overrides: Optional[dict]) -> HumanConfig:
return merge_config(self.cfg, overrides)
def timeout(self, frame: Any, timeout: Optional[float]) -> float:
return float(frame._timeout(timeout))
def deadline(self, target: Target, opts: dict) -> "_Deadline":
"""Deadline for an action; nested sub-actions share their caller's."""
d = opts.get("_deadline")
return d if d is not None else _Deadline(self.timeout(target.frame, opts.get("timeout")))
async def platform(self) -> str:
"""Persona platform (``navigator.platform`` read in the isolated world)."""
if self._platform is None:
try:
self._platform = str(await self.worlds.call(self.page.main_frame, "platform"))
except Error:
self._platform = ""
return self._platform
async def is_mac(self) -> bool:
return (await self.platform()).lower().startswith("mac")
async def select_all_key(self) -> str:
return "Meta+a" if await self.is_mac() else "Control+a"
async def _modifier(self, key: str) -> str:
if key == "ControlOrMeta":
return "Meta" if await self.is_mac() else "Control"
return key
# -- cursor -------------------------------------------------------------
async def ensure_cursor(self, cfg: Optional[HumanConfig] = None) -> None:
if not self.cursor.initialized:
cfg = cfg or self.cfg
self.cursor.x = round(rand(*cfg.initial_cursor_x))
self.cursor.y = round(rand(*cfg.initial_cursor_y))
await self.mouse.move(self.cursor.x, self.cursor.y)
self.cursor.initialized = True
async def move_to(self, x: float, y: float, cfg: HumanConfig) -> None:
"""Move from the current cursor position to (x, y): an early-peaking
main stroke timed by Fitts' law, sometimes a short corrective stroke,
one pointer event per frame and none without movement."""
await self.ensure_cursor(cfg)
sx, sy = self.cursor.x, self.cursor.y
clock = time.monotonic()
for px, py, dt in plan_move(sx, sy, x, y, cfg):
# Keep to the planned schedule: the CDP round trip of each move
# comes out of the next wait instead of adding to it.
clock += dt / 1000
await sleep_ms((clock - time.monotonic()) * 1000)
await self.mouse.move(px, py)
self.cursor.x, self.cursor.y = x, y
async def idle(self, cfg: HumanConfig) -> None:
if not cfg.idle_between_actions:
return
await self.ensure_cursor(cfg)
end = time.monotonic() + rand(*cfg.idle_between_duration)
x, y = self.cursor.x, self.cursor.y
while time.monotonic() < end:
x += (random.random() - 0.5) * 2 * cfg.idle_drift_px
y += (random.random() - 0.5) * 2 * cfg.idle_drift_px
await self.mouse.move(round(x), round(y))
await sleep_ms(min(rand_range(cfg.idle_pause_range), max(0.0, (end - time.monotonic()) * 1000)))
# Drift is noise around the resting point, not a new position.
await self.mouse.move(self.cursor.x, self.cursor.y)
# -- resolution ---------------------------------------------------------
async def resolve(self, target: Target, deadline: _Deadline) -> Resolved:
"""Resolve once. Raises ``_Retry`` (not yet there) or ``Error`` (final)."""
if target.handle is not None:
return await self._resolve_handle(target.handle, deadline)
frame = target.frame
parts = _ENTER_FRAME_SPLIT.split(target.selector)
for i, part in enumerate(parts):
res = await self.worlds.call(frame, "resolve", part, bool(target.strict), 0)
status = res["status"]
if status == "none":
raise _Retry(f"waiting for {target.description}")
if status == "strict":
raise Error(res["message"])
if status == "error":
raise Error(res["message"])
if i == len(parts) - 1:
return Resolved(frame, res["id"])
child = await self.worlds.content_frame(frame, res["id"])
if child is None:
raise _Retry(f"waiting for the frame of {target.description}")
frame = child
raise Error("unreachable") # pragma: no cover
async def _resolve_handle(self, handle: Any, deadline: _Deadline, exact: bool = False) -> Resolved:
"""``exact`` (reads): skip the cache (it may hold a click's pick from a
same-box chain) and accept only the one element with this box; anything
else raises RawFallback."""
frame = handle._frame
rec = await self.worlds._ready(frame)
cached = self._handle_ids.get(handle._guid)
if not exact and cached and cached[0] is frame and cached[1] == rec.ctx:
return Resolved(frame, cached[2])
# An ElementHandle carries no identity our context can read, so find
# the element with exactly its border box.
box = await handle.bounding_box()
if box is None:
if exact:
raise RawFallback()
if cached and cached[0] is frame:
# Resolved in an earlier document of this frame and gone now
# (a same-document navigation keeps the element, and its box).
raise Error("Element is not attached to the DOM")
raise _Retry("element is not visible")
ox, oy, _ = await self.worlds.frame_geometry(frame)
res = await self.worlds.call(frame, "matchRect", box["x"] - ox, box["y"] - oy,
box["width"], box["height"], exact)
if res.get("count") != 1:
if exact:
raise RawFallback()
raise Error(
"cloakbrowser humanize: cannot identify the element behind this ElementHandle "
"inside the isolated world (it shares its box with another element); "
"use a Locator instead"
)
self._handle_ids[handle._guid] = (frame, rec.ctx, res["id"])
return Resolved(frame, res["id"])
async def wait_for(self, target: Target, states: Sequence[str], deadline: _Deadline,
api: str, force: bool = False) -> Resolved:
"""Resolve and wait for ``states``; Playwright-style retry + timeout."""
return await self.retry(api, target, deadline, lambda: self._attempt_states(target, states, deadline, force))
async def _attempt_states(self, target: Target, states: Sequence[str], deadline: _Deadline,
force: bool) -> Resolved:
r = await self.resolve(target, deadline)
if force:
return r
res = await self.worlds.call(r.frame, "states", r.id, list(states))
if res is None:
return r
if "error" in res:
raise Error(res["error"])
missing = res["missing"]
if missing == "attached":
if target.handle is not None:
raise Error("Element is not attached to the DOM")
raise _Retry("element was detached from the DOM, retrying")
raise _Retry(f"element is not {missing}")
async def _handle_or_raw(self, target: Target, deadline: _Deadline, api: str) -> None:
"""For an ElementHandle: cache its exact isolated-world id, or raise
RawFallback at once when it has no box or shares it (Playwright reads
hidden elements; a read must not land on a same-box wrapper)."""
if target.handle is not None:
await self.retry(api, target, deadline,
lambda: self._resolve_handle(target.handle, deadline, exact=True))
async def read(self, target: Target, op: str, opts: dict, api: str, *extra: Any) -> Any:
"""Read a property through the isolated world.
Resolve until the selector matches once (Playwright's read semantics),
then read the property in the world the engine already runs in.
"""
deadline = self.deadline(target, opts)
await self._handle_or_raw(target, deadline, api)
r = await self.wait_for(target, [], deadline, api)
res = await self.worlds.call(r.frame, op, r.id, *extra)
if isinstance(res, dict):
if "error" in res:
raise Error(f"{api}: Error: {res['error']}")
return res.get("value")
return res
async def dispatch(self, target: Target, opts: dict, api: str, event_type: str,
event_init: Any = None) -> None:
"""Dispatch a DOM event on the resolved element.
The event is built by Playwright's own injected helper, evaluated in the
isolated world instead of the page's world.
"""
if _has_handle(event_init):
raise RawFallback()
deadline = self.deadline(target, opts)
await self._handle_or_raw(target, deadline, api)
r = await self.wait_for(target, [], deadline, api)
res = await self.worlds.call(r.frame, "dispatchEvent", r.id, event_type, event_init)
if isinstance(res, dict) and "error" in res:
raise Error(f"{api}: Error: {res['error']}")
async def retry(self, api: str, target: Target, deadline: _Deadline,
attempt: Callable[[], Awaitable[Any]]) -> Any:
reasons: List[str] = []
n = 0
while True:
try:
return await attempt()
except StaleElement:
if target.handle is not None:
raise Error(f"{api}: Element is not attached to the DOM") from None
self._note(reasons, "element was detached from the DOM, retrying")
except _Retry as r:
self._note(reasons, r.reason)
except Error as exc:
msg = str(exc)
if msg.startswith(api):
raise
raise type(exc)(f"{api}: {msg}") from None
if deadline.expired():
log = "\n".join(f" - {r}" for r in [f"waiting for {target.description}"] + reasons[-5:])
raise TimeoutError(f"{api}: Timeout {deadline.timeout:.0f}ms exceeded.\nCall log:\n{log}")
wait = _RETRY_MS[min(n, len(_RETRY_MS) - 1)]
n += 1
await sleep_ms(min(wait, deadline.remaining()))
@staticmethod
def _note(reasons: List[str], reason: str) -> None:
if not reasons or reasons[-1] != reason:
reasons.append(reason)
# -- geometry -----------------------------------------------------------
async def element_box(self, r: Resolved) -> Optional[Dict[str, float]]:
"""Element border box in viewport coordinates plus its visible clip."""
g = await self.worlds.call(r.frame, "geometry", r.id)
if g is None:
raise StaleElement()
ox, oy, clip = await self.worlds.frame_geometry(r.frame)
box = {"x": g["x"] + ox, "y": g["y"] + oy, "width": g["width"], "height": g["height"],
"bl": g["bl"], "bt": g["bt"]}
scroller = await self.worlds.call(r.frame, "scroller", r.id)
if scroller:
sc = {"x": scroller["x"] + ox, "y": scroller["y"] + oy,
"width": scroller["width"], "height": scroller["height"]}
clip = intersect(clip, sc)
box["scroller"] = sc
box["clip"] = clip
box["visible"] = intersect(clip, box)
return box
# -- scrolling ----------------------------------------------------------
async def scroll_into_view(self, r: Resolved, cfg: HumanConfig, deadline: _Deadline) -> Dict[str, Any]:
"""Wheel-scroll until the element sits inside its visible clip (and, for
the main document, inside ``scroll_target_zone`` when the page can
still scroll that way). Returns the final box."""
box = await self.element_box(r)
vp = await self.worlds.viewport()
stalled = rounds = 0
overshot = False
last = (0.0, 0.0)
notch = await self.notch_px(cfg)
while stalled < 2 and rounds < 40 and not deadline.expired():
dx, dy = await self._needed_scroll(r, box, vp, cfg)
if abs(dx) < 1 and abs(dy) < 1:
break
rounds += 1
# Whole notches can jump over a target whose slack is smaller than
# one notch (a short scroll box): after such a reversal, finish
# with one wheel event of the exact remainder (a free-spinning or
# high-resolution wheel sends those) instead of swinging forever.
fine_y = dy * last[1] < 0 and abs(dy) < notch
fine_x = dx * last[0] < 0 and abs(dx) < notch
last = (dx, dy)
area = box["clip"] if box["clip"]["width"] > 4 and box["clip"]["height"] > 4 else \
{"x": 0.0, "y": 0.0, "width": float(vp["width"]), "height": float(vp["height"])}
if not self._inside(area, self.cursor):
await self.move_to(area["x"] + area["width"] * rand(0.3, 0.7),
area["y"] + area["height"] * rand(0.3, 0.7), cfg)
await sleep_ms(rand_range(cfg.scroll_pre_move_delay))
before = (box["x"], box["y"])
if abs(dy) >= 1:
if fine_y:
await self.mouse.wheel(0, round(dy))
else:
await self._wheel_burst(0, dy, cfg, deadline)
if not overshot:
overshot = True
await self._overshoot(dy, cfg, deadline)
if abs(dx) >= 1:
if fine_x:
await self.mouse.wheel(round(dx), 0)
else:
await self._wheel_burst(dx, 0, cfg, deadline)
await sleep_ms(min(rand_range(cfg.scroll_settle_delay), deadline.remaining()))
box = await self.element_box(r)
moved = abs(box["x"] - before[0]) + abs(box["y"] - before[1])
stalled = stalled + 1 if moved < 1 else 0
return box
@staticmethod
def _inside(area: Dict[str, float], c: CursorState) -> bool:
return area["x"] <= c.x <= area["x"] + area["width"] and area["y"] <= c.y <= area["y"] + area["height"]
async def _needed_scroll(self, r: Resolved, box: Dict[str, Any], vp: Dict[str, float],
cfg: HumanConfig) -> Tuple[float, float]:
clip = box["clip"]
if clip["width"] <= 0 or clip["height"] <= 0:
# The frame (or container) itself is out of view: bring it in first.
return 0.0, (box["y"] + box["height"] / 2) - vp["height"] / 2
dy = dx = 0.0
# Vertical
top, bottom = box["y"], box["y"] + box["height"]
main_doc = r.frame.parent_frame is None and not box.get("scroller")
if main_doc and box["height"] <= vp["height"] * (cfg.scroll_target_zone[1] - cfg.scroll_target_zone[0]):
zt, zb = vp["height"] * cfg.scroll_target_zone[0], vp["height"] * cfg.scroll_target_zone[1]
if top < zt or bottom > zb:
dy = (top + box["height"] / 2) - vp["height"] * rand(*cfg.scroll_target_zone)
if top >= clip["y"] - 1 and bottom <= clip["y"] + clip["height"] + 1:
# Fully visible but off-centre: only if the page can scroll that way.
doc = await self.worlds.call(self.page.main_frame, "doc")
if (dy < 0 and doc["y"] <= 0) or (dy > 0 and doc["y"] >= doc["maxY"]):
dy = 0.0
elif box["height"] <= clip["height"]:
if top < clip["y"] - 1 or bottom > clip["y"] + clip["height"] + 1:
dy = (top + box["height"] / 2) - (clip["y"] + clip["height"] / 2)
elif box["visible"]["height"] < clip["height"] * 0.5:
dy = top - clip["y"] - clip["height"] * 0.1
# Horizontal (#521): containment only.
left, right = box["x"], box["x"] + box["width"]
if box["width"] <= clip["width"]:
if left < clip["x"] - 1 or right > clip["x"] + clip["width"] + 1:
dx = (left + box["width"] / 2) - (clip["x"] + clip["width"] / 2)
elif box["visible"]["width"] <= 0:
dx = left - clip["x"]
return dx, dy
async def _overshoot(self, dy: float, cfg: HumanConfig, deadline: _Deadline) -> None:
"""Sometimes scroll a little past the target, pause, and wheel back
(``scroll_overshoot_chance`` / ``scroll_overshoot_px``). The next
round of :meth:`scroll_into_view` fixes whatever is left."""
if random.random() >= cfg.scroll_overshoot_chance or deadline.expired():
return
sign = 1 if dy > 0 else -1
notch = await self.notch_px(cfg)
over = max(1, int(round(rand_range(cfg.scroll_overshoot_px) / notch)))
await self._wheel_burst(0, over * notch * sign, cfg, deadline)
await sleep_ms(min(rand_range(cfg.scroll_settle_delay), deadline.remaining()))
# Turn back in one or two flicks.
first = rand_int_range((1, over)) if over > 1 and random.random() < 0.5 else over
for n in (first, over - first):
if n <= 0 or deadline.expired():
continue
await self._wheel_burst(0, n * notch * -sign, cfg, deadline)
await sleep_ms(min(rand(100, 250), deadline.remaining()))
async def notch_px(self, cfg: HumanConfig) -> float:
"""CSS pixels one wheel notch scrolls for the persona's platform:
Chrome scrolls 100 px per notch on Windows (3 lines of 33.3 px), 40 on
macOS and 120 on Linux, and reports ``wheelDelta`` 120 per notch. CDP
always reports one notch per wheel event, so whole-notch events are
the only ones whose ``deltaY`` and ``wheelDeltaY`` agree."""
if cfg.scroll_notch_px and cfg.scroll_notch_px > 0:
return float(cfg.scroll_notch_px)
plat = (await self.platform()).lower()
if plat.startswith("mac"):
return 40.0
if plat.startswith("win"):
return 100.0
return 120.0
async def _wheel_burst(self, dx: float, dy: float, cfg: HumanConfig,
deadline: Optional[_Deadline] = None) -> None:
"""One logical scroll: flicks of a few wheel notches each, quicker in
the middle of a flick, with short pauses between flicks. Every event
is exactly one notch, so the page scrolls by the nearest whole number
of notches (the caller re-measures and scrolls again when needed)."""
total = abs(dy) if dy else abs(dx)
sign = 1 if (dy or dx) > 0 else -1
notch = await self.notch_px(cfg)
notches = max(1, int(round(total / notch)))
accel, decel = rand_int_range(cfg.scroll_accel_steps), rand_int_range(cfg.scroll_decel_steps)
flick = rand_int_range((2, 5))
for i in range(notches):
if deadline is not None and deadline.expired():
break
step = notch * sign
await self.mouse.wheel(step if dx else 0, step if dy else 0)
if i == notches - 1:
break
flick -= 1
if flick <= 0:
# A finger lifts and turns the wheel again.
flick = rand_int_range((2, 5))
await sleep_ms(rand_range(cfg.scroll_pause_slow) + rand(40, 120))
elif i < accel or i >= notches - decel:
await sleep_ms(rand_range(cfg.scroll_pause_slow))
else:
await sleep_ms(rand_range(cfg.scroll_pause_fast))
# -- pointer ------------------------------------------------------------
async def _point(self, box: Dict[str, Any], position: Optional[dict], is_input: bool,
cfg: HumanConfig) -> Tuple[float, float]:
if position is not None:
return box["x"] + box["bl"] + position["x"], box["y"] + box["bt"] + position["y"]
vis = box["visible"]
if vis["width"] <= 0 or vis["height"] <= 0:
raise _Retry("element is outside of the viewport")
# Aim inside the visible part, keep the input bias for text fields.
full = click_target(box, is_input, cfg)
x = min(max(full.x, vis["x"] + 1), vis["x"] + vis["width"] - 1)
y = min(max(full.y, vis["y"] + 1), vis["y"] + vis["height"] - 1)
return x, y
@staticmethod
def _aim_point(box: Dict[str, Any], aim: tuple) -> Tuple[float, float]:
"""Exact aim points: a date-editor segment, or a fraction along a slider."""
if aim[0] == "segment":
seg = aim[1]
x, y = box["x"] + seg["dx"] + rand(-2, 2), box["y"] + seg["dy"] + rand(-2, 2)
else:
frac, vertical = aim[1], aim[2]
# Native thumbs are inset by about half their width (~8px) at both ends.
if vertical:
inset = min(8.0, box["height"] / 4)
y = box["y"] + box["height"] - inset - frac * (box["height"] - 2 * inset)
x = box["x"] + box["width"] / 2
else:
inset = min(8.0, box["width"] / 4)
x = box["x"] + inset + frac * (box["width"] - 2 * inset)
y = box["y"] + box["height"] / 2 + rand(-1.5, 1.5)
vis = box["visible"]
if not (vis["x"] <= x <= vis["x"] + vis["width"] and vis["y"] <= y <= vis["y"] + vis["height"]):
raise _Retry("element is outside of the viewport")
return x, y
async def _hit(self, r: Resolved, x: float, y: float) -> None:
ox, oy, clip = await self.worlds.frame_geometry(r.frame)
if not (clip["x"] <= x <= clip["x"] + clip["width"] and clip["y"] <= y <= clip["y"] + clip["height"]):
raise _Retry("element is outside of the viewport")
desc = await self.worlds.call(r.frame, "hit", r.id, x - ox, y - oy)
if desc is None and r.frame.parent_frame is not None:
desc = await self.worlds.owners_hit(r.frame, x, y)
if desc:
raise _Retry(f"{desc} intercepts pointer events")
async def pointer_action(self, api: str, target: Target, opts: dict,
states: Sequence[str], is_input_hint: Optional[bool] = None,
action: Optional[Callable[[Resolved, float, float, HumanConfig], Awaitable[None]]] = None,
hold: Optional[Sequence[str]] = None,
) -> Resolved:
"""Shared scroll -> move -> hit-check -> ``action`` sequence.
``hold``: modifier names (``Shift``, ``ControlOrMeta``...) held down while
the cursor travels to the target, as Playwright's ``hover(modifiers=)``."""
cfg = self.call_cfg(opts.get("human_config"))
if opts.get("steps"):
cfg = merge_config(cfg, {"mouse_min_steps": int(opts["steps"]), "mouse_max_steps": int(opts["steps"]),
"mouse_overshoot_chance": 0.0})
deadline = self.deadline(target, opts)
force = bool(opts.get("force"))
trial = bool(opts.get("trial"))
position = opts.get("position")
no_scroll = opts.get("scroll") == "none"
await self.ensure_cursor(cfg)
if not (opts.get("_nested") or opts.get("_deadline")):
await self.idle(cfg)
done: Dict[str, Any] = {}
async def attempt() -> Resolved:
r = await self._attempt_states(target, states, deadline, force)
box = await self.element_box(r) if no_scroll else await self.scroll_into_view(r, cfg, deadline)
if is_input_hint is None:
info = await self.worlds.call(r.frame, "info", r.id)
is_input = info["tag"] in ("input", "textarea") or info["editable"]
else:
is_input = is_input_hint
aim = opts.get("_aim")
if aim is not None:
x, y = self._aim_point(box, aim)
else:
x, y = await self._point(box, position, is_input, cfg)
if not force:
await self._hit(r, x, y)
if trial:
return r
keys = [await self._modifier(m) for m in (hold or [])]
for k in keys:
await self.keyboard.down(k)
try:
await self.move_to(x, y, cfg)
finally:
for k in reversed(keys):
await self.keyboard.up(k)
# The page may replace or remove the target while the cursor
# travels; never press on whatever is under it now (even with
# force). Locators re-resolve and run the whole move again.
if not await self.worlds.call(r.frame, "connected", r.id):
raise StaleElement()
if not force:
await self._hit(r, self.cursor.x, self.cursor.y)
done["is_input"] = is_input
if action is not None:
await action(r, self.cursor.x, self.cursor.y, cfg)
return r
return await self.retry(api, target, deadline, attempt)
async def press_mouse(self, cfg: HumanConfig, is_input: bool, button: str = "left",
click_count: int = 1, delay: Optional[float] = None,
modifiers: Optional[Sequence[str]] = None) -> None:
mods = [await self._modifier(m) for m in (modifiers or [])]
await sleep_ms(rand_range(cfg.click_aim_delay_input if is_input else cfg.click_aim_delay_button))
for m in mods:
await self.keyboard.down(m)
try:
for n in range(1, max(1, int(click_count)) + 1):
hold = delay if delay is not None else rand_range(cfg.click_hold_input if is_input else cfg.click_hold_button)
await self.mouse.down(button=button, clickCount=n)
await sleep_ms(hold)
await self.mouse.up(button=button, clickCount=n)
if n < click_count:
await sleep_ms(delay if delay is not None else rand(60, 140))
finally:
for m in reversed(mods):
await self.keyboard.up(m)
# -- public actions -----------------------------------------------------
async def click(self, target: Target, opts: dict, api: str = "click", click_count: Optional[int] = None) -> None:
count = click_count if click_count is not None else int(opts.get("click_count") or 1)
async def act(r: Resolved, x: float, y: float, cfg: HumanConfig) -> None:
info = await self.worlds.call(r.frame, "info", r.id)
is_input = info["tag"] in ("input", "textarea") or info["editable"]
await self.press_mouse(cfg, is_input, opts.get("button") or "left", count,
opts.get("delay"), opts.get("modifiers"))
await self.pointer_action(api, target, opts, CLICK_STATES, action=act)
async def hover(self, target: Target, opts: dict, api: str = "hover") -> None:
# Playwright holds the modifiers while the mouse moves onto the target.
await self.pointer_action(api, target, opts, HOVER_STATES, is_input_hint=False,
hold=opts.get("modifiers"))
async def focus_element(self, target: Target, opts: dict, api: str, cfg: HumanConfig,
deadline: _Deadline, require_focus: bool = True) -> Tuple[Resolved, bool]:
"""Focus by a human click unless already focused.
Returns ``(r, clicked_in)``: ``clicked_in`` is true when this call
clicked and focus landed in the target (the caret then sits wherever the
click put it). With ``require_focus=False`` (press / type) a click that
does not move focus is fine, as in Playwright: the keys then go to
whatever handles them (a <canvas>, document-level key handlers)."""
r = await self.wait_for(target, FOCUS_STATES, deadline, api, bool(opts.get("force")))
info = await self.worlds.call(r.frame, "info", r.id)
if info["focused"]:
return r, False
# A person focuses a field by clicking it: scroll it into view with the
# wheel and click. When it cannot be brought on screen at all (e.g.
# positioned at negative offsets) the click times out with "element is
# outside of the viewport" -- no silent programmatic focus.
await self.click(target, {"_deadline": deadline, "force": opts.get("force"),
"human_config": opts.get("human_config")}, api=api)
info = await self.worlds.call(r.frame, "info", r.id)
if info["focused"] or await self._focus_by_tab(r, info):
return r, True
if require_focus:
raise Error(f"{api}: Error: element did not receive focus when clicked "
f"(it may not be focusable): {info['preview']}")
return r, False
async def _focus_by_tab(self, r: Resolved, info: dict) -> bool:
"""The click landed but focus went elsewhere (a focusable child or a
label-less wrapper). Accept focus that moved into the target."""
act = await self.worlds.call(r.frame, "activeValue")
return bool(act.get("tag")) and await self.worlds.evaluate(
r.frame, f"(() => {{ const e = {HELPERS}.el({r.id}); const a = document.activeElement;"
f" return !!a && (e === a || e.contains(a)); }})()")
async def caret_to_end(self, r: Resolved) -> None:
info = await self.worlds.call(r.frame, "info", r.id)
if info["tag"] == "input":
if not await self.worlds.call(r.frame, "caretAtEnd", r.id):
await self.press_key("End")
else:
await self.press_key("Control+End")
await sleep_ms(rand(20, 60))
async def press(self, target: Target, key: str, opts: dict, api: str = "press") -> None:
cfg = self.call_cfg(opts.get("human_config"))
deadline = self.deadline(target, opts)
await self.focus_element(target, opts, api, cfg, deadline, require_focus=False)
await sleep_ms(rand(50, 150))
await self.press_key(await self._keys(key), delay=opts.get("delay"))
async def _keys(self, key: str) -> str:
if "ControlOrMeta" in key:
return key.replace("ControlOrMeta", await self._modifier("ControlOrMeta"))
return key
async def type(self, target: Target, text: str, opts: dict, api: str = "type") -> None:
cfg = self.call_cfg(opts.get("human_config"))
deadline = self.deadline(target, opts)
await sleep_ms(rand_range(cfg.field_switch_delay))
r, clicked_in = await self.focus_element(target, opts, api, cfg, deadline, require_focus=False)
if clicked_in:
await self.caret_to_end(r)
await sleep_ms(rand(100, 250))
info = await self.worlds.call(r.frame, "info", r.id)
await self.type_text(text, cfg, r.frame, info, opts.get("delay"))
async def fill(self, target: Target, value: str, opts: dict, api: str = "fill") -> None:
cfg = self.call_cfg(opts.get("human_config"))
deadline = self.deadline(target, opts)
force = bool(opts.get("force"))
r = await self.wait_for(target, INPUT_STATES, deadline, api, force)
info = await self.worlds.call(r.frame, "info", r.id)
kind = fields.classify(info["tag"], info["type"], info["editable"])
if kind == fields.NOT_FILLABLE:
raise Error(f"{api}: Error: {fields.not_fillable_message(info['tag'], info['type'])}")
if info["type"] == "number":
try:
value = fields.validate_number(value)
except fields.FieldKindError as exc:
raise Error(f"{api}: Error: {exc}") from None
if kind == fields.SET:
value = fields.normalize_set_value(info["type"], value)
await sleep_ms(rand_range(cfg.field_switch_delay))
if info["type"] == "range":
await self.set_range(target, r, value, opts, api, cfg, deadline)
elif info["type"] == "color":
await self.set_color(target, r, value, opts, api, cfg, deadline)
else:
await self.set_datetime(target, r, info["type"], value, opts, api, cfg, deadline)
return
await sleep_ms(rand_range(cfg.field_switch_delay))
r, _ = await self.focus_element(target, opts, api, cfg, deadline)
await sleep_ms(rand(100, 250))
info = await self.worlds.call(r.frame, "info", r.id)
if info["value"]:
await self.press_key(await self.select_all_key())
await sleep_ms(rand(30, 80))
await self.press_key("Backspace")
await sleep_ms(rand(50, 150))
info = await self.worlds.call(r.frame, "info", r.id)
if info["value"]:
# Some widgets ignore select-all; clear what is left by keys.
await self.caret_to_end(r)
for _ in range(len(info["value"])):
await self.press_key("Backspace")
await sleep_ms(rand(15, 40))
if value:
await self.type_text(value, cfg, r.frame, info, None)
async def set_checked(self, target: Target, checked: bool, opts: dict, api: str) -> None:
deadline = self.deadline(target, opts)
r = await self.wait_for(target, CLICK_STATES, deadline, api, bool(opts.get("force")))
state = await self.worlds.call(r.frame, "checked", r.id)
if "error" in state:
raise Error(f"{api}: Error: {state['error']}")
if state["checked"] == checked:
return
if state["radio"] and not checked:
raise Error(f"{api}: Error: Cannot uncheck radio button. Radio buttons can only be unchecked "
"by selecting another radio button in the same group.")
sub = {k: opts.get(k) for k in ("force", "position", "trial", "human_config")}
sub["_deadline"] = deadline
await self.click(target, sub, api=api)
if opts.get("trial"):
return
state = await self.worlds.call(r.frame, "checked", r.id)
if state.get("checked") != checked:
raise Error(f"{api}: Error: Clicking the checkbox did not change its state")
# -- value inputs: what a person does --------------------------------
async def set_datetime(self, target: Target, r: Resolved, input_type: str, value: str, opts: dict,
api: str, cfg: HumanConfig, deadline: _Deadline) -> None:
"""date / time / datetime-local / month / week: click the first segment
of the native editor and type each part with the keyboard, the way a
person fills these fields. Segment order comes from the editor itself,
so any locale works."""
parts = fields.datetime_parts(input_type, value)
if parts is None:
raise Error(f"{api}: Error: Malformed value")
segs = await self.worlds.editor_fields(r.frame, r.id)
kinds = [f["kind"] for f in segs]
parts = fields.use_24h(parts, "ampm" in kinds)
if not segs or any(k not in parts and k != "ampm" for k in kinds):
raise Error(f"{api}: Error: unsupported date/time editor layout {kinds}")
await self.pointer_action(api, target, {**opts, "_deadline": deadline, "position": None,
"_aim": ("segment", segs[0])},
INPUT_STATES, is_input_hint=True,
action=lambda rr, x, y, c: self.press_mouse(c, True))
await sleep_ms(rand(80, 200))
current = fields.datetime_parts(input_type, await self.worlds.call(r.frame, "value", r.id) or "")
for i, seg in enumerate(segs):
kind = seg["kind"]
if input_type == "month" and kind == "month" and current:
# Shown as a month name; over an existing month Chromium's digit
# matching is unreliable, so step with the arrow keys like a
# person scrolling through the names.
diff = int(parts["month"]) - int(current["month"])
for _ in range(abs(diff)):
await self.press_key("ArrowUp" if diff > 0 else "ArrowDown")
await sleep_ms(rand(60, 140))
elif kind == "ampm":
await self._type_char("P" if parts["hour24"] >= 12 else "A", cfg)
else:
digits = parts[kind]
for j, ch in enumerate(digits):
await self._type_char(ch, cfg)
if j < len(digits) - 1:
# The editor forgets a partial entry after ~1 s without
# a key; people type a number in one go, so keep the
# gap inside a segment short (pauses go between parts).
await sleep_ms(min(450.0, max(40.0, key_gap(cfg, ch, digits[j + 1]) - key_hold(cfg))))
if i < len(segs) - 1 and not fields.segment_auto_advances(kind, parts, input_type):
await self.press_key("ArrowRight")
await sleep_ms(rand(60, 160))
got = await self.worlds.call(r.frame, "value", r.id)
if got != value:
raise Error(f"{api}: Error: the date/time editor produced {got!r} instead of {value!r}")
async def set_range(self, target: Target, r: Resolved, value: str, opts: dict, api: str,
cfg: HumanConfig, deadline: _Deadline) -> None:
"""Slider: click on the track near the wanted value, then nudge with
arrow keys until it is exact."""
info = await self.worlds.call(r.frame, "rangeInfo", r.id)
try:
want = float(value)
except ValueError:
raise Error(f"{api}: Error: Malformed value") from None
span = info["max"] - info["min"]
frac = 0.5 if span <= 0 else min(1.0, max(0.0, (want - info["min"]) / span))
if info["rtl"] and not info["vertical"]:
frac = 1 - frac
await self.pointer_action(api, target, {**opts, "_deadline": deadline, "position": None,
"_aim": ("fraction", frac, info["vertical"])},
INPUT_STATES, is_input_hint=False,
action=lambda rr, x, y, c: self.press_mouse(c, False))
for _ in range(400):
cur = float(await self.worlds.call(r.frame, "value", r.id))
if abs(cur - want) < 1e-9 or (info["step"] and abs(cur - want) < info["step"] / 2):
break
up = cur < want
key = ("ArrowRight" if up else "ArrowLeft") if not info["rtl"] else ("ArrowLeft" if up else "ArrowRight")
await self.press_key(key)
await sleep_ms(rand(40, 110))
if float(await self.worlds.call(r.frame, "value", r.id)) == cur:
break # cannot move further (min/max reached)
got = await self.worlds.call(r.frame, "value", r.id)
if float(got) != float(await self._range_snap(r, want)):
raise Error(f"{api}: Error: slider stopped at {got} instead of {value}")
async def _range_snap(self, r: Resolved, want: float) -> float:
info = await self.worlds.call(r.frame, "rangeInfo", r.id)
v = min(info["max"], max(info["min"], want))
if info["step"]:
v = info["min"] + round((v - info["min"]) / info["step"]) * info["step"]
v = min(info["max"], v)
return v
async def set_color(self, target: Target, r: Resolved, value: str, opts: dict, api: str,
cfg: HumanConfig, deadline: _Deadline) -> None:
"""<input type=color> opens an OS colour dialog that page input cannot
drive. Playwright sets the value; doing that silently would hide a
robot-only path, so refuse instead."""
raise Error(f"{api}: Error: <input type=color> opens a native colour picker that cannot be "
"operated with human input; set it with page._original.fill(...) if a programmatic "
"value is acceptable")
async def select_option(self, target: Target, options: List[dict], element_handles: List[Any],
opts: dict, api: str = "select_option") -> List[str]:
"""Pick options like a person: a dropdown is opened with a click and
moved with arrow keys + Enter; a list box gets clicks on the options
(Ctrl/Cmd-click to add more in a multi-select)."""
cfg = self.call_cfg(opts.get("human_config"))
deadline = self.deadline(target, opts)
force = bool(opts.get("force"))
async def plan() -> Tuple[Resolved, dict]:
r = await self._attempt_states(target, ["visible", "enabled"], deadline, force)
ids = [(await self._resolve_handle(h, deadline)).id for h in element_handles]
res = await self.worlds.call(r.frame, "selectPlan", r.id, options, ids)
if "error" in res:
raise Error(res["error"])
if "retry" in res:
raise _Retry(res["retry"])
return r, res
r, p = await self.retry(api, target, deadline, plan)
if not p["listbox"]:
await self._select_dropdown(target, r, p["targets"][0] if p["targets"] else None, p, opts, api,
cfg, deadline)
else:
await self._select_listbox(target, r, p, opts, api, cfg, deadline)
try:
state = await self.worlds.call(r.frame, "selectState", r.id)
except StaleElement:
# The pick navigated the page (a form submitted on change): nothing
# left to read back, so report what was chosen, as Playwright does.
return p["values"]
if sorted(state["selected"]) != sorted(p["targets"]):
raise Error(f"{api}: Error: selection ended as {state['values']} instead of {p['values']}")
return state["values"]
async def _select_dropdown(self, target: Target, r: Resolved, index: Optional[int], p: dict, opts: dict,
api: str, cfg: HumanConfig, deadline: _Deadline) -> None:
if index is None:
raise Error(f"{api}: Error: a dropdown always keeps one option selected")
state = await self.worlds.call(r.frame, "selectState", r.id)
if state["current"] == index:
await self.hover(target, {"_deadline": deadline, "human_config": opts.get("human_config")}, api=api)
return
mac = sys.platform == "darwin" # a macOS host's native popup ignores arrow keys
plan = await self.worlds.call(r.frame, "typeAheadPlan", r.id, index, 0 if mac else 3)
if plan is None:
raise Error(f"{api}: Error: no option of this dropdown can be reached")
if mac and plan.get("home") and any(ord(c) > 0xFFFF for c in plan["label"]):
raise Error(f"{api}: Error: this option's label has an emoji or similar character that cannot be "
"typed, and a macOS dropdown ignores arrow keys, so it cannot be picked with human "
"input; select it with page._original.select_option(...) if a programmatic "
"selection is acceptable")
await self.click(target, {"_deadline": deadline, "force": opts.get("force"),
"human_config": opts.get("human_config")}, api=api)
await sleep_ms(rand(200, 450)) # popup opens; eyes find the option
if plan.get("home"):
# No name prefix is unique (duplicate labels): Home, then walk; on
# macOS type the whole label instead.
keys = list(plan["label"]) if mac else ["Home"] + [
"ArrowDown" if plan["arrows"] > 0 else "ArrowUp"] * abs(plan["arrows"])
else:
keys = list(plan["typed"]) + ["ArrowDown" if plan["arrows"] > 0 else "ArrowUp"] * abs(plan["arrows"])
# The browser joins letters typed within ~1s into one search: let an
# earlier select_option's search lapse before starting a new one.
if keys and len(keys[0]) == 1:
wait = self._typeahead_until - time.monotonic()
if wait > 0:
await sleep_ms(wait * 1000)
typed = False
for k in keys:
if deadline.expired():
await self.press_key("Escape")
raise TimeoutError(f"{api}: Timeout {deadline.timeout:.0f}ms exceeded.\n"
f"Call log:\n - choosing an option of {target.description}")
if len(k) == 1:
await self._typeahead_char(k, cfg)
typed = True
self._typeahead_until = time.monotonic() + 1.1
await sleep_ms(rand(60, 160))
else:
await self.press_key(k)
await sleep_ms(rand(90, 200))
await sleep_ms(rand(100, 250))
await self.press_key("Enter")
if typed:
self._typeahead_until = time.monotonic() + 1.1
await sleep_ms(rand(80, 160))
async def _typeahead_char(self, ch: str, cfg: HumanConfig) -> None:
"""One type-ahead letter. Letters go lowercase (a person doesn't hold Shift
to search a list); a character without a US-layout key is sent as itself."""
if ch.isascii():
await self._type_char(ch, cfg)
return
session = await self.worlds.session()
await session.send("Input.dispatchKeyEvent", {"type": "keyDown", "key": ch, "text": ch, "unmodifiedText": ch})
await sleep_ms(key_hold(cfg))
await session.send("Input.dispatchKeyEvent", {"type": "keyUp", "key": ch})
async def _select_listbox(self, target: Target, r: Resolved, p: dict, opts: dict, api: str,
cfg: HumanConfig, deadline: _Deadline) -> None:
state = await self.worlds.call(r.frame, "selectState", r.id)
want = list(p["targets"])
if sorted(state["selected"]) == sorted(want):
await self.hover(target, {"_deadline": deadline, "human_config": opts.get("human_config")}, api=api)
return
add_key = "Meta" if await self.is_mac() else "Control"
first = True
if not want:
raise Error(f"{api}: Error: deselecting every option is not something a click can do")
for idx in want:
oid = await self.worlds.call(r.frame, "optionId", r.id, idx)
opt = Target(r.frame, None)
opt.description = f"option #{idx}"
res = Resolved(r.frame, oid)
# Plain click on the first option replaces the selection; further
# options are added with Ctrl/Cmd held, as a person does.
mods = [] if first else [add_key]
await self._click_resolved(res, opt, cfg, deadline, api, mods)
first = False
await sleep_ms(rand(120, 300))
async def _click_resolved(self, r: Resolved, label: Target, cfg: HumanConfig, deadline: _Deadline,
api: str, modifiers: Sequence[str]) -> None:
"""Scroll / move / hit-check / click an already resolved element."""
async def attempt() -> None:
box = await self.scroll_into_view(r, cfg, deadline)
x, y = await self._point(box, None, False, cfg)
await self._hit(r, x, y)
await self.move_to(x, y, cfg)
await self._hit(r, self.cursor.x, self.cursor.y)
await self.press_mouse(cfg, False, "left", 1, None, modifiers)
await self.retry(api, label, deadline, attempt)
async def drag(self, source: Target, dest: Target, opts: dict, api: str = "drag_and_drop") -> None:
sub = {"_deadline": self.deadline(source, opts), "force": opts.get("force"), "human_config": opts.get("human_config"),
"position": opts.get("source_position"), "trial": opts.get("trial")}
await self.idle(self.call_cfg(opts.get("human_config")))
await self.hover(source, sub, api=api)
if opts.get("trial"):
await self.hover(dest, {**sub, "position": opts.get("target_position"), "_nested": True}, api=api)
return
await sleep_ms(rand(100, 200))
await self.mouse.down()
try:
await sleep_ms(rand(80, 150))
await self.hover(dest, {**sub, "position": opts.get("target_position"), "force": True, "_nested": True},
api=api)
await sleep_ms(rand(80, 150))
finally:
await self.mouse.up()
async def focus(self, target: Target, opts: dict, api: str = "focus", move: bool = False) -> None:
deadline = self.deadline(target, opts)
if move:
await self.hover(target, {"timeout": opts.get("timeout"), "human_config": opts.get("human_config")}, api=api)
r = await self.retry(api, target, deadline, lambda: self.resolve(target, deadline))
await self.worlds.call(r.frame, "focus", r.id)
async def scroll_into_view_if_needed(self, target: Target, opts: dict) -> None:
cfg = self.call_cfg(opts.get("human_config"))
deadline = self.deadline(target, opts)
await self.ensure_cursor(cfg)
async def attempt() -> None:
r = await self._attempt_states(target, ["visible", "stable"], deadline, False)
await self.scroll_into_view(r, cfg, deadline)
await self.retry("scroll_into_view_if_needed", target, deadline, attempt)
# -- keyboard -----------------------------------------------------------
async def type_text(self, text: str, cfg: HumanConfig, frame: Any, info: Optional[dict],
delay: Optional[float]) -> None:
"""Type ``text`` key by key into the focused element of ``frame``."""
tag = info.get("tag") if info else None
mistypes = cfg.mistype_chance > 0 and tag is not None and fields.allows_mistype(tag, info.get("type"))
typist = _Typist(self, cfg, delay)
try:
for ch in text:
if mistypes and ch.isascii() and ch.isalnum() and random.random() < cfg.mistype_chance:
await self._typo(typist, ch, cfg, frame)
await typist.key(ch)
await typist.finish()
finally:
await typist.release_all()
async def _typo(self, typist: "_Typist", ch: str, cfg: HumanConfig, frame: Any) -> None:
lower = ch.lower()
if lower not in NEARBY_KEYS:
return
wrong = random.choice(NEARBY_KEYS[lower])
if ch.isupper():
wrong = wrong.upper()
await typist.finish()
before = await self._active_value(frame)
await typist.key(wrong)
await typist.rest()
await sleep_ms(rand_range(cfg.mistype_delay_notice))
after = await self._active_value(frame)
if before is not None and after == before:
# The page rejected the wrong key (mask / filter): nothing to undo.
await sleep_ms(rand_range(cfg.mistype_delay_correct))
return
await typist.key("Backspace")
await typist.rest()
await sleep_ms(rand_range(cfg.mistype_delay_correct))
async def _active_value(self, frame: Any) -> Optional[str]:
try:
return (await self.worlds.call(frame, "activeValue")).get("value")
except Error:
return None
async def _type_char(self, ch: str, cfg: HumanConfig) -> None:
"""One key on its own (date editors, type-ahead)."""
typist = _Typist(self, cfg)
try:
await typist.key(ch)
await typist.finish()
finally:
await typist.release_all()
async def press_key(self, key: str, cfg: Optional[HumanConfig] = None, delay: Optional[float] = None) -> None:
"""``keyboard.press`` with a human key-down time (Playwright's own
press releases the key in the same instant)."""
await self.keyboard.press(key, delay=delay if delay is not None else key_hold(cfg or self.cfg))
async def keyboard_type(self, text: str, delay: Optional[float] = None) -> None:
"""``page.keyboard.type``: no target; mistypes only where safe."""
frame = self.page.main_frame
try:
info = await self.worlds.call(frame, "activeValue")
except Error:
info = None
if info and info.get("tag") == "iframe":
info = None # focus is inside a child frame: value checks impossible
await self.type_text(text, self.cfg, frame, info if info and info.get("tag") else None, delay)
# -- raw mouse API ------------------------------------------------------
async def mouse_move(self, x: float, y: float, steps: Optional[int] = None) -> None:
if steps is not None:
await self.ensure_cursor()
await self.mouse.move(x, y, steps=steps)
self.cursor.x, self.cursor.y = x, y
return
await self.move_to(x, y, self.cfg)
self.cursor.x, self.cursor.y = x, y
async def mouse_click(self, x: float, y: float, delay: Optional[float] = None,
button: Optional[str] = None, click_count: Optional[int] = None) -> None:
await self.move_to(x, y, self.cfg)
self.cursor.x, self.cursor.y = x, y
await self.press_mouse(self.cfg, False, button or "left", click_count or 1, delay)