#!/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 import captcha import focus from clicker import Dashboard, DashboardError, NotFoundError, force_utf8_output 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.19.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_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | None = None) -> None: """Dispatch a single step. Raises StepError / DashboardError on failure.""" 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
confirms it is really there. target = step.get("url", "") landed = dash.locate("body", 0, step.get("urlPattern", "") or opts.url, opts.timeout, step.get("openUrl", "") or "", navigate_url=target) # Navigating to a protected page is where an expired session shows up: # the site answers with a redirect to its login form. actions.check_signed_in(landed, step.get("signedOut", "") or "") report(f"tab is on {landed.get('url', target)}") elif step["action"] == "waitFor": # A gate, not an action: block until the element exists — or, with # `absent`, until it is gone. Nothing is clicked or typed. Whatever has to # change the page — a person solving a challenge, a modal closing itself, # a slow server — happens outside this run. # # `hidden` widens an absent gate from "removed from the DOM" to "no # longer visible": a modal that closes by going display:none never stops # matching its selector, so the plain gate can only ever time out. selector = step["selector"] absent = bool(step.get("absent")) by_sight = absent and bool(step.get("hidden")) goal = "disappear from view" if by_sight else "disappear" if absent else "appear" timeout_s = float(step.get("timeoutSeconds", 120)) deadline = time.time() + timeout_s announced = False while True: # Long gates must still honour the dashboard's Stop button. if run_id is not None and run_status(dash, run_id) == "cancelled": raise actions.StepError("cancelled while waiting") # A frame-scoped gate resolves the selector inside the named