Step vocabulary gains `waitFor`: block until a selector exists, then continue. Nothing is clicked or typed — it is a gate for conditions something outside the run has to satisfy. Unlike every other step it carries no signed-out guard, because the things worth gating on often sit on the login page, where that guard would abort the run at exactly the wrong moment. Honours the dashboard's Stop button, since a two-minute gate that ignored it would be worse than no gate. Session handling. A run that lands on the login page must not continue: once redirected, every selector resolves against a login form, so a click aimed at "Add Account" hits whatever that form renders in the same place. Runs now detect the redirect and stop before sending any input, with a distinct SignedOutError rather than a generic failure. Two ways out of that state, in order: a firm's `authSteps` run and the failed step is retried, or — when none are defined — the run pauses for signedOutWaitSeconds so a human can sign in, then resumes. Auth steps are verified rather than trusted: they can all "succeed" while the site still rejects the sign-in, so the session is re-checked before the retry, and the run stops with "auth steps ran but the session is still signed out" if it did not take. That check polls for up to 20s instead of reading once. Submitting a login form starts a network round trip and then a redirect, so the tab still shows the login URL for a second or two afterwards; checking immediately failed a sign-in that was merely in flight, killing run #15 nine seconds after it had actually worked. Third instance of the same mistake in this system — reading page state immediately after an action that triggers async navigation. The runner reports its version in the heartbeat and the dashboard blocks the buttons when it is behind. A running Python process does not reload when the source changes, so a stale runner fails on step types it predates; that cost a debugging round when a navigate step reached a runner that had never heard of one. lib/automations.ts carries the Tradeify buy-accounts flow: navigate to the dashboard, open Add Account, pick the account type and size, enter the account name, and work through the challenge widget before submitting. Selectors are authored by hand against the live page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
11 KiB
Python
247 lines
11 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("--signed-out", default="", help="URL substring identifying the login page; abort if the tab lands there")
|
|
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,
|
|
signed_out=args.signed_out,
|
|
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())
|