// 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 = | { action: 'click'; selector: string; index?: number; urlPattern?: string; label?: string } | { action: 'type'; selector: string; text: string; index?: number; clear?: 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, 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; label: string; description: string; /** 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: 'Open Add Account' }, ], automations: [ { id: 'buy-accounts', label: 'Buy Accounts', description: 'Opens the Add Account flow.', confirm: 'This drives the real mouse against Tradeify and can spend money. Continue?', 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. ], }, ], }, ]; /** 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; } /** Fill in the firm's tab pattern for any step that didn't name one, so the * 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 }; }); } /** 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 `wait for ${step.selector}`; } }