Files
autofirmer-expanded/lib/automations.ts
T
Brandon LiandClaude Opus 5 159389be35 Solve captchas through the 2captcha API, not its extension
The two Tradeify automations clicked the 2captcha *extension's* solve button
and waited for it to go green. That needed a second extension installed and
signed in, put a real mouse click on a button whose markup is 2captcha's to
change, and gave a run no way to tell "still solving" from "not installed".

Replaced with one `solveCaptcha` step. No mouse: the extension reads the
widget's sitekey out of the page, the dashboard buys a token from 2captcha,
and the extension puts it in the response field and fires the page's own
callback.

captcha-hook.js is the part that earns its keep. A widget rendered
programmatically puts nothing in the DOM — no sitekey, and no callback. Most
pages only enable submit from that callback, so filling the hidden field alone
leaves the page looking unsolved. The hook wraps turnstile/grecaptcha/hcaptcha
`render` at document_start to hold on to what the page passed.

Solves go through 2captcha's residential proxy. Its API rejects host names in
proxyAddress, so na.proxy.2captcha.com is resolved to an IP first (cached);
the gateway was confirmed to work addressed that way.

Covers reCAPTCHA v2 (incl. invisible and Enterprise), hCaptcha and Turnstile.
NOT reCAPTCHA v3: a v3 page carries an anchor iframe with k=, so it is
detected as v2 and would be solved with the wrong task type. Everything else
unsupported fails loudly instead.

The API key and proxy credentials are hardcoded in the captcha route on
request. Unlike the sqlite database, that file is in the repo, so they travel
with it. TWOCAPTCHA_API_KEY / TWOCAPTCHA_PROXY and the settings rows still
override; neither has a field on the Settings page.

Runner VERSION and RUNNER_EXPECTED_VERSION both to 0.17.0 — they are a pair,
and a mismatch shows as a red "restart your runner" banner that blames the
wrong process.

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

396 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;
}
// 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 };
/** 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: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' },
// 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: '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: '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 === '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':
return step.absent
? `wait for ${step.selector} to disappear`
: `wait for ${step.selector}`;
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'})`;
}
}
}