Turns the autobuyer from a page scraper into something that acts. A dashboard button queues a run; a desktop process executes it against the real browser. lib/automations.ts — automations are declarative step lists nested inside the firm whose site they drive. Steps are click / type / wait / navigate, and they inherit the firm's tab pattern and URL, so one firm's automation can't act on another's tab. Adding a button means adding an entry here; the page renders buttons from the API and the runner receives steps from the server, so neither needs editing. Runs key on firm:automation — every firm will plausibly have its own "buy-accounts", and a bare id would resolve to the wrong one. clicker/runner.py — the daemon behind the buttons. Claims a queued run, works through the steps, reports each one back for the page's live log. Only one run executes at a time: two processes driving one physical mouse would interleave clicks. Heartbeats on its own thread, because a step can block for tens of seconds and folding the beat into the main loop would show the runner as offline in the middle of the run it was executing. clicker/actions.py — one implementation of the safety checks, shared by the CLI and the runner. Refuses to act when the element is covered by an overlay, when coordinates fall off-screen, when the browser can't be confirmed frontmost, or (for type) when the target isn't an editable field. Typing: uneven human cadence, and the field is read back afterwards and compared against what was typed — a field that never took focus fails silently and looks identical to success otherwise. Non-ASCII is rejected because pyautogui skips those characters without complaint, and newlines because Enter may submit the form. Typos are deliberately not simulated: a mistyped digit in a trading form is a real loss, and the correction is the part that can go wrong. Extension: opens the firm's page when no tab matches, navigates to a specific page for a navigate step (skipped when already there, so page state survives), and retries the locate while a freshly loaded React app mounts — `complete` only means the document loaded. Staleness reporting, after it cost three debugging rounds: Chrome doesn't reload an unpacked extension and Python doesn't reload a running process, so both now report their version. A stale runner gets a red banner naming both versions and the automation buttons are disabled, rather than failing mid-run on a step type it predates. Scale detection is now conservative: a raw OS/browser width ratio is only trusted when it lands on a real scaling factor. On this multi-monitor desktop the previous logic would have silently halved every coordinate. Verified end to end against the live browser: navigate, locate, and a real click (run #12, all three steps). API round-trips, claim-once semantics, run cancellation, the heartbeat online/offline lifecycle, motion geometry and timing, focus activation, and typing verification all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
185 lines
7.0 KiB
Python
185 lines
7.0 KiB
Python
"""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)
|