Files
autofirmer-expanded/clicker/runner.py
T
Brandon LiandClaude Fable 5 b1dccd7dec Revamp login guards into general per-firm page guards
Replace the single hardcoded signed-out check with a guards list on each
firm: any guard can trip mid-run via a URL substring (checked on every
step) or a selector probe (checked when a step fails). Each guard defines
recovery steps or a human-wait fallback, a per-run trigger cap, and a
resume policy — 'continue' retries the tripped step, 'restart' rewinds
the run to step 1.

Runner bumped to 0.20.0; the server still ships the legacy
signedOut/authSteps shape for stale runners, and a new runner synthesizes
a guard from a legacy payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 12:50:14 -05:00

573 lines
24 KiB
Python

#!/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.
# 0.20.0: page guards — run-level `guards` replace per-step signedOut/authSteps.
VERSION = "0.20.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, guards: list | None = None) -> None:
"""Dispatch a single step. Raises StepError / DashboardError on failure,
GuardTripped when a URL guard's condition shows up on the page.
`guards` is empty for recovery steps: they run while a guard's condition
holds, so checking it again would trip on the very page they exist to fix.
"""
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 <body> 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_guards(landed, guards)
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 <iframe>;
# `hidden` adds the visibility probe. Either can be set independently.
loc_opts: dict = {}
if by_sight:
loc_opts["op"] = "visible"
if step.get("frame"):
loc_opts["frame"] = step["frame"]
try:
seen = dash.locate(selector, int(step.get("index", 0)),
step.get("urlPattern", "") or opts.url,
opts.timeout, step.get("openUrl", "") or "",
options=loc_opts or None)
# A guard page appearing mid-gate would otherwise burn the whole
# gate timeout before anyone noticed. GuardTripped is not caught
# below, so it escapes the wait as it should.
actions.check_guards(seen, guards)
if by_sight:
# An extension too old for the probe quietly does an ordinary
# locate instead, whose result has no `visible` key — reading
# that as "gone" would wave the gate through on a visible
# element, so insist the answer is the one we asked for.
if seen.get("op") != "visible":
raise actions.StepError(
"waitFor `hidden` needs AutoBuyer extension 0.12.0+ — "
"reload it in chrome://extensions"
)
if not seen.get("visible"):
report(f"{selector} is out of sight ({seen.get('reason') or 'hidden'})")
break
elif not absent:
report(f"{selector} appeared")
break
# Still there. Keep waiting for it to go.
except NotFoundError:
if absent:
report(f"{selector} is gone")
break
# Not there yet; keep waiting for it to arrive.
except DashboardError:
# A dead extension or an unreachable dashboard must not be read
# as "the element is gone" — that would satisfy an absent gate
# for entirely the wrong reason.
pass
if time.time() >= deadline:
raise actions.StepError(
f"{selector} did not {goal} within {timeout_s:.0f}s"
)
if not announced:
report(f"waiting for {selector} to {goal} (up to {timeout_s:.0f}s)")
announced = True
time.sleep(2.0)
elif step["action"] == "solveCaptcha":
# No mouse: the extension reads the widget, the dashboard buys a token
# from 2captcha, and the extension hands it to the page's own callback.
captcha.solve(
dash, step, opts, report,
cancelled=(lambda: run_status(dash, run_id) == "cancelled") if run_id is not None else None,
)
elif step["action"] == "scrollToLoad":
# Lists that load progressively need walking to the bottom before the
# steps that act on their items can see everything.
result = dash.locate(
step["selector"], 0,
step.get("urlPattern", "") or opts.url,
max(opts.timeout, 120.0), # scrolling a long list outlasts a normal step
step.get("openUrl", "") or "",
options={
"op": "scrollToLoad",
"containerSelector": step.get("containerSelector", ""),
"maxScrolls": int(step.get("maxScrolls", 25)),
"settleMs": int(step.get("settleMs", 800)),
},
)
found_n = result.get("after", 0)
report(f"{found_n} match(es) after {result.get('scrolls', 0)} scroll(s) "
f"of {result.get('container', '?')} (was {result.get('before', 0)})")
if not result.get("exhausted"):
# Stopping on the scroll cap is not a failure, but it does mean the
# list may still have more below — worth saying so rather than
# letting a later step quietly work on a partial list.
report("hit the scroll limit — there may be more not loaded")
elif step["action"] not in ("click", "type"):
# Almost always a stale runner: the server defines the step vocabulary,
# so a step type this process has never heard of means automations.ts has
# moved on and this file has not been restarted.
raise actions.StepError(
f"unknown step action {step['action']!r} — this runner is "
f"v{VERSION}; restart it to pick up newer step types"
)
else:
try:
actions.perform(
dash,
step["action"],
step["selector"],
index=int(step.get("index", 0)),
url=step.get("urlPattern", "") or opts.url,
open_url=step.get("openUrl", "") or "",
guards=guards,
frame=step.get("frame", "") or "",
text=step.get("text"),
clear=bool(step.get("clear")),
scale=opts.scale,
timeout=opts.timeout,
rng=rng,
activate=not opts.no_activate,
dry_run=opts.dry_run,
report=report,
)
except NotFoundError:
# Only absence is skippable. A covered element, an off-screen target
# or an unreachable extension still fails the run.
if not step.get("skipIfNotFound"):
raise
report(f"{step['selector']} not present — skipped")
def run_status(dash: Dashboard, run_id: int) -> str | None:
"""Current status, so a wait can notice the dashboard's Stop button."""
try:
return dash._request(f"/api/autobuyer/runs?id={run_id}")["run"]["status"]
except (DashboardError, KeyError):
return None
def guard_present(dash: Dashboard, guard: dict, step: dict, opts,
probe_timeout: float = 6.0) -> bool | None:
"""Is the guard's condition on the page right now? None = could not tell.
A URL guard reads the tab's URL off a cheap body locate; a selector guard
needs a real DOM probe, whose timeout is kept short — a guard element that
exists shows up immediately, so most of the timeout is only ever spent
confirming absence. A guard defining both trips when either is present.
"""
pattern = step.get("urlPattern", "") or opts.url
open_url = step.get("openUrl", "") or ""
if guard.get("urlContains"):
try:
landed = dash.locate("body", 0, pattern, opts.timeout, open_url)
except DashboardError:
return None # tab busy mid-redirect; the caller decides
if guard["urlContains"] in (landed.get("url") or ""):
return True
if not guard.get("selector"):
return False
if guard.get("selector"):
try:
dash.locate(guard["selector"], 0, pattern, probe_timeout, open_url)
return True
except NotFoundError:
return False
except DashboardError:
return None
return False
def guard_cleared(dash: Dashboard, guard: dict, step: dict, opts,
settle_seconds: float = 20.0) -> bool:
"""Did recovery actually clear the condition?
Recovery steps can 'succeed' — every click landing — while the page stays in
the guarded state, so this confirms rather than assumes before resuming.
It polls rather than checking once: submitting a login form starts a network
round trip and then a redirect, so the tab is still sitting on the guard
page for a second or two afterwards. Checking immediately reports a failure
for a recovery that is merely still in flight.
"""
deadline = time.time() + settle_seconds
while True:
if guard_present(dash, guard, step, opts) is False:
return True
if time.time() >= deadline:
return False
time.sleep(1.0)
def wait_for_clear(dash: Dashboard, guard: dict, step: dict, run_id: int, opts,
deadline: float) -> bool:
"""Block until a human deals with the guard's condition.
The runner does not act here — for the login guard, entering credentials is
the human's job, and a keystroke aimed at the wrong field is not something
to risk automating. All this does is watch the page and pick the run back
up once the condition is gone.
"""
while time.time() < deadline:
if run_status(dash, run_id) == "cancelled":
return False
time.sleep(3.0)
if guard_present(dash, guard, step, opts) is False:
return True
return False
def find_tripped_guard(dash: Dashboard, guards: list, step: dict, opts) -> dict | None:
"""After a step failure: is a guard's condition what actually broke it?
URL guards are caught in flight by check_guards; selector guards cost a
locate round trip each, so they are only probed once a step has already
failed — a session-expired modal explains a NotFound far better than a
wrong selector does.
"""
for guard in guards:
if not guard.get("selector"):
continue # URL-only guards were already checked in flight
if guard_present(dash, guard, step, opts):
return guard
return None
def legacy_guards(run: dict) -> list:
"""Guards synthesized from the pre-guard payload shape, so this runner still
works against a dashboard that predates run-level guards."""
for step in run.get("steps") or []:
if step.get("signedOut"):
return [{
"id": "signed-out",
"label": "signed out",
"urlContains": step["signedOut"],
"resume": "continue",
"waitSeconds": float(step.get("signedOutWait", 0) or 0),
"recoverySteps": run.get("authSteps") or [],
}]
return []
def handle_guard(dash: Dashboard, run_id: int, step_index: int, label: str,
guard: dict, step: dict, opts, rng, report,
trigger_counts: dict) -> str:
"""A guard tripped mid-run: recover, then say where the run picks up.
Returns "retry" (redo the step that tripped), "restart" (back to step 1),
"cancelled", or "fail" — the terminal outcomes have already been reported
and the run finished by the time they are returned.
"""
gid = guard.get("id") or "guard"
name = guard.get("label") or gid
def fail(msg: str) -> str:
post_progress(dash, run_id, step_index, label, False, msg)
finish(dash, run_id, msg)
print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
return "fail"
# A guard whose recovery "works" without clearing the cause would trip
# again on the next step, forever. Cap it per run, not per step: a restart
# revisits every step, so a per-step cap would never fill up.
trigger_counts[gid] = trigger_counts.get(gid, 0) + 1
limit = int(guard.get("maxTriggers") or 3)
if trigger_counts[gid] > limit:
return fail(f"{name} guard tripped {trigger_counts[gid]} times in one run — giving up")
recovery = guard.get("recoverySteps") or []
if recovery:
post_progress(dash, run_id, step_index,
f"{name} guard tripped — running {len(recovery)} recovery step(s)",
False, None)
try:
for rstep in recovery:
rlabel = rstep.get("label") or f"recovery: {rstep['action']}"
print(f" [{name}] {rlabel}")
# No guards passed: recovery runs on the guard page itself.
run_one_step(dash, rstep, opts, rng, report, run_id)
except (actions.StepError, DashboardError) as exc:
return fail(f"{name} guard recovery failed: {exc}")
if not guard_cleared(dash, guard, step, opts):
return fail(f"{name} guard recovery ran but the condition is still on the page")
report(f"{name} guard cleared")
else:
wait_seconds = float(guard.get("waitSeconds") or 0)
if wait_seconds <= 0:
return fail(f"{name} guard tripped and defines no recovery — stopping")
mins = wait_seconds / 60
note = f"{name} guard tripped — waiting up to {mins:.0f} min for you to deal with it"
print(f" {note}", file=sys.stderr)
post_progress(dash, run_id, step_index, note, False, None)
if not wait_for_clear(dash, guard, step, run_id, opts, time.time() + wait_seconds):
if run_status(dash, run_id) == "cancelled":
print(f"■ run #{run_id} cancelled while waiting on the {name} guard")
return "cancelled"
return fail(f"{name} guard still tripped after {mins:.0f} min")
report(f"{name} guard cleared")
return "restart" if (guard.get("resume") or "continue") == "restart" else "retry"
def run_steps(dash: Dashboard, run: dict, opts) -> None:
"""Work through one automation. Reports every step; stops on the first
failure, because a half-completed purchase flow should not barrel on.
Page guards can interrupt any step. Once one is handled, its `resume`
policy decides where the run picks up: "continue" retries the step that
tripped, "restart" goes back to the first step — hence a while loop over
the step index rather than a plain for."""
run_id = run["id"]
steps = run["steps"]
guards = run.get("guards")
if guards is None:
guards = legacy_guards(run)
rng = random.Random(opts.seed) if opts.seed is not None else random.Random()
print(f"\n▶ run #{run_id} — {run['label']} ({len(steps)} steps)")
trigger_counts: dict[str, int] = {}
i = 0
while i < len(steps):
step = steps[i]
label = step.get("label") or f"{step['action']} {step.get('selector', '')}".strip()
print(f" [{i + 1}/{len(steps)}] {label}")
detail_parts: list[str] = []
def report(message: str) -> None:
detail_parts.append(message)
print(f" {message}")
outcome = None
try:
run_one_step(dash, step, opts, rng, report, run_id, guards=guards)
except actions.GuardTripped as exc:
print(f" {exc}", file=sys.stderr)
outcome = handle_guard(dash, run_id, i, label, exc.guard, step,
opts, rng, report, trigger_counts)
except (actions.StepError, DashboardError) as exc:
if run_status(dash, run_id) == "cancelled":
print(f"■ run #{run_id} cancelled from the dashboard")
return
# Before writing the failure off, see whether a selector guard's
# condition is sitting on the page — an expired-session modal
# explains a missing button better than a wrong selector does.
tripped = find_tripped_guard(dash, guards, step, opts) if guards else None
if tripped is not None:
name = tripped.get("label") or tripped.get("id")
print(f" step failed with the {name} guard's condition on the page",
file=sys.stderr)
outcome = handle_guard(dash, run_id, i, label, tripped, step,
opts, rng, report, trigger_counts)
else:
print(f" FAILED: {exc}", file=sys.stderr)
post_progress(dash, run_id, i, label, False, str(exc))
finish(dash, run_id, str(exc))
print(f"✗ run #{run_id} stopped at step {i + 1}")
return
if outcome == "retry":
continue # same step, another go
if outcome == "restart":
print(f" ↺ restarting run #{run_id} from step 1")
i = 0
continue
if outcome in ("fail", "cancelled"):
return # handle_guard already reported and finished
status = post_progress(dash, run_id, i + 1, label, True, "; ".join(detail_parts) or None)
# The dashboard's Stop button shows up here.
if status == "cancelled":
print(f"■ run #{run_id} cancelled from the dashboard")
return
i += 1
finish(dash, run_id, None)
print(f"✓ run #{run_id} complete")
def post_progress(dash, run_id, step_index, step, ok, detail):
try:
res = dash._request("/api/autobuyer/runs/progress", "POST", {
"id": run_id, "stepIndex": step_index, "step": step, "ok": ok, "detail": detail,
})
return res.get("status")
except DashboardError as exc:
print(f" (could not report progress: {exc})", file=sys.stderr)
return None
def finish(dash, run_id, error):
try:
dash._request("/api/autobuyer/runs/progress", "POST",
{"id": run_id, "finish": True, "error": error})
except DashboardError as exc:
print(f" (could not report completion: {exc})", file=sys.stderr)
def main() -> int:
force_utf8_output()
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL")
parser.add_argument("--url", default="", help="fallback Chrome match pattern for steps that omit one")
parser.add_argument("--timeout", type=float, default=45.0, help="seconds to wait for the extension per step (a cold page load takes time)")
parser.add_argument("--scale", type=float, default=None, help="CSS-to-desktop pixel ratio")
parser.add_argument("--seed", type=int, default=None, help="seed the motion RNG (debugging)")
parser.add_argument("--dry-run", action="store_true", help="move the cursor through the steps but never press or type")
parser.add_argument("--no-activate", action="store_true", help="do not raise the browser before each step")
opts = parser.parse_args()
try:
import pyautogui # noqa: F401
except ImportError:
print("error: pyautogui is not installed — run: pip install -r requirements.txt", file=sys.stderr)
return 1
# Before anything asks the OS how big the screen is. No-op off Windows.
dpi = focus.enable_dpi_awareness()
if dpi:
print(f" {dpi}")
dash = Dashboard(opts.api)
print(f"Runner watching {opts.api}" + (" [DRY RUN — nothing will be pressed]" if opts.dry_run else ""))
print("Waiting for a run. Press a button on the AutoBuyer page. Ctrl-C to stop.")
stop = threading.Event()
beat = threading.Thread(target=heartbeat_loop, args=(dash, opts, stop), daemon=True)
beat.start()
idle_warned = False
while True:
try:
claim = dash._request("/api/autobuyer/runs/claim", "POST", {})
except DashboardError as exc:
if not idle_warned:
print(f" ({exc})", file=sys.stderr)
idle_warned = True
time.sleep(POLL_SECONDS * 3)
continue
idle_warned = False
run = claim.get("run")
if not run:
time.sleep(POLL_SECONDS)
continue
_busy.set()
try:
run_steps(dash, run, opts)
except Exception as exc: # keep the daemon alive
print(f"✗ run failed unexpectedly: {exc}", file=sys.stderr)
finish(dash, run["id"], f"runner error: {exc}")
finally:
_busy.clear()
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nstopped.")
sys.exit(0)