"""Human-like cursor motion for pyautogui. A straight-line teleport followed by an instant click is not just conspicuous — it is unreliable. Plenty of web UIs only arm a control once it has actually been hovered (dropdowns, custom widgets, tooltip-gated buttons), and a cursor that arrives and presses in the same tick can beat the page's own mousemove handlers. So the motion here does what a hand does: accelerates out, coasts, decelerates in along a slightly curved path, occasionally overshoots and corrects, pauses a beat before pressing, and holds the button down for a human interval. """ import math import random import sys import time import pyautogui # pyautogui sleeps PAUSE seconds after *every* call. With a stepped path that # would add tens of seconds, so we take over timing entirely. pyautogui.PAUSE = 0 # Motion feel. Durations in seconds, distances in pixels. MIN_DURATION = 0.15 MAX_DURATION = 1.70 CURVE_STRENGTH = 0.18 # lateral bow, as a fraction of travel distance TREMOR = 0.7 # sub-pixel hand tremor OVERSHOOT_ABOVE = 260.0 # only long throws overshoot OVERSHOOT_CHANCE = 0.55 DWELL = (0.06, 0.17) # settle after arriving, before pressing HOLD = (0.055, 0.12) # how long the button stays down def _ease(t: float) -> float: """Smootherstep: zero velocity at both ends, quick through the middle.""" return t * t * t * (t * (t * 6 - 15) + 10) def _bezier(p0, p1, p2, p3, t): """Cubic Bezier — the bow that keeps the path off a dead-straight line.""" u = 1 - t return ( u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0], u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1], ) def _duration_for(distance: float, rng: random.Random) -> float: """Farther costs more, but sub-linearly — pointing time grows roughly with the square root of distance over the range a screen covers. Calibrated so a nudge of ~40px takes ~0.2s and a throw across a large display takes ~0.8s; a log curve here would spend a full second creeping 40 pixels.""" base = 0.10 + 0.018 * math.sqrt(distance) return max(MIN_DURATION, min(MAX_DURATION, base * rng.uniform(0.85, 1.2))) def _glide(start, end, duration: float, rng: random.Random) -> None: """One curved, eased sweep from start to end.""" dx, dy = end[0] - start[0], end[1] - start[1] distance = math.hypot(dx, dy) if distance < 1: return # Control points pushed perpendicular to the direction of travel, so the path # bows to one side the way an arm swings rather than tracking a ruler. nx, ny = -dy / distance, dx / distance bow = distance * CURVE_STRENGTH * rng.uniform(-1, 1) c1 = (start[0] + dx * 0.3 + nx * bow, start[1] + dy * 0.3 + ny * bow) c2 = (start[0] + dx * 0.7 + nx * bow * rng.uniform(0.4, 1.0), start[1] + dy * 0.7 + ny * bow * rng.uniform(0.4, 1.0)) steps = max(14, min(95, int(distance / 5))) step_time = duration / steps next_at = time.perf_counter() for i in range(1, steps + 1): t = _ease(i / steps) x, y = _bezier(start, c1, c2, end, t) # Tremor fades out as we close in, so the landing stays accurate. if i < steps: decay = 1 - (i / steps) x += rng.gauss(0, TREMOR) * decay y += rng.gauss(0, TREMOR) * decay pyautogui.moveTo(x, y, duration=0, _pause=False) next_at += step_time slack = next_at - time.perf_counter() if slack > 0: time.sleep(slack) def move(x: float, y: float, rng: random.Random | None = None) -> None: """Move the cursor to (x, y) the way a hand would.""" rng = rng or random.Random() start = pyautogui.position() distance = math.hypot(x - start[0], y - start[1]) if distance < 1: return duration = _duration_for(distance, rng) # A long throw usually lands slightly past the mark and gets pulled back. if distance > OVERSHOOT_ABOVE and rng.random() < OVERSHOOT_CHANCE: angle = math.atan2(y - start[1], x - start[0]) + rng.uniform(-0.35, 0.35) past = rng.uniform(6, 16) overshoot = (x + math.cos(angle) * past, y + math.sin(angle) * past) _glide(start, overshoot, duration * 0.82, rng) time.sleep(rng.uniform(0.02, 0.06)) _glide(pyautogui.position(), (x, y), rng.uniform(0.10, 0.19), rng) else: _glide(start, (x, y), duration, rng) # Land exactly on target — accumulated float error must not cost us the click. pyautogui.moveTo(x, y, duration=0, _pause=False) # Typing rhythm. KEY_DELAY = (0.045, 0.130) # between consecutive keystrokes WORD_PAUSE = (0.050, 0.170) # extra beat after a space THINK_CHANCE = 0.045 # occasional longer pause mid-string THINK_PAUSE = (0.22, 0.55) # pyautogui.write() can only emit characters it has a keycode for — roughly # printable ASCII. Anything else is *silently skipped*, so we reject it up front # rather than typing a quietly truncated string into a form. TYPEABLE = frozenset(chr(c) for c in range(32, 127)) | {"\t"} def untypeable(text: str) -> list[str]: """Characters pyautogui would silently drop. Empty list means safe to type.""" return sorted({c for c in text if c not in TYPEABLE and c != "\n"}) def clear_field(rng: random.Random | None = None) -> None: """Select-all then delete, in the focused field.""" rng = rng or random.Random() modifier = "command" if sys.platform == "darwin" else "ctrl" pyautogui.hotkey(modifier, "a", _pause=False) time.sleep(rng.uniform(0.05, 0.12)) pyautogui.press("delete", _pause=False) time.sleep(rng.uniform(0.05, 0.12)) def type_text(text: str, rng: random.Random | None = None) -> None: """Type with a human cadence: uneven keystrokes, a beat after each word, and the occasional pause. Deliberately does NOT simulate typos — a mistyped digit in a trading form that fails to get corrected is a real loss, and the correction is exactly the part that can go wrong.""" rng = rng or random.Random() for ch in text: if ch == "\n": pyautogui.press("enter", _pause=False) else: pyautogui.write(ch, _pause=False) delay = rng.uniform(*KEY_DELAY) if ch == " ": delay += rng.uniform(*WORD_PAUSE) if rng.random() < THINK_CHANCE: delay += rng.uniform(*THINK_PAUSE) time.sleep(delay) def click(x: float, y: float, rng: random.Random | None = None, press: bool = True) -> None: """Move to (x, y), settle, then press and release. With press=False the cursor travels and dwells but no button event is sent. """ rng = rng or random.Random() move(x, y, rng) # A beat between arriving and pressing: this is what lets hover handlers, # CSS transitions and lazily-armed controls catch up before the press. time.sleep(rng.uniform(*DWELL)) if not press: return pyautogui.mouseDown(_pause=False) time.sleep(rng.uniform(*HOLD)) pyautogui.mouseUp(_pause=False)