diff --git a/app/api/autobuyer/runs/claim/route.ts b/app/api/autobuyer/runs/claim/route.ts
index f028a6f..e643604 100644
--- a/app/api/autobuyer/runs/claim/route.ts
+++ b/app/api/autobuyer/runs/claim/route.ts
@@ -1,5 +1,5 @@
import { claimRun, getSetting } from '@/lib/db';
-import { findAutomation, resolveSteps } from '@/lib/automations';
+import { findAutomation, resolveSteps, resolveAuthSteps } from '@/lib/automations';
import { corsJson, corsPreflight } from '../../cors';
/** The Python runner polls this. Returns the run plus the steps to execute, so
@@ -24,6 +24,7 @@ export async function POST() {
firm: found.firm.label,
label: found.automation.label,
steps: resolveSteps(found.firm, found.automation),
+ authSteps: resolveAuthSteps(found.firm),
},
});
}
diff --git a/app/autobuyer/page.tsx b/app/autobuyer/page.tsx
index 3cf7540..7f1466e 100644
--- a/app/autobuyer/page.tsx
+++ b/app/autobuyer/page.tsx
@@ -301,16 +301,7 @@ export default function AutoBuyer() {
? 'Runner offline'
: runner.busy ? 'Runner busy' : 'Runner online'}
- {runner?.online && runner.stale && (
-
- The runner is out of date (reporting v{runner.version ?? 'unknown'}, this
- dashboard needs v{runner.expectedVersion}). It will fail on step types it
- predates. Restart it: stop with Ctrl-C and run{' '}
- python clicker/runner.py again.
-
- )}
-
- {runner?.online && runner.dryRun && (
+ {runner?.online && runner.dryRun && (
dry run
diff --git a/clicker/actions.py b/clicker/actions.py
index 31ec81e..57b3d60 100644
--- a/clicker/actions.py
+++ b/clicker/actions.py
@@ -14,7 +14,7 @@ class StepError(Exception):
"""A step refused to run, or ran and could not be verified.
`code` mirrors the CLI's exit codes: 3 = refused before acting,
- 4 = acted but verification failed.
+ 4 = acted but verification failed, 5 = the session is signed out.
"""
def __init__(self, message: str, code: int = 3):
@@ -22,6 +22,35 @@ class StepError(Exception):
self.code = code
+class SignedOutError(StepError):
+ """The tab is on the login page, so the session has expired.
+
+ Distinct from an ordinary failure because nothing is wrong with the
+ automation — it just cannot proceed until a human signs in.
+ """
+
+ def __init__(self, url: str):
+ super().__init__(
+ f"signed out — the tab is on {url}. Log in to the site, then run this again.",
+ code=5,
+ )
+
+
+def check_signed_in(found: dict, signed_out: str) -> None:
+ """Abort if the page redirected to a login screen.
+
+ Worth doing before every action, not just at the start: a session can expire
+ mid-run, and once it does, every selector resolves against a login form. A
+ click or a keystroke aimed at the old page would land on whatever that form
+ happens to render in the same place.
+ """
+ if not signed_out:
+ return
+ url = found.get("url") or ""
+ if signed_out in url:
+ raise SignedOutError(url)
+
+
# Display scaling factors that actually exist. Anything else means the two sides
# are describing different things (usually a multi-monitor desktop) rather than a
# scaled single display.
@@ -73,6 +102,7 @@ def perform(
index: int = 0,
url: str = "",
open_url: str = "",
+ signed_out: str = "",
text: str | None = None,
clear: bool = False,
scale: float | None = None,
@@ -101,6 +131,9 @@ def perform(
if on_located:
on_located(found)
+ # Before any check that assumes we are on the real page.
+ check_signed_in(found, signed_out)
+
if found.get("covered") and not force:
raise StepError(
f"{selector} is covered by {found.get('coveredBy')} — the click would hit that instead"
@@ -163,6 +196,7 @@ def perform(
# Typing into a field that never took focus, or that ignores the input, fails
# silently and is otherwise indistinguishable from success.
after = dash.locate(selector, index, url, timeout, open_url)
+ check_signed_in(after, signed_out)
got = after.get("value")
if got is None:
say("element exposes no value to verify against")
diff --git a/clicker/clicker.py b/clicker/clicker.py
index 2569b16..ecf2d08 100644
--- a/clicker/clicker.py
+++ b/clicker/clicker.py
@@ -119,6 +119,7 @@ def main() -> int:
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)")
@@ -210,6 +211,7 @@ def main() -> int:
index=args.index,
url=args.url,
open_url=args.open_url,
+ signed_out=args.signed_out,
text=text,
clear=args.clear,
scale=args.scale,
diff --git a/clicker/runner.py b/clicker/runner.py
index 168167e..c7ff2bd 100644
--- a/clicker/runner.py
+++ b/clicker/runner.py
@@ -32,7 +32,7 @@ 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.4.0"
+VERSION = "0.9.0"
# Shared with the heartbeat thread: whether a run is currently executing.
_busy = threading.Event()
@@ -55,11 +55,155 @@ def heartbeat_loop(dash: Dashboard, opts, stop: threading.Event) -> None:
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. Nothing is
+ # clicked or typed. Whatever has to make it appear — a person solving a
+ # challenge, a slow server, a background job — happens outside this run.
+ selector = step["selector"]
+ 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")
+
+ try:
+ dash.locate(selector, int(step.get("index", 0)),
+ step.get("urlPattern", "") or opts.url,
+ opts.timeout, step.get("openUrl", "") or "")
+ report(f"{selector} appeared")
+ break
+ except DashboardError:
+ pass # not there yet, or the tab is mid-render
+
+ if time.time() >= deadline:
+ raise actions.StepError(
+ f"{selector} did not appear within {timeout_s:.0f}s"
+ )
+ if not announced:
+ report(f"waiting for {selector} (up to {timeout_s:.0f}s)")
+ announced = True
+ time.sleep(2.0)
+
+ 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:
+ 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 "",
+ signed_out=step.get("signedOut", "") 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,
+ )
+
+
+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 wait_for_sign_in(dash: Dashboard, step: dict, run_id: int, opts, deadline: float) -> bool:
+ """Block until the tab leaves the login page.
+
+ The runner does not log in — 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 tab and pick the run back up once you are through.
+ """
+ signed_out = step.get("signedOut", "") or ""
+ pattern = step.get("urlPattern", "") or opts.url
+ open_url = step.get("openUrl", "") or ""
+
+ while time.time() < deadline:
+ if run_status(dash, run_id) == "cancelled":
+ return False
+ time.sleep(3.0)
+ try:
+ landed = dash.locate("body", 0, pattern, opts.timeout, open_url)
+ except DashboardError:
+ continue # tab busy mid-redirect; look again shortly
+ if signed_out not in (landed.get("url") or ""):
+ return True
+
+ return False
+
+
+def signed_in_now(dash: Dashboard, step: dict, opts, settle_seconds: float = 20.0) -> bool:
+ """Did we actually get back in?
+
+ Auth steps can 'succeed' — every click landing — while the site still rejects
+ the sign-in, so this confirms rather than assumes before the failed step is
+ retried.
+
+ 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 login URL
+ for a second or two afterwards. Checking immediately reports a failure for a
+ sign-in that is merely still in flight.
+ """
+ signed_out = step.get("signedOut", "") or ""
+ if not signed_out:
+ return True
+
+ deadline = time.time() + settle_seconds
+ while True:
+ try:
+ landed = dash.locate("body", 0, step.get("urlPattern", "") or opts.url,
+ opts.timeout, step.get("openUrl", "") or "")
+ if signed_out not in (landed.get("url") or ""):
+ return True
+ except DashboardError:
+ pass # mid-redirect the tab can be briefly un-injectable
+
+ if time.time() >= deadline:
+ return False
+ time.sleep(1.0)
+
+
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."""
run_id = run["id"]
steps = run["steps"]
+ auth_steps = run.get("authSteps") or []
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)")
@@ -74,49 +218,73 @@ def run_steps(dash: Dashboard, run: dict, opts) -> None:
detail_parts.append(message)
print(f" {message}")
- try:
- 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's really there.
- target = step.get("url", "")
- dash.locate("body", 0, step.get("urlPattern", "") or opts.url,
- opts.timeout, step.get("openUrl", "") or "",
- navigate_url=target)
- report(f"tab is on {target}")
- 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 hasn't 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:
- 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 "",
- 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 (actions.StepError, DashboardError) as exc:
- 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
+ # One retry after a sign-in: the session can only be expired once per step.
+ for attempt in range(2):
+ try:
+ run_one_step(dash, step, opts, rng, report, run_id)
+ break
+
+ except actions.SignedOutError as exc:
+ wait_seconds = float(step.get("signedOutWait", 0) or 0)
+
+ # Scripted auth first, if the firm defines any; otherwise fall
+ # back to pausing for a human. Only ever attempted once per step —
+ # auth that "succeeds" without signing in would otherwise loop.
+ if auth_steps and attempt == 0:
+ post_progress(dash, run_id, i, f"signed out — running {len(auth_steps)} auth step(s)",
+ False, exc.args[0])
+ try:
+ for astep in auth_steps:
+ alabel = astep.get("label") or f"auth: {astep['action']}"
+ print(f" [auth] {alabel}")
+ run_one_step(dash, astep, opts, rng, report, run_id)
+ except (actions.StepError, DashboardError) as aexc:
+ msg = f"auth steps failed: {aexc}"
+ post_progress(dash, run_id, i, label, False, msg)
+ finish(dash, run_id, msg)
+ print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
+ return
+
+ if not signed_in_now(dash, step, opts):
+ msg = "auth steps ran but the session is still signed out"
+ post_progress(dash, run_id, i, label, False, msg)
+ finish(dash, run_id, msg)
+ print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
+ return
+
+ report("signed in via auth steps — retrying")
+ continue
+
+ if wait_seconds <= 0 or attempt > 0:
+ 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
+
+ mins = wait_seconds / 60
+ note = f"signed out — waiting up to {mins:.0f} min for you to log in"
+ print(f" {note}", file=sys.stderr)
+ post_progress(dash, run_id, i, note, False, exc.args[0])
+
+ if not wait_for_sign_in(dash, step, run_id, opts, time.time() + wait_seconds):
+ if run_status(dash, run_id) == "cancelled":
+ print(f"■ run #{run_id} cancelled while waiting for sign-in")
+ return
+ msg = f"still signed out after {mins:.0f} min"
+ post_progress(dash, run_id, i, label, False, msg)
+ finish(dash, run_id, msg)
+ print(f"✗ run #{run_id} gave up waiting for sign-in")
+ return
+
+ report("signed in — retrying the step")
+
+ except (actions.StepError, DashboardError) as exc:
+ 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
status = post_progress(dash, run_id, i + 1, label, True, "; ".join(detail_parts) or None)
diff --git a/lib/automations.ts b/lib/automations.ts
index e2f6996..c9cf205 100644
--- a/lib/automations.ts
+++ b/lib/automations.ts
@@ -16,7 +16,10 @@ export type AutomationStep =
| { action: 'wait'; seconds: number; label?: string }
// Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped
// when the tab is already there, so it doesn't reload and lose page state.
- | { action: 'navigate'; url?: string; label?: string };
+ | { action: 'navigate'; url?: string; label?: string }
+ // Block until `selector` exists, then carry on. Nothing is clicked or typed —
+ // this is a gate, for conditions something outside the run has to satisfy.
+ | { action: 'waitFor'; selector: string; index?: number; timeoutSeconds?: number; label?: string };
export interface Automation {
id: string;
@@ -32,6 +35,8 @@ export interface Automation {
export type ResolvedStep = AutomationStep & {
urlPattern?: string;
openUrl?: string;
+ signedOut?: string;
+ signedOutWait?: number;
};
export interface Firm {
@@ -43,6 +48,27 @@ export interface Firm {
/** Concrete page to open when no tab matches `urlPattern`. A match pattern
* can't be navigated to, so this has to be spelled out separately. */
url: string;
+ /** Substring identifying the signed-out page. When a session expires the site
+ * redirects here, and every subsequent selector resolves against a login form
+ * — so a run that lands on it must stop rather than click through it. */
+ signedOutPattern: string;
+ /** Seconds to pause and let a human sign in when a run hits the login page.
+ * 0 aborts instead. Used only when `authSteps` is empty. */
+ signedOutWaitSeconds: number;
+ /** Steps run when a run lands on the login page, before retrying the step
+ * that hit it. Left empty here on purpose — fill it in yourself.
+ *
+ * Two things to know if you do:
+ * - These run *while on the signed-out page*, so unlike normal steps they
+ * carry no signed-out guard. Nothing stops them clicking around a login
+ * form; that is the point, and also why a wrong selector here is worse
+ * than elsewhere.
+ * - Anything written here lives in this file in plain text, and this file
+ * is in the repo.
+ *
+ * While empty, a run that hits the login page falls back to pausing for
+ * `signedOutWaitSeconds` so you can sign in by hand. */
+ authSteps: AutomationStep[];
automations: Automation[];
}
@@ -52,6 +78,11 @@ export const FIRMS: Firm[] = [
label: 'Tradeify',
urlPattern: 'https://app-f.tradeify.co/*',
url: 'https://app-f.tradeify.co/',
+ signedOutPattern: '/auth/',
+ signedOutWaitSeconds: 300,
+ authSteps: [
+ { action: 'click', selector: 'form > div > div:last-child button', label: 'Open Add Account' },
+ ],
automations: [
{
id: 'buy-accounts',
@@ -69,8 +100,18 @@ export const FIRMS: Firm[] = [
// every site build, so they are not safe to select on.
{ action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' },
{ action: 'wait', seconds: 2, label: 'Wait for the form' },
+ { action: 'click', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)'},
+ { action: 'click', selector: 'div.account_types:nth-child(7) span:last-child'},
+ { action: 'click', selector: 'div.summary_section div.MuiTextField-root input'},
+ { action: 'type', selector: 'div.summary_section div.MuiTextField-root input', text: 'MX8'},
+ { action: 'click', selector: 'div.summary_section div.MuiTextField-root + button'},
+ { action: 'click', selector: 'div.captcha-solver[data-state="ready"]'},
+ { action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120,
+ label: 'Wait for the challenge' },
+ { action: 'click', selector: 'div.summary_section > button:last-child', label: 'Open Add Account' },
// TODO: the rest of the purchase flow. Confirm each selector with
// `clicker.py locate` before adding it here.
+
],
},
],
@@ -101,10 +142,42 @@ export function findAutomation(key: string): { firm: Firm; automation: Automatio
* runner never has to know which firm it is working on. */
export function resolveSteps(firm: Firm, automation: Automation): ResolvedStep[] {
return automation.steps.map((step) => {
+ if (step.action === 'wait') return step;
+ if (step.action === 'navigate') {
+ return {
+ ...step,
+ url: step.url ?? firm.url,
+ urlPattern: firm.urlPattern,
+ openUrl: firm.url,
+ signedOut: firm.signedOutPattern,
+ signedOutWait: firm.signedOutWaitSeconds,
+ };
+ }
+ if (step.action === 'waitFor') {
+ return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
+ }
+ return {
+ ...step,
+ urlPattern: step.urlPattern ?? firm.urlPattern,
+ openUrl: firm.url,
+ signedOut: firm.signedOutPattern,
+ signedOutWait: firm.signedOutWaitSeconds,
+ };
+ });
+}
+
+/** Auth steps run on the login page itself, so they get the firm's tab pattern
+ * but deliberately no `signedOut` guard — that guard exists to stop ordinary
+ * steps acting on a login form, and these are the exception. */
+export function resolveAuthSteps(firm: Firm): ResolvedStep[] {
+ return firm.authSteps.map((step) => {
if (step.action === 'wait') return step;
if (step.action === 'navigate') {
return { ...step, url: step.url ?? firm.url, urlPattern: firm.urlPattern, openUrl: firm.url };
}
+ if (step.action === 'waitFor') {
+ return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
+ }
return { ...step, urlPattern: step.urlPattern ?? firm.urlPattern, openUrl: firm.url };
});
}
@@ -117,5 +190,6 @@ export function describeStep(step: AutomationStep): string {
case 'type': return `type into ${step.selector}`;
case 'wait': return `wait ${step.seconds}s`;
case 'navigate': return `open ${step.url ?? 'the firm page'}`;
+ case 'waitFor': return `wait for ${step.selector}`;
}
}
diff --git a/lib/db.ts b/lib/db.ts
index 9c3623b..f864bfb 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -565,7 +565,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
/** The runner version this server's step vocabulary requires. A running process
* doesn't reload when the source changes, so an older one silently fails on
* steps it predates — the dashboard warns instead. */
-export const RUNNER_EXPECTED_VERSION = '0.4.0';
+export const RUNNER_EXPECTED_VERSION = '0.9.0';
export interface RunnerHeartbeat {
at: number;