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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3ac9fe060f
commit
3cc7ddcc5c
+30
-5
@@ -21,9 +21,24 @@ export type AutomationStep =
|
||||
// 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 }
|
||||
// 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.
|
||||
@@ -160,7 +175,9 @@ export const FIRMS: Firm[] = [
|
||||
{ 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: 'click', selector: 'button.cancelBtn + button.modalActionBtn' },
|
||||
|
||||
{ action: 'waitFor', selector: '.reset_acc_modal', absent: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
|
||||
]},
|
||||
]
|
||||
}
|
||||
@@ -265,6 +282,10 @@ function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
|
||||
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');
|
||||
@@ -341,7 +362,11 @@ export function describeStep(step: AutomationStep): string {
|
||||
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}`;
|
||||
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}`;
|
||||
|
||||
@@ -394,6 +394,7 @@ export interface LocateRow {
|
||||
url_pattern: string;
|
||||
open_url: string;
|
||||
navigate_url: string;
|
||||
options: string; // JSON, per-request extras (scroll parameters, ...)
|
||||
status: 'pending' | 'claimed' | 'done' | 'error';
|
||||
result: string | null;
|
||||
error: string | null;
|
||||
@@ -403,10 +404,10 @@ export interface LocateRow {
|
||||
|
||||
const LOCATE_HISTORY = 20;
|
||||
|
||||
export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string, openUrl = '', navigateUrl = ''): LocateRow {
|
||||
export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string, openUrl = '', navigateUrl = '', options = '{}'): LocateRow {
|
||||
const res = db.prepare(
|
||||
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, Date.now());
|
||||
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, options, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, options, Date.now());
|
||||
|
||||
db.prepare(`
|
||||
DELETE FROM autobuyer_locate
|
||||
@@ -459,6 +460,14 @@ db.exec(`
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: free-form per-request options, so a new kind of request doesn't
|
||||
// need a new column each time.
|
||||
try {
|
||||
db.exec("ALTER TABLE autobuyer_locate ADD COLUMN options TEXT NOT NULL DEFAULT '{}'");
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Migration: the page to send the tab to before locating.
|
||||
try {
|
||||
db.exec("ALTER TABLE autobuyer_locate ADD COLUMN navigate_url TEXT NOT NULL DEFAULT ''");
|
||||
@@ -604,7 +613,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
|
||||
/** The runner version this server's step vocabulary requires. A running process
|
||||
* doesn't reload when the source changes, so an older one silently fails on
|
||||
* steps it predates — the dashboard warns instead. */
|
||||
export const RUNNER_EXPECTED_VERSION = '0.14.0';
|
||||
export const RUNNER_EXPECTED_VERSION = '0.16.0';
|
||||
|
||||
export interface RunnerHeartbeat {
|
||||
at: number;
|
||||
|
||||
Reference in New Issue
Block a user