Files
autofirmer-expanded/lib/automations.ts
T
Brandon LiandClaude Opus 5 3cc7ddcc5c Add scrollToLoad and an absent variant of waitFor
scrollToLoad walks a progressively-loading list to the bottom before the steps
that act on its items run. Stopping is two-part: no new matches appeared AND the
container was already pinned to the bottom — counting alone stops early on a slow
fetch. Hitting the scroll cap is reported rather than passed off as done, so a
later step never works quietly on a partial list.

The scrolling element is usually not the window. Lists like this live in a div
with its own overflow, and scrolling the document does nothing at all, so the
step walks up from a matched item to the ancestor that actually scrolls —
overflow allows it and there is more content than fits — with containerSelector
to name one outright when the guess is wrong. Verified against a page whose
document also scrolls, which is the case that tells the two apart: it found the
inner div and pulled 12 items up to 60 in 7 scrolls.

waitFor gains `absent`, for waiting on something to go rather than arrive — a
modal closing after a reset. It only accepts a genuine "selector matched
nothing"; an unreachable extension looks the same from a distance and would
otherwise satisfy the gate for the wrong reason, sending the next iteration into
a page that still has the modal open.

The locate queue carries a free-form options blob now, so a new kind of request
stops meaning a new column each time.

Also fixes a missing comma in the reset flow that broke the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:31:36 -05:00

377 lines
18 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.
| { action: 'click'; selector: string; index?: number; skipIfNotFound?: boolean; urlPattern?: string; label?: string }
| { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; skipIfNotFound?: boolean; urlPattern?: 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` means removed, not merely
// hidden: an element still in the DOM with display:none keeps matching, so
// for those use a selector that only matches while it is visible.
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; timeoutSeconds?: number; 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;
}
// 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 };
/** 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[];
}
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: '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.
],
},
{
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' },
{ action: 'repeat', timesFrom: 'count', steps: [
// one purchase — the steps you already have
{ action: 'click', selector: '.status-failed button.reset_btn', label: 'Reset Account' },
{ action: 'waitFor', selector: "div.captcha-solver[data-state='ready']", timeoutSeconds: 30, label: 'Wait for the solver' },
{ 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: '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: 'repeat', timesFrom: 'count', steps: [
// one purchase — the steps you already have
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' },
]},
],
},
],
},
];
/** 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 === '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':
return step.absent
? `wait for ${step.selector} to disappear`
: `wait for ${step.selector}`;
case 'scrollToLoad': return `scroll to load all ${step.selector}`;
case 'repeat': {
const inner = step.steps.length;
const count = step.timesFrom ? `{${step.timesFrom}}` : `${step.times ?? 1}`;
return `repeat ${count}× (${inner} step${inner === 1 ? '' : 's'})`;
}
}
}