Files
autofirmer-expanded/lib/automations.ts
T
Brandon LiandClaude Opus 4.8 7d677dfa89 Click the terms checkboxes directly, not the required row
.validate-required is on every required checkout field, so the old selector's
first match was usually a text input, and on the terms row the click landed in
the empty half of a full-width <p> beside the box. Target the checkbox input in
a required row and gate on :not(:checked) so the two clicks tick box one then
box two instead of toggling the same box twice; the second is skipIfNotFound in
case a checkout has only one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 21:39:15 -05:00

472 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Automations live inside the firm whose site they drive. The dashboard renders
// a tab per firm and a button per automation within it; the Python runner claims
// a queued run, resolves each selector through the extension, and drives the real
// mouse.
//
// Adding a button means adding an entry to that firm's `automations` — nothing in
// the page, the API or the runner changes.
//
// Adding a NEW FIRM also needs its host added to extension/manifest.json
// host_permissions, and the extension reloaded. Without that the extension is not
// permitted to read that site and every step fails to locate.
export type AutomationStep =
// `skipIfNotFound` skips the step when the element isn't on the page, instead of
// failing the run — for things that only sometimes appear, like a cookie
// banner or a confirmation modal. Only absence is tolerated: an element that
// is present but covered or off-screen still fails.
// `frame` targets an element inside a cross-origin <iframe> (e.g. the
// WooCommerce checkout modal). Its value is a selector for the <iframe>
// element in the top document; the step's own `selector` is then resolved
// inside that frame and its coordinates offset back to the page. Without it a
// selector only ever matches the top document. Needs extension 0.14.0+ and
// runner 0.19.0+.
| { action: 'click'; selector: string; index?: number; skipIfNotFound?: boolean; urlPattern?: string; frame?: string; label?: string }
| { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; skipIfNotFound?: boolean; urlPattern?: string; frame?: string; label?: string }
| { 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 }
// Block until `selector` exists — or, with `absent`, until it is gone from the
// DOM. Nothing is clicked or typed; this is a gate, for conditions something
// outside the run has to satisfy. Note `absent` alone means removed, not
// merely hidden: an element still in the DOM with display:none keeps
// matching, and the gate would sit there until it timed out.
//
// `hidden` (only meaningful with `absent`) widens the gate to "we cannot see
// it any more" — removed, display:none, visibility:hidden, opacity:0,
// content-visibility, or collapsed to zero size all end the wait. It says
// nothing about being scrolled off-screen or covered by an overlay: those
// are still on the page. Needs extension 0.12.0+ and runner 0.18.0+.
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; hidden?: boolean; timeoutSeconds?: number; frame?: string; label?: string }
// Scroll until the page stops adding elements matching `selector`, for lists
// that load progressively. `containerSelector` names the scrolling element
// when the automatic guess is wrong — these lists usually scroll inside a div
// rather than the window, and scrolling the document does nothing.
| {
action: 'scrollToLoad';
selector: string;
containerSelector?: string;
maxScrolls?: number;
settleMs?: number;
label?: string;
}
// Solve the page's captcha through the 2captcha API.
//
// Replaces the old pair of steps that clicked the 2captcha *extension's* own
// "solve" button and waited for it to go green. That needed a second
// extension installed and signed in on the machine, put a real mouse click on
// a button whose position and markup are 2captcha's to change, and gave the
// run no way to tell "still solving" from "the extension is not there".
//
// This reads the widget's parameters out of the page, has the dashboard ask
// 2captcha for a token, and hands the token back to the page's own callback.
// No mouse involved.
//
// `provider` is normally left off: the widget is detected. Set it when a page
// carries more than one kind. `selector` likewise scopes detection to a
// container when several widgets are on the page at once.
| {
action: 'solveCaptcha';
provider?: 'turnstile' | 'recaptcha' | 'hcaptcha';
selector?: string;
timeoutSeconds?: number;
label?: string;
}
// Run `steps` several times over. `times` fixes the count here; `timesFrom`
// takes it from an input the user fills in on the dashboard. The block is
// unrolled before the runner ever sees it — see resolveSteps.
| { action: 'repeat'; times?: number; timesFrom?: string; steps: AutomationStep[]; label?: string };
/**
* NOTE: the `FIRMS` array below is the AutoBuyer's list of prop-firm *websites*
* to click through. It is unrelated to the firm catalog in lib/firm-catalog.ts,
* which is the Tradovate side. Linking the two is future work.
* A number the user supplies on the dashboard before starting a run. */
export interface AutomationInput {
id: string;
label: string;
default: number;
min: number;
max: number;
}
export type RunInputs = Record<string, number>;
/** Unrolling a repeat multiplies steps, and each one can be a purchase. Cap the
* expansion so a bad input can't queue a thousand clicks. */
const MAX_RESOLVED_STEPS = 400;
const MAX_REPEAT_DEPTH = 3;
export interface Automation {
id: string;
label: string;
description: string;
/** Values the user sets per run. Referenced by `timesFrom` on a repeat. */
inputs?: AutomationInput[];
/** Shown as a confirmation before the run. Set it on anything that spends money. */
confirm?: string;
steps: AutomationStep[];
}
/** A step as handed to the runner: the firm's tab pattern and fallback URL
* filled in, so the runner never has to know which firm it is working on. */
export type ResolvedStep = AutomationStep & {
urlPattern?: string;
openUrl?: string;
signedOut?: string;
signedOutWait?: number;
};
export interface Firm {
id: string;
label: string;
/** Chrome match pattern for this firm's tab. Steps inherit it unless they set
* their own, which keeps one firm's automation from acting on another's tab. */
urlPattern: string;
/** 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[];
}
// The AutoBuyer's list of prop-firm *websites* to click through. Unrelated to
// the firm catalog in lib/firm-catalog.ts, which is the Tradovate side — the
// names overlap but nothing joins them. Linking the two is future work.
export const FIRMS: Firm[] = [
{
id: 'tradeify',
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: 'Login' },
],
automations: [
{
id: 'buy-accounts',
label: 'Buy Accounts x 5',
description: 'Opens the Add Account flow.',
confirm: 'This drives the real mouse against Tradeify and can spend money. Continue?',
inputs: [{ id: 'count', label: 'Accounts - 5 Pack', default: 1, min: 1, max: 3 }],
steps: [
// The tab is routinely left on another Tradeify page (/the-circuit,
// an account view). The Add Account link only exists on the
// dashboard, so go there first rather than assuming.
{ action: 'navigate', label: 'Open the Tradeify dashboard' },
// `a.add_account_btn` is the authored class on the Add Account
// link — confirmed against a real capture, matchCount 1. The MUI
// hash classes on the same element (mui-*) are regenerated on
// every site build, so they are not safe to select on.
{ action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' },
{ action: 'waitFor', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)', timeoutSeconds: 30, label: 'Wait for 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: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' },
{ action: 'click', selector: 'div.summary_section > button:last-child', label: 'Buy Account' },
// TODO: the rest of the purchase flow. Confirm each selector with
// `clicker.py locate` before adding it here.
],
},
{
id: 'reset',
label: 'Reset Accounts',
description: 'Resets Accounts',
confirm: 'This resets accounts',
inputs: [{ id: 'count', label: 'Accounts', default: 1, min: 1, max: 10 }],
steps: [
{ action: 'navigate', label: 'Open the Tradeify dashboard' },
{ action: 'click', selector: 'div.MuiTabs-scroller button:nth-child(2)', label: 'Click Evaluation Tab'},
{ action: 'click', selector: '.tab-head-right .MuiSwitch-colorPrimary.Mui-checked', skipIfNotFound: true, label: 'Show failed accounts' },
// The account list loads a screenful at a time, so a reset
// count larger than the first page would run out of rows to
// click. Walk it to the bottom first. Counting the reset
// buttons rather than the rows means it stops when the thing
// the loop actually clicks stops appearing.
//
// The list scrolls inside dashboard_body, not the document —
// the automatic guess walks up from a match looking for an
// overflow ancestor, and setting scrollTop on the wrong
// element scrolls nothing at all. Named here so it cannot
// guess wrong; confirmed against a live run, which reported
// the container as `dashboard_body MuiBox-root mui-0`.
{
action: 'scrollToLoad',
selector: '.status-failed button.reset_btn',
containerSelector: '.dashboard_body',
label: 'Load every failed account',
},
{ action: 'repeat', timesFrom: 'count', steps: [
// one purchase — the steps you already have
{ action: 'click', selector: '.status-failed button.reset_btn', label: 'Reset Account' },
{ action: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' },
{ action: 'click', selector: 'button.cancelBtn + button.modalActionBtn' },
{ action: 'waitFor', selector: '.reset_acc_modal', absent: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
]},
]
}
],
},
{
id: 'lucid',
label: 'Lucid Trading',
// One pattern covers both hosts: Chrome's `*.` form matches the apex as
// well as subdomains, confirmed against a live tab on lucidtrading.com.
urlPattern: 'https://*.lucidtrading.com/*',
url: 'https://dash.lucidtrading.com/',
// Signed out lands on the apex; signed in stays on dash. The leading `//`
// anchors this to the start of the host — without it, the substring also
// matches dash.lucidtrading.com and every step would abort while logged in.
signedOutPattern: '//lucidtrading.com/',
signedOutWaitSeconds: 300,
authSteps: [
{ action: 'click', selector: 'button.lucid-login-btn', label: 'Login' },
],
automations: [
{
id: 'buy-account',
label: 'Buy Account',
description: 'Opens the Add Account flow.',
confirm: 'This drives the real mouse against Lucid and can spend money. Continue?',
inputs: [{ id: 'count', label: 'Accounts', default: 1, min: 1, max: 15 }],
steps: [
// The tab is routinely left on another Tradeify page (/the-circuit,
// an account view). The Add Account link only exists on the
// dashboard, so go there first rather than assuming.
{ action: 'navigate', label: 'Open the Lucid dashboard' },
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' },
{ action: 'click', selector: 'button.tab-flex', label: 'Open Flex Accounts' },
{ action: 'repeat', timesFrom: 'count', steps: [
// one purchase — the steps you already have
{ action: 'click', selector: '.slide:nth-child(2) .drawdown-option:last-child', label: 'Disable DLL' },
{ action: 'click', selector: '.slide:nth-child(2) .cta-btn', label: 'Open' },
{ action: 'click', selector: '.platform-btn:first-child', label: 'CQG' },
// The checkout renders in a WooCommerce iframe on
// lucidtrading.com (parent app is dash.lucidtrading.com), so
// every step here targets the frame. The coupon field id
// varies between WooCommerce's default (#coupon_code /
// name="coupon_code") and this template's custom id; list
// both so one of them matches. Apply is WooCommerce's
// named submit button, not a positional sibling.
{ action: 'waitFor', selector: 'button#apply_coupon_button', frame: '#woocommerceModal iframe', label: 'Wait for apply coupon button to open' },
{ action: 'click', selector: 'input#coupon_code, input[name="coupon_code"], input#coupon_code_field', frame: '#woocommerceModal iframe', label: 'Focus coupon field' },
{ action: 'type', selector: 'input#coupon_code, input[name="coupon_code"], input#coupon_code_field', frame: '#woocommerceModal iframe', text: 'THEPROPFARM', label: 'Enter coupon' },
{ action: 'click', selector: 'button#apply_coupon_button', frame: '#woocommerceModal iframe', label: 'Apply coupon' },
// Target the checkbox itself, not the .validate-required
// row: that class is on every required field (name, email,
// …), so its first match is usually a text input, and even
// on the terms row the click lands in the empty right half
// of a full-width <p> rather than on the box. `:not(:checked)`
// makes the two clicks self-sequence — the first tick checks
// box one, so the second click resolves to box two — and
// re-clicking never un-ticks a box already checked.
{ action: 'click', selector: '.validate-required input[type="checkbox"]:not(:checked)', frame: '#woocommerceModal iframe', label: 'Agree to terms' },
{ action: 'click', selector: '.validate-required input[type="checkbox"]:not(:checked)', frame: '#woocommerceModal iframe', skipIfNotFound: true, label: 'Agree to terms 2' },
{ action: 'waitFor', selector: '#woocommerceModal', absent: true, hidden: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
]},
],
},
],
},
];
/** Runs store one string, so it has to identify the automation globally — and
* every firm will plausibly have its own "buy-accounts". Hence firm:automation
* rather than the bare id. */
export function automationKey(firmId: string, automationId: string): string {
return `${firmId}:${automationId}`;
}
export function getFirm(id: string): Firm | undefined {
return FIRMS.find((f) => f.id === id);
}
export function findAutomation(key: string): { firm: Firm; automation: Automation } | undefined {
for (const firm of FIRMS) {
for (const automation of firm.automations) {
if (automationKey(firm.id, automation.id) === key) return { firm, automation };
}
}
return undefined;
}
/** Clamp a user-supplied count to what the automation declared. Never trust the
* number that arrived over the wire: each iteration can be a purchase. */
export function resolveCount(
step: Extract<AutomationStep, { action: 'repeat' }>,
automation: Automation,
inputs: RunInputs,
): number {
if (!step.timesFrom) return Math.max(1, Math.floor(step.times ?? 1));
const declared = (automation.inputs ?? []).find((i) => i.id === step.timesFrom);
const raw = inputs[step.timesFrom];
if (!declared) return 1;
const n = Number.isFinite(raw) ? Math.floor(raw) : declared.default;
return Math.min(declared.max, Math.max(declared.min, n));
}
/** Give each step of an unrolled iteration a label that says which pass it is,
* so a failure on the third purchase reads as such in the run log. */
function labelled(step: ResolvedStep, iteration: number, total: number): ResolvedStep {
if (total <= 1) return step;
return { ...step, label: `${step.label ?? describeStep(step)} (${iteration}/${total})` };
}
/** One non-repeat step, with the firm's tab pattern and URLs filled in. */
function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
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 };
}
if (step.action === 'scrollToLoad') {
// Acts on nothing, so no signed-out guard — same treatment as waitFor.
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
}
if (step.action === 'solveCaptcha') {
// No guard either, and deliberately: a login form is exactly where a
// captcha shows up, so auth steps have to be able to solve one.
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
}
if (step.action === 'repeat') {
// expand() peels these off first; reaching here means a caller bypassed it.
throw new Error('repeat steps must be expanded, not resolved directly');
}
return {
...step,
urlPattern: step.urlPattern ?? firm.urlPattern,
openUrl: firm.url,
signedOut: firm.signedOutPattern,
signedOutWait: firm.signedOutWaitSeconds,
};
}
/** Flatten repeats into a plain list.
*
* Expanding here rather than looping in the runner keeps the runner unchanged,
* makes the run's total step count honest, and puts every iteration in the run
* log as its own line. The cost is that the count must be known up front, which
* it is: either fixed in the config or supplied before the run starts.
*/
function expand(
steps: AutomationStep[],
firm: Firm,
automation: Automation,
inputs: RunInputs,
depth = 0,
): ResolvedStep[] {
if (depth > MAX_REPEAT_DEPTH) {
throw new Error(`repeat nested more than ${MAX_REPEAT_DEPTH} deep`);
}
const out: ResolvedStep[] = [];
for (const step of steps) {
if (step.action === 'repeat') {
const times = resolveCount(step, automation, inputs);
for (let i = 1; i <= times; i++) {
for (const inner of expand(step.steps, firm, automation, inputs, depth + 1)) {
out.push(labelled(inner, i, times));
}
}
} else {
out.push(resolveOne(firm, step));
}
if (out.length > MAX_RESOLVED_STEPS) {
throw new Error(`expands to more than ${MAX_RESOLVED_STEPS} steps`);
}
}
return out;
}
export function resolveSteps(firm: Firm, automation: Automation, inputs: RunInputs = {}): ResolvedStep[] {
return expand(automation.steps, firm, automation, inputs);
}
/** 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.
*
* They share the expansion path, so a repeat works here too; there are no
* per-run inputs during auth, so `timesFrom` falls back to a single pass. */
export function resolveAuthSteps(firm: Firm): ResolvedStep[] {
const stub: Automation = { id: '_auth', label: 'auth', description: '', steps: firm.authSteps };
return expand(firm.authSteps, firm, stub, {}).map((step) =>
step.action === 'wait' ? step : { ...step, signedOut: undefined, signedOutWait: undefined }
);
}
/** What a step is doing, for the run log and the dashboard. */
export function describeStep(step: AutomationStep): string {
if (step.label) return step.label;
switch (step.action) {
case 'click': return `click ${step.selector}`;
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':
if (!step.absent) return `wait for ${step.selector}`;
return step.hidden
? `wait for ${step.selector} to disappear from view`
: `wait for ${step.selector} to disappear`;
case 'scrollToLoad': return `scroll to load all ${step.selector}`;
case 'solveCaptcha': return `solve the ${step.provider ?? 'page'} captcha with 2captcha`;
case 'repeat': {
const inner = step.steps.length;
const count = step.timesFrom ? `{${step.timesFrom}}` : `${step.times ?? 1}`;
return `repeat ${count}× (${inner} step${inner === 1 ? '' : 's'})`;
}
}
}