Builds the pipeline the autobuyer needs: see the page, find an element, click it. extension/ — MV3 Chromium extension. Polls /api/autobuyer/status and, while on, scrapes the target tab's HTML and posts it back. Also serves locate requests: focuses the window, scrolls the element into view, and reports its position. host_permissions is scoped to tradeify plus localhost so it cannot read other sites — an empty target pattern would otherwise capture whatever tab happened to be active, including banking or mail. app/api/autobuyer/ — status toggle, capture store, and the locate request queue. CORS is open because the extension's origin changes every time an unpacked extension is reloaded. app/autobuyer/page.tsx — ON switch, source view (default) and a rendered view. The render uses sandbox="allow-scripts" without allow-same-origin: the page's own JS is needed because sites ship content at opacity:0 and fade it in, but the frame must not reach the dashboard's same-origin API routes, which serve firm credentials. clicker/ — Python CLI. Asks the extension where a selector is, adds the element rect to the window's screen position and the browser chrome height to get desktop coordinates, then clicks with a human motion model (curved path, eased velocity, occasional overshoot, dwell before press). Raises the browser application first, since macOS consumes a click on an unfocused window rather than delivering it. Refuses to click when the element is covered by an overlay, when the coordinates fall off-screen, or when the browser cannot be confirmed frontmost. Verified: API round-trips, capture pruning, locate claim-once semantics, motion geometry and timing, and focus activation — the last two against stubs, since pyautogui and pyobjc are not installed here. NOT verified end to end: Chrome is still running a stale build of the extension, so a locate request has never completed against a real page and no real click has been sent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
5.1 KiB
Python
136 lines
5.1 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 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)
|
|
|
|
|
|
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)
|