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>
224 lines
9.3 KiB
Python
224 lines
9.3 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 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())
|