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>
210 lines
8.3 KiB
Python
210 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Automation runner — the process that makes dashboard buttons do something.
|
|
|
|
Leave this running. It polls the dashboard for queued runs, and when one appears
|
|
it works through that automation's steps, driving the real mouse and keyboard,
|
|
reporting each step back so the page can show progress.
|
|
|
|
python runner.py # against http://localhost:3000
|
|
python runner.py --api http://vps:3000
|
|
|
|
Steps come from the server, so adding a new button means editing
|
|
lib/automations.ts — nothing here needs to change.
|
|
|
|
Stop with Ctrl-C. A run in progress can be halted from the dashboard's Stop
|
|
button; the runner notices between steps.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import random
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import actions
|
|
from clicker import Dashboard, DashboardError
|
|
|
|
POLL_SECONDS = 1.0
|
|
HEARTBEAT_SECONDS = 2.0
|
|
|
|
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
|
|
# the heartbeat so the dashboard can say "restart your runner" instead of letting
|
|
# a stale process fail on a step type it has never heard of.
|
|
VERSION = "0.4.0"
|
|
|
|
# Shared with the heartbeat thread: whether a run is currently executing.
|
|
_busy = threading.Event()
|
|
|
|
|
|
def heartbeat_loop(dash: Dashboard, opts, stop: threading.Event) -> None:
|
|
"""Check in on our own thread.
|
|
|
|
Deliberately not folded into the main poll loop: a single step can block for
|
|
twenty seconds waiting on the extension, and a runner that goes quiet that
|
|
long would show as offline in the middle of the run it is executing.
|
|
"""
|
|
payload = {"host": socket.gethostname(), "pid": os.getpid(),
|
|
"dryRun": opts.dry_run, "version": VERSION}
|
|
while not stop.is_set():
|
|
try:
|
|
dash._request("/api/autobuyer/runner", "POST", {**payload, "busy": _busy.is_set()})
|
|
except DashboardError:
|
|
pass # the main loop reports connectivity; don't double up
|
|
stop.wait(HEARTBEAT_SECONDS)
|
|
|
|
|
|
def run_steps(dash: Dashboard, run: dict, opts) -> None:
|
|
"""Work through one automation. Reports every step; stops on the first
|
|
failure, because a half-completed purchase flow should not barrel on."""
|
|
run_id = run["id"]
|
|
steps = run["steps"]
|
|
rng = random.Random(opts.seed) if opts.seed is not None else random.Random()
|
|
|
|
print(f"\n▶ run #{run_id} — {run['label']} ({len(steps)} steps)")
|
|
|
|
for i, step in enumerate(steps):
|
|
label = step.get("label") or f"{step['action']} {step.get('selector', '')}".strip()
|
|
print(f" [{i + 1}/{len(steps)}] {label}")
|
|
|
|
detail_parts: list[str] = []
|
|
|
|
def report(message: str) -> None:
|
|
detail_parts.append(message)
|
|
print(f" {message}")
|
|
|
|
try:
|
|
if step["action"] == "wait":
|
|
time.sleep(float(step.get("seconds", 1)))
|
|
report(f"waited {step.get('seconds', 1)}s")
|
|
elif step["action"] == "navigate":
|
|
# No mouse involved: the extension points the tab at the page and
|
|
# waits for it to load. Locating <body> confirms it's really there.
|
|
target = step.get("url", "")
|
|
dash.locate("body", 0, step.get("urlPattern", "") or opts.url,
|
|
opts.timeout, step.get("openUrl", "") or "",
|
|
navigate_url=target)
|
|
report(f"tab is on {target}")
|
|
elif step["action"] not in ("click", "type"):
|
|
# Almost always a stale runner: the server defines the step
|
|
# vocabulary, so a step type this process has never heard of means
|
|
# automations.ts has moved on and this file hasn't been restarted.
|
|
raise actions.StepError(
|
|
f"unknown step action {step['action']!r} — this runner is "
|
|
f"v{VERSION}; restart it to pick up newer step types"
|
|
)
|
|
else:
|
|
actions.perform(
|
|
dash,
|
|
step["action"],
|
|
step["selector"],
|
|
index=int(step.get("index", 0)),
|
|
url=step.get("urlPattern", "") or opts.url,
|
|
open_url=step.get("openUrl", "") or "",
|
|
text=step.get("text"),
|
|
clear=bool(step.get("clear")),
|
|
scale=opts.scale,
|
|
timeout=opts.timeout,
|
|
rng=rng,
|
|
activate=not opts.no_activate,
|
|
dry_run=opts.dry_run,
|
|
report=report,
|
|
)
|
|
except (actions.StepError, DashboardError) as exc:
|
|
print(f" FAILED: {exc}", file=sys.stderr)
|
|
post_progress(dash, run_id, i, label, False, str(exc))
|
|
finish(dash, run_id, str(exc))
|
|
print(f"✗ run #{run_id} stopped at step {i + 1}")
|
|
return
|
|
|
|
status = post_progress(dash, run_id, i + 1, label, True, "; ".join(detail_parts) or None)
|
|
|
|
# The dashboard's Stop button shows up here.
|
|
if status == "cancelled":
|
|
print(f"■ run #{run_id} cancelled from the dashboard")
|
|
return
|
|
|
|
finish(dash, run_id, None)
|
|
print(f"✓ run #{run_id} complete")
|
|
|
|
|
|
def post_progress(dash, run_id, step_index, step, ok, detail):
|
|
try:
|
|
res = dash._request("/api/autobuyer/runs/progress", "POST", {
|
|
"id": run_id, "stepIndex": step_index, "step": step, "ok": ok, "detail": detail,
|
|
})
|
|
return res.get("status")
|
|
except DashboardError as exc:
|
|
print(f" (could not report progress: {exc})", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def finish(dash, run_id, error):
|
|
try:
|
|
dash._request("/api/autobuyer/runs/progress", "POST",
|
|
{"id": run_id, "finish": True, "error": error})
|
|
except DashboardError as exc:
|
|
print(f" (could not report completion: {exc})", file=sys.stderr)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL")
|
|
parser.add_argument("--url", default="", help="fallback Chrome match pattern for steps that omit one")
|
|
parser.add_argument("--timeout", type=float, default=45.0, help="seconds to wait for the extension per step (a cold page load takes time)")
|
|
parser.add_argument("--scale", type=float, default=None, help="CSS-to-desktop pixel ratio")
|
|
parser.add_argument("--seed", type=int, default=None, help="seed the motion RNG (debugging)")
|
|
parser.add_argument("--dry-run", action="store_true", help="move the cursor through the steps but never press or type")
|
|
parser.add_argument("--no-activate", action="store_true", help="do not raise the browser before each step")
|
|
opts = parser.parse_args()
|
|
|
|
try:
|
|
import pyautogui # noqa: F401
|
|
except ImportError:
|
|
print("error: pyautogui is not installed — run: pip install -r requirements.txt", file=sys.stderr)
|
|
return 1
|
|
|
|
dash = Dashboard(opts.api)
|
|
print(f"Runner watching {opts.api}" + (" [DRY RUN — nothing will be pressed]" if opts.dry_run else ""))
|
|
print("Waiting for a run. Press a button on the AutoBuyer page. Ctrl-C to stop.")
|
|
|
|
stop = threading.Event()
|
|
beat = threading.Thread(target=heartbeat_loop, args=(dash, opts, stop), daemon=True)
|
|
beat.start()
|
|
|
|
idle_warned = False
|
|
while True:
|
|
try:
|
|
claim = dash._request("/api/autobuyer/runs/claim", "POST", {})
|
|
except DashboardError as exc:
|
|
if not idle_warned:
|
|
print(f" ({exc})", file=sys.stderr)
|
|
idle_warned = True
|
|
time.sleep(POLL_SECONDS * 3)
|
|
continue
|
|
|
|
idle_warned = False
|
|
run = claim.get("run")
|
|
if not run:
|
|
time.sleep(POLL_SECONDS)
|
|
continue
|
|
|
|
_busy.set()
|
|
try:
|
|
run_steps(dash, run, opts)
|
|
except Exception as exc: # keep the daemon alive
|
|
print(f"✗ run failed unexpectedly: {exc}", file=sys.stderr)
|
|
finish(dash, run["id"], f"runner error: {exc}")
|
|
finally:
|
|
_busy.clear()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except KeyboardInterrupt:
|
|
print("\nstopped.")
|
|
sys.exit(0)
|