Files
Brandon LiandClaude Opus 5 b748f95372 Add automation framework, typing, and runner for the autobuyer
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>
2026-08-28 14:00:33 -05:00

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)