#!/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 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) -> dict: """Queue a lookup and block until the extension answers it.""" queued = self._request( "/api/autobuyer/locate", "POST", {"selector": selector, "index": index, "urlPattern": url_pattern}, ) 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 scale_factor(found: dict, override: float | None) -> float: """CSS pixels and desktop pixels are the same on macOS and on unscaled Windows, but Windows display scaling and some Linux setups break that. Compare the screen size the browser reports against the one the OS reports.""" if override is not None: return override try: import pyautogui except ImportError: return 1.0 # `locate` is useful without the mouse library installed os_width = pyautogui.size().width css_width = (found.get("screenSize") or {}).get("width") if not css_width: return 1.0 ratio = os_width / css_width # Only trust a clean-ish ratio; anything odd means a multi-monitor layout we # shouldn't guess at, so fall back to 1:1 and let --scale override. return ratio if 0.4 < ratio < 4.0 else 1.0 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"], help="locate = measure only; click = measure then click") 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("--api", default=DEFAULT_API, help=f"dashboard URL (default {DEFAULT_API})") parser.add_argument("--timeout", type=float, default=20.0, help="seconds to wait for the extension (default 20)") 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)") args = parser.parse_args() 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 print(f"Locating {args.selector!r} …", flush=True) found = dash.locate(args.selector, args.index, args.url, args.timeout) except DashboardError as exc: print(f"error: {exc}", file=sys.stderr) return 1 if args.action == "locate": print(describe(found, scale_factor(found, args.scale))) return 0 if found.get("covered") and not args.force: print(describe(found, 1.0)) print("\nRefusing to click: the element is covered — the click would hit " f"{found['coveredBy']} instead. Pass --force to click anyway.", file=sys.stderr) return 3 try: import pyautogui except ImportError: print("error: pyautogui is not installed — run: pip install -r requirements.txt", file=sys.stderr) return 1 # Slamming the cursor into a screen corner aborts the script. pyautogui.FAILSAFE = True factor = scale_factor(found, args.scale) x = found["screen"]["x"] * factor y = found["screen"]["y"] * factor screen_w, screen_h = pyautogui.size() if not (0 <= x < screen_w and 0 <= y < screen_h): print(describe(found, factor)) print(f"\nerror: target ({x:.0f}, {y:.0f}) is off-screen ({screen_w}x{screen_h}). " f"Is the Chrome window partly off the display, or on a second monitor?", file=sys.stderr) return 3 print(describe(found, factor)) # A click on a background window is eaten by the window manager activating it, # so the first attempt silently does nothing. Raise the browser first, and do it # here rather than before locating: the measurement takes seconds, and focus is # only required at the moment of the press. if not args.no_activate: focused = focus.ensure_frontmost() print(f" focus {focused.detail}") if not focused.ok: print("\nRefusing to click: the browser is not frontmost, so the click " "would be consumed activating its window instead of pressing the " "element. Pass --no-activate to override.", file=sys.stderr) return 3 if args.robotic: pyautogui.moveTo(x, y, duration=0.25) if not args.dry_run: pyautogui.click() else: import humanize rng = random.Random(args.seed) if args.seed is not None else random.Random() humanize.click(x, y, rng=rng, press=not args.dry_run) if args.dry_run: print("\ndry run — cursor moved, no click sent.") else: print("\nclicked.") return 0 if __name__ == "__main__": sys.exit(main())