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>
377 lines
18 KiB
TypeScript
377 lines
18 KiB
TypeScript
// 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'})`;
|
||
}
|
||
}
|
||
}
|