486 lines
25 KiB
TypeScript
486 lines
25 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.
|
||
// `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: 'repeat', timesFrom: 'count', steps: [
|
||
{ 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: '.MuiBox-root:last-child div.account_types:last-child 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.MuiButton-fullWidth', label: 'Buy Account' },
|
||
// TODO: the rest of the purchase flow. Confirm each selector with
|
||
// `clicker.py locate` before adding it here.
|
||
{ action: 'navigate', label: 'Open the Tradeify dashboard' },
|
||
]},
|
||
|
||
],
|
||
},
|
||
{
|
||
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.
|
||
//
|
||
// WooCommerce only stamps validate-required on its own
|
||
// required rows; the second agreement box here is theme
|
||
// markup without that class, so the second click also
|
||
// sweeps the whole checkout form for unchecked boxes —
|
||
// minus the ones that must never be ticked (different
|
||
// shipping address expands extra fields, mailing-list
|
||
// opt-ins). No skipIfNotFound: silently skipping the
|
||
// second agreement just moves the failure to the
|
||
// modal-close gate below with a misleading timeout.
|
||
{ action: 'click', selector: '.validate-required input[type="checkbox"]:not(:checked)', frame: '#woocommerceModal iframe', label: 'Agree to terms' },
|
||
// Ticking box one can re-render the checkout (update_checkout);
|
||
// give it a beat so the second locate sees the settled DOM.
|
||
{ action: 'click', selector: '.validate-required input[type="checkbox"]:not(:checked):last-child', frame: '#woocommerceModal iframe', 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'})`;
|
||
}
|
||
}
|
||
}
|