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>
245 lines
10 KiB
Python
245 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Desktop mouse control for the AutoFirmer autobuyer.
|
|
|
|
The browser extension can see the DOM but can't move the mouse; this script can
|
|
move the mouse but can't see inside Chrome. They meet at the dashboard API:
|
|
|
|
clicker --POST /api/autobuyer/locate--> "where is button.buy?"
|
|
extension raises the window, scrolls the
|
|
element into view, measures it
|
|
clicker --GET /api/autobuyer/locate--> desktop x,y
|
|
clicker moves the real mouse and clicks
|
|
|
|
Usage:
|
|
python clicker.py locate "button.buy" # measure only, no clicking
|
|
python clicker.py click "button.buy" # measure, then really click
|
|
python clicker.py click ".btn" --index 2 --url "https://tradeify.co/*"
|
|
python clicker.py click "button.buy" --dry-run # move the cursor, don't press
|
|
|
|
Requires the AutoBuyer page switch to be ON — that's the master arming switch.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import random
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
import actions
|
|
import focus
|
|
|
|
DEFAULT_API = "http://localhost:3000"
|
|
POLL_INTERVAL = 0.25
|
|
|
|
|
|
class DashboardError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class Dashboard:
|
|
"""Thin client for the autobuyer endpoints."""
|
|
|
|
def __init__(self, base: str, timeout: float = 10.0):
|
|
self.base = base.rstrip("/")
|
|
self.timeout = timeout
|
|
|
|
def _request(self, path: str, method: str = "GET", payload: dict | None = None) -> dict:
|
|
data = json.dumps(payload).encode() if payload is not None else None
|
|
headers = {"Content-Type": "application/json"} if data else {}
|
|
req = urllib.request.Request(self.base + path, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
return json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as exc:
|
|
body = exc.read().decode(errors="replace")
|
|
try:
|
|
message = json.loads(body).get("error", body)
|
|
except json.JSONDecodeError:
|
|
message = body
|
|
raise DashboardError(f"{method} {path} -> HTTP {exc.code}: {message}") from None
|
|
except urllib.error.URLError as exc:
|
|
raise DashboardError(f"Cannot reach {self.base} — {exc.reason}") from None
|
|
|
|
def status(self) -> dict:
|
|
return self._request("/api/autobuyer/status")
|
|
|
|
def locate(self, selector: str, index: int, url_pattern: str, timeout: float,
|
|
open_url: str = "", navigate_url: str = "") -> dict:
|
|
"""Queue a lookup and block until the extension answers it.
|
|
|
|
`open_url` is the page the extension should open if no tab matches
|
|
`url_pattern` — a match pattern isn't navigable, so it's passed separately.
|
|
"""
|
|
queued = self._request(
|
|
"/api/autobuyer/locate",
|
|
"POST",
|
|
{"selector": selector, "index": index, "urlPattern": url_pattern,
|
|
"openUrl": open_url, "navigateUrl": navigate_url},
|
|
)
|
|
request_id = queued["id"]
|
|
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
row = self._request(f"/api/autobuyer/locate?id={request_id}")
|
|
if row["status"] == "done":
|
|
return row["result"]
|
|
if row["status"] == "error":
|
|
raise DashboardError(f"Extension could not locate it: {row['error']}")
|
|
time.sleep(POLL_INTERVAL)
|
|
|
|
raise DashboardError(
|
|
f"No answer within {timeout:g}s. Is the extension installed, is Chrome "
|
|
f"running, and is the AutoBuyer switch ON?"
|
|
)
|
|
|
|
|
|
def describe(found: dict, factor: float) -> str:
|
|
x, y = found["screen"]["x"] * factor, found["screen"]["y"] * factor
|
|
lines = [
|
|
f" matched <{found['tag']}> {found['text']!r}"
|
|
+ (f" (1 of {found['matchCount']})" if found["matchCount"] > 1 else ""),
|
|
f" page {found['url']}",
|
|
f" viewport x={found['viewport']['x']:.0f} y={found['viewport']['y']:.0f}"
|
|
f" {found['viewport']['width']:.0f}x{found['viewport']['height']:.0f}",
|
|
f" desktop x={x:.0f} y={y:.0f}" + (f" (scale {factor:g})" if factor != 1 else ""),
|
|
]
|
|
if found.get("covered"):
|
|
lines.append(f" WARNING something is on top of it: {found['coveredBy']}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("action", choices=["locate", "click", "type"],
|
|
help="locate = measure only; click = measure then click; "
|
|
"type = click the field, then type into it")
|
|
parser.add_argument("selector", help="CSS selector of the target element")
|
|
parser.add_argument("--index", type=int, default=0, help="which match, if the selector hits several (default 0)")
|
|
parser.add_argument("--url", default="", help='Chrome match pattern for the tab, e.g. "https://tradeify.co/*"')
|
|
parser.add_argument("--open-url", default="", help="page to open if no tab matches --url")
|
|
parser.add_argument("--api", default=DEFAULT_API, help=f"dashboard URL (default {DEFAULT_API})")
|
|
parser.add_argument("--timeout", type=float, default=45.0, help="seconds to wait for the extension (default 45; a cold page load takes time)")
|
|
parser.add_argument("--scale", type=float, default=None, help="CSS-to-desktop pixel ratio (default: auto-detect)")
|
|
parser.add_argument("--dry-run", action="store_true", help="move the cursor to the target but do not press")
|
|
parser.add_argument("--force", action="store_true", help="click even if something is covering the element")
|
|
parser.add_argument("--robotic", action="store_true",
|
|
help="straight-line move and instant click, skipping the human motion model")
|
|
parser.add_argument("--seed", type=int, default=None,
|
|
help="seed the motion RNG so a run is reproducible (for debugging)")
|
|
parser.add_argument("--no-activate", action="store_true",
|
|
help="do not raise the browser first (the click may be eaten by window activation)")
|
|
parser.add_argument("--text", default=None, help="text to type (action=type)")
|
|
parser.add_argument("--stdin", action="store_true",
|
|
help="read the text to type from stdin instead of --text, keeping it out of shell history")
|
|
parser.add_argument("--clear", action="store_true",
|
|
help="select-all and delete before typing, rather than appending at the caret")
|
|
parser.add_argument("--allow-enter", action="store_true",
|
|
help="permit newlines in the text (each one presses Enter, which may submit the form)")
|
|
parser.add_argument("--no-verify", action="store_true",
|
|
help="skip reading the field back after typing")
|
|
args = parser.parse_args()
|
|
|
|
# Anything that moves the mouse or presses a key needs pyautogui; check once,
|
|
# up front, so a missing dependency reports itself rather than surfacing as an
|
|
# ImportError from somewhere deeper.
|
|
if args.action in ("click", "type"):
|
|
try:
|
|
import pyautogui # noqa: F401
|
|
except ImportError:
|
|
print("error: pyautogui is not installed — run: pip install -r requirements.txt",
|
|
file=sys.stderr)
|
|
return 1
|
|
|
|
# ── resolve and vet the text before touching anything ───────────────────
|
|
text = ""
|
|
if args.action == "type":
|
|
if args.stdin:
|
|
text = sys.stdin.read()
|
|
elif args.text is not None:
|
|
text = args.text
|
|
else:
|
|
print("error: type needs --text or --stdin", file=sys.stderr)
|
|
return 1
|
|
if not text:
|
|
print("error: nothing to type", file=sys.stderr)
|
|
return 1
|
|
|
|
import humanize as _h
|
|
bad = _h.untypeable(text)
|
|
if bad:
|
|
print(f"error: pyautogui cannot type {''.join(bad)!r} and would silently "
|
|
f"skip those characters, leaving a truncated value in the field.",
|
|
file=sys.stderr)
|
|
return 1
|
|
if "\n" in text and not args.allow_enter:
|
|
print("error: the text contains a newline, which presses Enter and may "
|
|
"submit the form. Pass --allow-enter if that is intended.", file=sys.stderr)
|
|
return 1
|
|
|
|
dash = Dashboard(args.api)
|
|
|
|
try:
|
|
if not dash.status().get("enabled"):
|
|
print("AutoBuyer capture is OFF — turn it on from the dashboard first.", file=sys.stderr)
|
|
return 2
|
|
except DashboardError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"Locating {args.selector!r} …", flush=True)
|
|
|
|
# `locate` never touches the mouse, so it stays a plain lookup.
|
|
if args.action == "locate":
|
|
try:
|
|
found = dash.locate(args.selector, args.index, args.url, args.timeout, args.open_url)
|
|
except DashboardError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
print(describe(found, actions.scale_factor(found, args.scale)))
|
|
return 0
|
|
|
|
rng = random.Random(args.seed) if args.seed is not None else random.Random()
|
|
|
|
try:
|
|
actions.perform(
|
|
dash,
|
|
args.action,
|
|
args.selector,
|
|
index=args.index,
|
|
url=args.url,
|
|
open_url=args.open_url,
|
|
text=text,
|
|
clear=args.clear,
|
|
scale=args.scale,
|
|
timeout=args.timeout,
|
|
rng=rng,
|
|
activate=not args.no_activate,
|
|
verify=not args.no_verify,
|
|
force=args.force,
|
|
dry_run=args.dry_run,
|
|
robotic=args.robotic,
|
|
on_located=lambda f: print(describe(f, actions.scale_factor(f, args.scale))),
|
|
report=lambda m: print(f" {m}"),
|
|
)
|
|
except DashboardError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
except actions.StepError as exc:
|
|
print(f"\nerror: {exc}", file=sys.stderr)
|
|
return exc.code
|
|
|
|
if args.dry_run:
|
|
print("\ndry run — cursor moved, nothing pressed or typed.")
|
|
elif args.action == "click":
|
|
print("\nclicked.")
|
|
else:
|
|
shown = text if len(text) <= 60 else text[:57] + "\u2026"
|
|
print(f"\ntyped {shown!r}" + (" (field cleared first)" if args.clear else ""))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|