Add automation framework, typing, and runner for the autobuyer
Turns the autobuyer from a page scraper into something that acts. A dashboard button queues a run; a desktop process executes it against the real browser. lib/automations.ts — automations are declarative step lists nested inside the firm whose site they drive. Steps are click / type / wait / navigate, and they inherit the firm's tab pattern and URL, so one firm's automation can't act on another's tab. Adding a button means adding an entry here; the page renders buttons from the API and the runner receives steps from the server, so neither needs editing. Runs key on firm:automation — every firm will plausibly have its own "buy-accounts", and a bare id would resolve to the wrong one. clicker/runner.py — the daemon behind the buttons. Claims a queued run, works through the steps, reports each one back for the page's live log. Only one run executes at a time: two processes driving one physical mouse would interleave clicks. Heartbeats on its own thread, because a step can block for tens of seconds and folding the beat into the main loop would show the runner as offline in the middle of the run it was executing. clicker/actions.py — one implementation of the safety checks, shared by the CLI and the runner. Refuses to act when the element is covered by an overlay, when coordinates fall off-screen, when the browser can't be confirmed frontmost, or (for type) when the target isn't an editable field. Typing: uneven human cadence, and the field is read back afterwards and compared against what was typed — a field that never took focus fails silently and looks identical to success otherwise. Non-ASCII is rejected because pyautogui skips those characters without complaint, and newlines because Enter may submit the form. Typos are deliberately not simulated: a mistyped digit in a trading form is a real loss, and the correction is the part that can go wrong. Extension: opens the firm's page when no tab matches, navigates to a specific page for a navigate step (skipped when already there, so page state survives), and retries the locate while a freshly loaded React app mounts — `complete` only means the document loaded. Staleness reporting, after it cost three debugging rounds: Chrome doesn't reload an unpacked extension and Python doesn't reload a running process, so both now report their version. A stale runner gets a red banner naming both versions and the automation buttons are disabled, rather than failing mid-run on a step type it predates. Scale detection is now conservative: a raw OS/browser width ratio is only trusted when it lands on a real scaling factor. On this multi-monitor desktop the previous logic would have silently halved every coordinate. Verified end to end against the live browser: navigate, locate, and a real click (run #12, all three steps). API round-trips, claim-once semantics, run cancellation, the heartbeat online/offline lifecycle, motion geometry and timing, focus activation, and typing verification all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
54221bbc0c
commit
b748f95372
+86
-13
@@ -183,10 +183,19 @@ function pageLocate(selector, index) {
|
||||
const atPoint = document.elementFromPoint(cx, cy);
|
||||
const covered = atPoint && atPoint !== el && !el.contains(atPoint);
|
||||
|
||||
// Current contents, so the caller can confirm afterwards that what it typed
|
||||
// actually landed — a field that silently ignored the keystrokes (masked,
|
||||
// read-only, or never focused) is otherwise indistinguishable from success.
|
||||
const isField = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA';
|
||||
const value = isField ? el.value : (el.isContentEditable ? el.innerText : null);
|
||||
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: (el.textContent || '').trim().slice(0, 80),
|
||||
matchCount: matches.length,
|
||||
value,
|
||||
editable: (isField || el.isContentEditable) && !el.disabled && !el.readOnly,
|
||||
inputType: isField ? (el.type || null) : null,
|
||||
covered: !!covered,
|
||||
coveredBy: covered ? `${atPoint.tagName.toLowerCase()}${atPoint.id ? '#' + atPoint.id : ''}` : null,
|
||||
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
|
||||
@@ -201,17 +210,62 @@ function pageLocate(selector, index) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Is the tab already showing this page? Compares origin and path only —
|
||||
* query strings and hashes shouldn't force a reload. */
|
||||
function alreadyAt(current, target) {
|
||||
try {
|
||||
const a = new URL(current);
|
||||
const b = new URL(target);
|
||||
return a.origin === b.origin && a.pathname.replace(/\/$/, '') === b.pathname.replace(/\/$/, '');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve after the tab reports `complete`. A tab that has only just been
|
||||
* created has no DOM to inject into yet. */
|
||||
function waitForTabLoad(tabId, timeoutMs = 15000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const started = Date.now();
|
||||
const check = async () => {
|
||||
let tab;
|
||||
try {
|
||||
tab = await chrome.tabs.get(tabId);
|
||||
} catch {
|
||||
return reject(new Error('the tab was closed while loading'));
|
||||
}
|
||||
if (tab.status === 'complete') return resolve(tab);
|
||||
if (Date.now() - started > timeoutMs) {
|
||||
return reject(new Error(`the page did not finish loading within ${timeoutMs / 1000}s`));
|
||||
}
|
||||
setTimeout(check, 200);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveLocateTab(cfg, request) {
|
||||
const pattern = request.urlPattern || cfg.targetUrlPattern;
|
||||
if (pattern) {
|
||||
const tabs = await chrome.tabs.query({ url: pattern });
|
||||
const tab = tabs.find((t) => /^https?:/.test(t.url || ''));
|
||||
if (!tab) throw new Error(`No open tab matches ${pattern}`);
|
||||
return tab;
|
||||
if (tab) return { tab, opened: false };
|
||||
|
||||
// Nothing matching is open. Open it rather than failing the run — but only
|
||||
// to the URL the firm declared, never to something a request supplied that
|
||||
// we have no host permission for.
|
||||
if (request.openUrl) {
|
||||
const created = await chrome.tabs.create({ url: request.openUrl, active: true });
|
||||
await waitForTabLoad(created.id);
|
||||
return { tab: await chrome.tabs.get(created.id), opened: true };
|
||||
}
|
||||
|
||||
throw new Error(`No open tab matches ${pattern}, and no URL is configured to open`);
|
||||
}
|
||||
|
||||
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
||||
if (!tab || !/^https?:/.test(tab.url || '')) throw new Error('No eligible active tab');
|
||||
return tab;
|
||||
return { tab, opened: false };
|
||||
}
|
||||
|
||||
/** Returns true if a request was handled, false if the queue was empty. */
|
||||
@@ -224,7 +278,17 @@ async function serveLocateRequest(cfg) {
|
||||
let result = null;
|
||||
let error = null;
|
||||
try {
|
||||
const tab = await resolveLocateTab(cfg, request);
|
||||
let { tab, opened } = await resolveLocateTab(cfg, request);
|
||||
|
||||
// A navigate step points the tab at a specific page first. Skip it when we
|
||||
// are already there — reloading would throw away page state for nothing,
|
||||
// and the common case is that the tab is on the right page already.
|
||||
if (request.navigateUrl && !alreadyAt(tab.url, request.navigateUrl)) {
|
||||
await chrome.tabs.update(tab.id, { url: request.navigateUrl });
|
||||
await waitForTabLoad(tab.id);
|
||||
tab = await chrome.tabs.get(tab.id);
|
||||
opened = true; // treat as a cold load: the app still has to mount
|
||||
}
|
||||
|
||||
// The reported coordinates are only worth anything if that tab is the one
|
||||
// actually visible: raise its window and bring the tab to the front.
|
||||
@@ -238,15 +302,24 @@ async function serveLocateRequest(cfg) {
|
||||
await chrome.tabs.update(tab.id, { active: true });
|
||||
await new Promise((r) => setTimeout(r, 250)); // let the OS finish raising it
|
||||
|
||||
const [injection] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: pageLocate,
|
||||
args: [request.selector, request.index || 0],
|
||||
});
|
||||
const out = injection?.result;
|
||||
if (!out) throw new Error('Injection returned nothing');
|
||||
if (out.error) throw new Error(out.error);
|
||||
result = { ...out, tabId: tab.id, windowId: tab.windowId };
|
||||
// `complete` only means the document loaded — a React app still has to
|
||||
// mount and paint. Retry briefly rather than declaring the element missing,
|
||||
// with a longer budget when we just opened the page from cold.
|
||||
const deadline = Date.now() + (opened ? 8000 : 2500);
|
||||
let out;
|
||||
for (;;) {
|
||||
const [injection] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: pageLocate,
|
||||
args: [request.selector, request.index || 0],
|
||||
});
|
||||
out = injection?.result;
|
||||
if (!out) throw new Error('Injection returned nothing');
|
||||
if (!out.error) break;
|
||||
if (Date.now() >= deadline) throw new Error(out.error);
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
}
|
||||
result = { ...out, tabId: tab.id, windowId: tab.windowId, openedTab: opened };
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user