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
@@ -0,0 +1,53 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { FIRMS, findAutomation, automationKey, describeStep } from '@/lib/automations';
|
||||
import { createRun, getSetting } from '@/lib/db';
|
||||
import { corsJson, corsPreflight } from '../cors';
|
||||
|
||||
/** The dashboard renders a tab per firm, and a button per automation inside it. */
|
||||
export async function GET() {
|
||||
return corsJson({
|
||||
firms: FIRMS.map((firm) => ({
|
||||
id: firm.id,
|
||||
label: firm.label,
|
||||
urlPattern: firm.urlPattern,
|
||||
automations: firm.automations.map((a) => ({
|
||||
key: automationKey(firm.id, a.id),
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
description: a.description,
|
||||
confirm: a.confirm ?? null,
|
||||
steps: a.steps.map(describeStep),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/** Queue a run. The Python runner picks it up. */
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as { automationId?: unknown };
|
||||
if (typeof body.automationId !== 'string') {
|
||||
return corsJson({ error: '`automationId` is required' }, { status: 400 });
|
||||
}
|
||||
const found = findAutomation(body.automationId);
|
||||
if (!found) {
|
||||
return corsJson({ error: `No automation "${body.automationId}"` }, { status: 404 });
|
||||
}
|
||||
if (found.automation.steps.length === 0) {
|
||||
return corsJson({ error: `"${found.automation.label}" has no steps yet` }, { status: 400 });
|
||||
}
|
||||
// The dashboard switch is the master arm for anything that drives the mouse.
|
||||
if (getSetting('autobuyer_enabled') !== '1') {
|
||||
return corsJson({ error: 'AutoBuyer is switched off' }, { status: 409 });
|
||||
}
|
||||
|
||||
const run = createRun(body.automationId, found.automation.steps.length);
|
||||
return corsJson({ runId: run.id, totalSteps: run.total_steps });
|
||||
} catch (err: any) {
|
||||
return corsJson({ error: err?.message ?? 'Failed to queue run' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export async function POST() {
|
||||
selector: row.selector,
|
||||
index: row.match_index,
|
||||
urlPattern: row.url_pattern,
|
||||
openUrl: row.open_url,
|
||||
navigateUrl: row.navigate_url,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,14 +5,16 @@ import { corsJson, corsPreflight } from '../cors';
|
||||
/** Python enqueues "find this selector and tell me where it is on screen". */
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as { selector?: unknown; index?: unknown; urlPattern?: unknown };
|
||||
const body = await req.json() as { selector?: unknown; index?: unknown; urlPattern?: unknown; openUrl?: unknown; navigateUrl?: unknown };
|
||||
if (typeof body.selector !== 'string' || !body.selector.trim()) {
|
||||
return corsJson({ error: '`selector` is required' }, { status: 400 });
|
||||
}
|
||||
const index = Number.isInteger(body.index) ? Number(body.index) : 0;
|
||||
const urlPattern = typeof body.urlPattern === 'string' ? body.urlPattern : '';
|
||||
const openUrl = typeof body.openUrl === 'string' ? body.openUrl : '';
|
||||
const navigateUrl = typeof body.navigateUrl === 'string' ? body.navigateUrl : '';
|
||||
|
||||
const row = createLocateRequest(body.selector.trim(), index, urlPattern);
|
||||
const row = createLocateRequest(body.selector.trim(), index, urlPattern, openUrl, navigateUrl);
|
||||
return corsJson({ id: row.id, status: row.status });
|
||||
} catch (err: any) {
|
||||
return corsJson({ error: err?.message ?? 'Failed to queue request' }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { setRunnerHeartbeat, getRunnerHeartbeat, RUNNER_TIMEOUT_MS, RUNNER_EXPECTED_VERSION } from '@/lib/db';
|
||||
import { corsJson, corsPreflight } from '../cors';
|
||||
|
||||
/** The runner checks in. Timestamped server-side so a runner with a skewed clock
|
||||
* doesn't read as permanently offline (or permanently online). */
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json().catch(() => ({})) as Record<string, unknown>;
|
||||
setRunnerHeartbeat({
|
||||
host: typeof body.host === 'string' ? body.host : undefined,
|
||||
pid: typeof body.pid === 'number' ? body.pid : undefined,
|
||||
dryRun: body.dryRun === true,
|
||||
version: typeof body.version === 'string' ? body.version : undefined,
|
||||
busy: body.busy === true,
|
||||
});
|
||||
return corsJson({ ok: true });
|
||||
} catch (err: any) {
|
||||
return corsJson({ error: err?.message ?? 'Failed to record heartbeat' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const hb = getRunnerHeartbeat();
|
||||
if (!hb) return corsJson({ online: false, lastSeen: null, ageMs: null });
|
||||
|
||||
const ageMs = Date.now() - hb.at;
|
||||
return corsJson({
|
||||
online: ageMs < RUNNER_TIMEOUT_MS,
|
||||
lastSeen: hb.at,
|
||||
ageMs,
|
||||
host: hb.host ?? null,
|
||||
pid: hb.pid ?? null,
|
||||
dryRun: !!hb.dryRun,
|
||||
busy: !!hb.busy,
|
||||
version: hb.version ?? null,
|
||||
expectedVersion: RUNNER_EXPECTED_VERSION,
|
||||
stale: (hb.version ?? '') !== RUNNER_EXPECTED_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { claimRun, getSetting } from '@/lib/db';
|
||||
import { findAutomation, resolveSteps } from '@/lib/automations';
|
||||
import { corsJson, corsPreflight } from '../../cors';
|
||||
|
||||
/** The Python runner polls this. Returns the run plus the steps to execute, so
|
||||
* the runner never needs its own copy of the automation definitions. */
|
||||
export async function POST() {
|
||||
if (getSetting('autobuyer_enabled') !== '1') {
|
||||
return corsJson({ run: null, reason: 'AutoBuyer is switched off' });
|
||||
}
|
||||
|
||||
const row = claimRun();
|
||||
if (!row) return corsJson({ run: null });
|
||||
|
||||
const found = findAutomation(row.automation_id);
|
||||
if (!found) {
|
||||
return corsJson({ run: null, reason: `Unknown automation ${row.automation_id}` });
|
||||
}
|
||||
|
||||
return corsJson({
|
||||
run: {
|
||||
id: row.id,
|
||||
automationId: row.automation_id,
|
||||
firm: found.firm.label,
|
||||
label: found.automation.label,
|
||||
steps: resolveSteps(found.firm, found.automation),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { appendRunLog, finishRun, getRun } from '@/lib/db';
|
||||
import { corsJson, corsPreflight } from '../../cors';
|
||||
|
||||
interface ProgressBody {
|
||||
id?: unknown;
|
||||
stepIndex?: unknown;
|
||||
step?: unknown;
|
||||
ok?: unknown;
|
||||
detail?: unknown;
|
||||
/** Present only on the final call. */
|
||||
finish?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
/** The runner reports each step, then a final finish. */
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as ProgressBody;
|
||||
const id = Number(body.id);
|
||||
const row = getRun(id);
|
||||
if (!row) return corsJson({ error: 'No such run' }, { status: 404 });
|
||||
|
||||
if (typeof body.step === 'string') {
|
||||
appendRunLog(id, {
|
||||
at: Date.now(),
|
||||
step: body.step,
|
||||
ok: body.ok !== false,
|
||||
detail: typeof body.detail === 'string' ? body.detail : undefined,
|
||||
}, Number(body.stepIndex) || row.step_index);
|
||||
}
|
||||
|
||||
if (body.finish === true) {
|
||||
const error = typeof body.error === 'string' && body.error ? body.error : null;
|
||||
finishRun(id, error ? 'error' : 'done', error);
|
||||
}
|
||||
|
||||
// The runner reads this to notice the Stop button between steps.
|
||||
const now = getRun(id);
|
||||
return corsJson({ ok: true, status: now?.status ?? 'error' });
|
||||
} catch (err: any) {
|
||||
return corsJson({ error: err?.message ?? 'Failed to record progress' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getRun, getRecentRuns, cancelRun } from '@/lib/db';
|
||||
import { findAutomation } from '@/lib/automations';
|
||||
import { corsJson, corsPreflight } from '../cors';
|
||||
|
||||
function shape(row: NonNullable<ReturnType<typeof getRun>>) {
|
||||
return {
|
||||
id: row.id,
|
||||
automationId: row.automation_id,
|
||||
automationLabel: findAutomation(row.automation_id)?.automation.label ?? row.automation_id,
|
||||
status: row.status,
|
||||
stepIndex: row.step_index,
|
||||
totalSteps: row.total_steps,
|
||||
log: (() => { try { return JSON.parse(row.log); } catch { return []; } })(),
|
||||
error: row.error,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** `?id=` for one run, otherwise the recent history the dashboard shows. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const idParam = req.nextUrl.searchParams.get('id');
|
||||
if (idParam) {
|
||||
const row = getRun(Number(idParam));
|
||||
if (!row) return corsJson({ error: 'No such run' }, { status: 404 });
|
||||
return corsJson({ run: shape(row) });
|
||||
}
|
||||
return corsJson({ runs: getRecentRuns(5).map(shape) });
|
||||
}
|
||||
|
||||
/** Stop button. A queued run never starts; a running one halts at the next step. */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const id = Number(req.nextUrl.searchParams.get('id'));
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return corsJson({ error: '`id` is required' }, { status: 400 });
|
||||
}
|
||||
return corsJson({ cancelled: cancelRun(id) });
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -34,6 +34,64 @@ interface Capture {
|
||||
capturedAt: number;
|
||||
}
|
||||
|
||||
interface AutomationSummary {
|
||||
/** firm:automation — what runs are recorded against. */
|
||||
key: string;
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
confirm: string | null;
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
interface FirmSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
urlPattern: string;
|
||||
automations: AutomationSummary[];
|
||||
}
|
||||
|
||||
interface RunLogEntry {
|
||||
at: number;
|
||||
step: string;
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
id: number;
|
||||
automationId: string;
|
||||
automationLabel: string;
|
||||
status: 'queued' | 'running' | 'done' | 'error' | 'cancelled';
|
||||
stepIndex: number;
|
||||
totalSteps: number;
|
||||
log: RunLogEntry[];
|
||||
error: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface RunnerStatus {
|
||||
online: boolean;
|
||||
lastSeen: number | null;
|
||||
ageMs: number | null;
|
||||
host?: string | null;
|
||||
pid?: number | null;
|
||||
dryRun?: boolean;
|
||||
busy?: boolean;
|
||||
version?: string | null;
|
||||
expectedVersion?: string;
|
||||
stale?: boolean;
|
||||
}
|
||||
|
||||
const RUN_BADGE: Record<Run['status'], string> = {
|
||||
queued: 'bg-slate-100 text-slate-600',
|
||||
running: 'bg-blue-100 text-blue-700',
|
||||
done: 'bg-green-100 text-green-700',
|
||||
error: 'bg-red-100 text-red-700',
|
||||
cancelled: 'bg-amber-100 text-amber-700',
|
||||
};
|
||||
|
||||
const POLL_MS = 2000;
|
||||
|
||||
/** Point relative URLs (stylesheets, images, fonts) at the origin the snapshot came
|
||||
@@ -57,6 +115,11 @@ export default function AutoBuyer() {
|
||||
// reload the frame that often. So the rendered view shows a snapshot pinned when you
|
||||
// opened it — clicking Rendered again re-pins to the latest capture.
|
||||
const [pinned, setPinned] = useState<Capture | null>(null);
|
||||
const [firms, setFirms] = useState<FirmSummary[]>([]);
|
||||
const [activeFirm, setActiveFirm] = useState<string | null>(null);
|
||||
const [runs, setRuns] = useState<Run[]>([]);
|
||||
const [busyRun, setBusyRun] = useState(false);
|
||||
const [runner, setRunner] = useState<RunnerStatus | null>(null);
|
||||
|
||||
// Tracks the newest capture we already hold, so the poll can skip re-downloading it.
|
||||
const lastAtRef = useRef(0);
|
||||
@@ -87,6 +150,67 @@ export default function AutoBuyer() {
|
||||
return () => clearInterval(id);
|
||||
}, [poll]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/autobuyer/automations')
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const list: FirmSummary[] = d.firms ?? [];
|
||||
setFirms(list);
|
||||
setActiveFirm((current) => current ?? list[0]?.id ?? null);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Runs tick faster than the capture poll — a run is something you watch.
|
||||
const pollRuns = useCallback(async () => {
|
||||
try {
|
||||
const [d, r] = await Promise.all([
|
||||
fetch('/api/autobuyer/runs', { cache: 'no-store' }).then((x) => x.json()),
|
||||
fetch('/api/autobuyer/runner', { cache: 'no-store' }).then((x) => x.json()),
|
||||
]);
|
||||
setRuns(d.runs ?? []);
|
||||
setRunner(r);
|
||||
} catch { /* transient; the next tick retries */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
pollRuns();
|
||||
const id = setInterval(pollRuns, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [pollRuns]);
|
||||
|
||||
const activeRun = runs.find((r) => r.status === 'queued' || r.status === 'running') ?? null;
|
||||
const firmAutomations = firms.find((f) => f.id === activeFirm)?.automations ?? [];
|
||||
|
||||
async function startRun(automation: AutomationSummary) {
|
||||
// Anything that drives the real mouse against a broker gets a confirmation.
|
||||
if (automation.confirm && !window.confirm(
|
||||
`${automation.confirm}\n\nSteps:\n${automation.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
|
||||
)) return;
|
||||
|
||||
setBusyRun(true);
|
||||
try {
|
||||
const res = await fetch('/api/autobuyer/automations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ automationId: automation.key }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) setError(data.error ?? 'Failed to start');
|
||||
else setError(null);
|
||||
await pollRuns();
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to start');
|
||||
} finally {
|
||||
setBusyRun(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRun(id: number) {
|
||||
await fetch(`/api/autobuyer/runs?id=${id}`, { method: 'DELETE' });
|
||||
await pollRuns();
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -153,6 +277,158 @@ export default function AutoBuyer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── One tab per firm ── */}
|
||||
<div className="flex items-end gap-5 mb-3 border-b border-slate-200">
|
||||
{firms.map((firm) => (
|
||||
<button
|
||||
key={firm.id}
|
||||
onClick={() => setActiveFirm(firm.id)}
|
||||
className={`text-sm font-semibold uppercase tracking-wider pb-2 -mb-px border-b-2 transition-colors ${
|
||||
firm.id === activeFirm
|
||||
? 'text-slate-900 border-slate-900'
|
||||
: 'text-slate-400 border-transparent hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{firm.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs pb-2">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
runner?.online ? (runner.busy ? 'bg-blue-500 animate-pulse' : 'bg-green-500') : 'bg-slate-300'
|
||||
}`} />
|
||||
<span className={runner?.online ? 'text-slate-500' : 'text-slate-400'}>
|
||||
{!runner?.online
|
||||
? 'Runner offline'
|
||||
: runner.busy ? 'Runner busy' : 'Runner online'}
|
||||
</span>
|
||||
{runner?.online && runner.stale && (
|
||||
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-xs text-red-800">
|
||||
The runner is out of date (reporting v{runner.version ?? 'unknown'}, this
|
||||
dashboard needs v{runner.expectedVersion}). It will fail on step types it
|
||||
predates. Restart it: stop with Ctrl-C and run{' '}
|
||||
<code className="font-mono">python clicker/runner.py</code> again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runner?.online && runner.dryRun && (
|
||||
<span className="ml-1 px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
|
||||
dry run
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!runner?.online && (
|
||||
<div className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-2.5 text-xs text-slate-600">
|
||||
Nothing will run until the runner is started — it's the process that
|
||||
moves the mouse. In the project directory:{' '}
|
||||
<code className="font-mono text-slate-800">python clicker/runner.py</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runner?.online && runner.stale && (
|
||||
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-xs text-red-800">
|
||||
The runner is out of date (reporting v{runner.version ?? 'unknown'}, this
|
||||
dashboard needs v{runner.expectedVersion}). It will fail on step types it
|
||||
predates. Restart it: stop with Ctrl-C and run{' '}
|
||||
<code className="font-mono">python clicker/runner.py</code> again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runner?.online && runner.dryRun && (
|
||||
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-800">
|
||||
The runner is in dry-run mode: it will walk through the steps and move the
|
||||
cursor, but never press or type. Restart it without <code className="font-mono">--dry-run</code> to act for real.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
|
||||
{firmAutomations.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-slate-400">
|
||||
{firms.length === 0 ? 'No firms configured' : 'No automations for this firm yet'}
|
||||
</div>
|
||||
) : firmAutomations.map((a, i) => (
|
||||
<div
|
||||
key={a.key}
|
||||
className={`flex items-center justify-between px-4 py-3 ${
|
||||
i < firmAutomations.length - 1 ? 'border-b border-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">{a.label}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{a.description} · {a.steps.length} step{a.steps.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => startRun(a)}
|
||||
disabled={!enabled || busyRun || activeRun !== null || !runner?.online || !!runner?.stale}
|
||||
title={
|
||||
!enabled ? 'AutoBuyer is switched off'
|
||||
: !runner?.online ? 'The runner is not running'
|
||||
: runner?.stale ? 'The runner is out of date — restart it'
|
||||
: activeRun !== null ? 'A run is already in progress'
|
||||
: undefined
|
||||
}
|
||||
className="text-sm px-4 py-1.5 rounded-lg font-medium bg-blue-500 hover:bg-blue-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Run status ── */}
|
||||
{runs.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||
Runs
|
||||
</h2>
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8 divide-y divide-slate-100">
|
||||
{runs.map((run) => (
|
||||
<div key={run.id} className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${RUN_BADGE[run.status]}`}>
|
||||
{run.status}
|
||||
</span>
|
||||
<span className="text-sm text-slate-800">{run.automationLabel}</span>
|
||||
<span className="text-xs font-mono text-slate-400">
|
||||
step {Math.min(run.stepIndex + (run.status === 'done' ? 0 : 1), run.totalSteps)}/{run.totalSteps}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-slate-400">
|
||||
{new Date(run.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
{(run.status === 'queued' || run.status === 'running') && (
|
||||
<button
|
||||
onClick={() => cancelRun(run.id)}
|
||||
className="text-xs text-slate-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{run.log.length > 0 && (
|
||||
<ol className="mt-2 space-y-0.5">
|
||||
{run.log.map((entry, i) => (
|
||||
<li key={i} className="text-xs font-mono text-slate-500">
|
||||
<span className={entry.ok ? 'text-green-600' : 'text-red-500'}>
|
||||
{entry.ok ? '✓' : '✗'}
|
||||
</span>{' '}
|
||||
{entry.step}
|
||||
{entry.detail && <span className="text-slate-400"> — {entry.detail}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{run.error && (
|
||||
<p className="mt-1 text-xs text-red-600">{run.error}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Latest capture ── */}
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||
Latest capture
|
||||
|
||||
@@ -16,6 +16,27 @@ clicker --GET /api/autobuyer/locate?id=---> reads the answer
|
||||
clicker moves the mouse, clicks
|
||||
```
|
||||
|
||||
## Two ways to run it
|
||||
|
||||
**`clicker.py`** — one action at a time, from the shell. Use this to find
|
||||
selectors and confirm coordinates before wiring anything up.
|
||||
|
||||
**`runner.py`** — the daemon behind the dashboard's buttons. Leave it running; it
|
||||
polls for queued runs and executes the steps.
|
||||
|
||||
```bash
|
||||
python runner.py # then press a button on the AutoBuyer page
|
||||
python runner.py --dry-run # walks the steps, never presses or types
|
||||
```
|
||||
|
||||
Automations are defined in `lib/automations.ts`, and the steps are sent to the
|
||||
runner by the server — so adding a button means editing that file, and nothing in
|
||||
the runner or the page changes. Each run reports step-by-step progress back to the
|
||||
dashboard, and the page's Stop button halts a run between steps.
|
||||
|
||||
Only one run executes at a time: two processes driving the same physical mouse
|
||||
would interleave clicks.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
@@ -41,6 +62,12 @@ python clicker.py click "button.buy" --dry-run
|
||||
# Actually click.
|
||||
python clicker.py click "button.buy"
|
||||
|
||||
# Type into a field: clicks it to place the caret, then types.
|
||||
python clicker.py type 'input[name="quantity"]' --text "5000" --clear
|
||||
|
||||
# Keep the text out of shell history.
|
||||
echo "5000" | python clicker.py type 'input#qty' --stdin --clear
|
||||
|
||||
# Pin it to a specific tab, and pick the 3rd match.
|
||||
python clicker.py click ".trade-btn" --index 2 --url "https://tradeify.co/*"
|
||||
```
|
||||
@@ -57,6 +84,46 @@ python clicker.py click ".trade-btn" --index 2 --url "https://tradeify.co/*"
|
||||
| `--robotic` | Straight-line move and instant click, skipping the motion model. |
|
||||
| `--seed` | Seed the motion RNG so a run replays identically (debugging). |
|
||||
| `--no-activate` | Don't raise the browser first. The click may then be swallowed. |
|
||||
| `--text` | Text to type (action `type`). |
|
||||
| `--stdin` | Read the text from stdin instead, keeping it out of shell history. |
|
||||
| `--clear` | Select-all and delete before typing, instead of appending at the caret. |
|
||||
| `--allow-enter` | Permit newlines. Each one presses Enter, which may submit the form. |
|
||||
| `--no-verify` | Skip reading the field back after typing. |
|
||||
|
||||
## Typing
|
||||
|
||||
`type` clicks the field to place the caret, then types with an uneven human
|
||||
cadence (45–130ms between keys, a beat after each space, an occasional longer
|
||||
pause). Real OS-level keystrokes are also the *correct* way to fill a React form:
|
||||
setting `.value` directly is ignored by controlled components, while genuine key
|
||||
events are not.
|
||||
|
||||
Four guards, all of which catch silent failures:
|
||||
|
||||
- **Non-ASCII is rejected.** `pyautogui.write()` has no keycode for `é` or `£` and
|
||||
skips them without complaint, which would leave a quietly truncated value in the
|
||||
field. Better to refuse than to submit `caf` where you meant `café`.
|
||||
- **Newlines are rejected** unless `--allow-enter`, because Enter may submit the
|
||||
form — and on this site that could mean placing an order.
|
||||
- **Non-editable targets are refused** — disabled, read-only, or simply not an
|
||||
input. The keystrokes would go nowhere.
|
||||
- **The field is read back afterwards** and compared against what was typed
|
||||
(`--no-verify` skips it). A field that never took focus, or that ignored the
|
||||
input, otherwise looks exactly like success. Exit code 4 means the text did not
|
||||
land.
|
||||
|
||||
`--clear` selects-all and deletes first. Without it the text is inserted at
|
||||
wherever the caret landed, which for a field with existing contents usually
|
||||
produces something like `50005000`.
|
||||
|
||||
Typos are deliberately *not* simulated. A mistyped digit in a trading form is a
|
||||
real loss, and the backspace-and-correct step is exactly the part that can go
|
||||
wrong — a field with input masking or autocomplete can swallow the correction and
|
||||
leave the wrong number behind.
|
||||
|
||||
Don't pass credentials via `--text`: it lands in your shell history and in the
|
||||
process list. `--stdin` avoids both, but nothing here is built to handle secrets
|
||||
safely.
|
||||
|
||||
## Window focus
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Execution of a single automation step: locate the element, then click or type.
|
||||
|
||||
Shared by the CLI (clicker.py) and the automation runner (runner.py) so there is
|
||||
one implementation of the safety checks. Callers differ only in how they report
|
||||
progress and how they surface failures.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import focus
|
||||
|
||||
|
||||
class StepError(Exception):
|
||||
"""A step refused to run, or ran and could not be verified.
|
||||
|
||||
`code` mirrors the CLI's exit codes: 3 = refused before acting,
|
||||
4 = acted but verification failed.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, code: int = 3):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
# Display scaling factors that actually exist. Anything else means the two sides
|
||||
# are describing different things (usually a multi-monitor desktop) rather than a
|
||||
# scaled single display.
|
||||
KNOWN_SCALES = (1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0)
|
||||
|
||||
|
||||
def scale_factor(found: dict, override: float | None, report=None) -> float:
|
||||
"""CSS pixels and desktop pixels match on macOS and unscaled Windows, but
|
||||
Windows display scaling breaks that.
|
||||
|
||||
Deliberately conservative: a raw ratio is only trusted when it lands on a real
|
||||
scaling factor. On a multi-monitor desktop the browser reports the display it
|
||||
is on while pyautogui reports the primary one, and the resulting ratio is
|
||||
meaningless — halving every coordinate would put clicks far from the target.
|
||||
Fall back to 1:1 and say so, rather than silently scaling.
|
||||
"""
|
||||
if override is not None:
|
||||
return override
|
||||
|
||||
try:
|
||||
import pyautogui
|
||||
except ImportError:
|
||||
return 1.0
|
||||
|
||||
css_width = (found.get("screenSize") or {}).get("width")
|
||||
if not css_width:
|
||||
return 1.0
|
||||
|
||||
os_width = pyautogui.size().width
|
||||
ratio = os_width / css_width
|
||||
|
||||
for known in KNOWN_SCALES:
|
||||
if abs(ratio - known) < 0.02:
|
||||
return known
|
||||
|
||||
if report:
|
||||
report(
|
||||
f"screen size disagrees (browser {css_width}px, OS {os_width}px, "
|
||||
f"ratio {ratio:.3f}) — assuming 1:1. If clicks land off, pass --scale."
|
||||
)
|
||||
return 1.0
|
||||
|
||||
|
||||
def perform(
|
||||
dash,
|
||||
action: str,
|
||||
selector: str,
|
||||
*,
|
||||
index: int = 0,
|
||||
url: str = "",
|
||||
open_url: str = "",
|
||||
text: str | None = None,
|
||||
clear: bool = False,
|
||||
scale: float | None = None,
|
||||
timeout: float = 20.0,
|
||||
rng: random.Random | None = None,
|
||||
activate: bool = True,
|
||||
verify: bool = True,
|
||||
force: bool = False,
|
||||
dry_run: bool = False,
|
||||
robotic: bool = False,
|
||||
report=None,
|
||||
on_located=None,
|
||||
) -> dict:
|
||||
"""Run one step. Returns the located element's data.
|
||||
|
||||
Raises DashboardError if the extension can't find it, StepError if the step
|
||||
is refused or fails verification.
|
||||
"""
|
||||
import pyautogui
|
||||
import humanize
|
||||
|
||||
rng = rng or random.Random()
|
||||
say = report or (lambda _m: None)
|
||||
|
||||
found = dash.locate(selector, index, url, timeout, open_url)
|
||||
if on_located:
|
||||
on_located(found)
|
||||
|
||||
if found.get("covered") and not force:
|
||||
raise StepError(
|
||||
f"{selector} is covered by {found.get('coveredBy')} — the click would hit that instead"
|
||||
)
|
||||
|
||||
if action == "type" and not found.get("editable", True) and not force:
|
||||
kind = found.get("inputType")
|
||||
raise StepError(
|
||||
f"<{found['tag']}{f' type={kind}' if kind else ''}> is not an editable field "
|
||||
"(disabled, read-only, or not an input)"
|
||||
)
|
||||
|
||||
factor = scale_factor(found, scale, report)
|
||||
x = found["screen"]["x"] * factor
|
||||
y = found["screen"]["y"] * factor
|
||||
|
||||
screen_w, screen_h = pyautogui.size()
|
||||
if not (0 <= x < screen_w and 0 <= y < screen_h):
|
||||
raise StepError(
|
||||
f"target ({x:.0f}, {y:.0f}) is off-screen ({screen_w}x{screen_h}) — "
|
||||
"is the Chrome window on another display?"
|
||||
)
|
||||
|
||||
say(f"at {x:.0f},{y:.0f}")
|
||||
|
||||
# A click on a background window is consumed activating it and never reaches
|
||||
# the page, so raise the browser immediately before pressing.
|
||||
if activate:
|
||||
focused = focus.ensure_frontmost()
|
||||
say(focused.detail)
|
||||
if not focused.ok:
|
||||
raise StepError(
|
||||
"the browser is not frontmost — the click would be consumed "
|
||||
"activating its window instead of pressing the element"
|
||||
)
|
||||
|
||||
pyautogui.FAILSAFE = True
|
||||
|
||||
if robotic:
|
||||
pyautogui.moveTo(x, y, duration=0.25)
|
||||
if not dry_run:
|
||||
pyautogui.click()
|
||||
else:
|
||||
humanize.click(x, y, rng=rng, press=not dry_run)
|
||||
|
||||
if action == "click" or dry_run:
|
||||
return found
|
||||
|
||||
# ── type ────────────────────────────────────────────────────────────────
|
||||
import time
|
||||
time.sleep(0.15) # let the field take focus and any JS handlers settle
|
||||
|
||||
if clear:
|
||||
humanize.clear_field(rng)
|
||||
humanize.type_text(text or "", rng)
|
||||
|
||||
if not verify:
|
||||
return found
|
||||
|
||||
# Typing into a field that never took focus, or that ignores the input, fails
|
||||
# silently and is otherwise indistinguishable from success.
|
||||
after = dash.locate(selector, index, url, timeout, open_url)
|
||||
got = after.get("value")
|
||||
if got is None:
|
||||
say("element exposes no value to verify against")
|
||||
return found
|
||||
if (text or "") in got:
|
||||
say(f"verified, field contains {got!r}")
|
||||
return after
|
||||
|
||||
raise StepError(f"verification failed — field contains {got!r}", code=4)
|
||||
+101
-80
@@ -27,6 +27,7 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import actions
|
||||
import focus
|
||||
|
||||
DEFAULT_API = "http://localhost:3000"
|
||||
@@ -64,12 +65,18 @@ class Dashboard:
|
||||
def status(self) -> dict:
|
||||
return self._request("/api/autobuyer/status")
|
||||
|
||||
def locate(self, selector: str, index: int, url_pattern: str, timeout: float) -> dict:
|
||||
"""Queue a lookup and block until the extension answers it."""
|
||||
def locate(self, selector: str, index: int, url_pattern: str, timeout: float,
|
||||
open_url: str = "", navigate_url: str = "") -> dict:
|
||||
"""Queue a lookup and block until the extension answers it.
|
||||
|
||||
`open_url` is the page the extension should open if no tab matches
|
||||
`url_pattern` — a match pattern isn't navigable, so it's passed separately.
|
||||
"""
|
||||
queued = self._request(
|
||||
"/api/autobuyer/locate",
|
||||
"POST",
|
||||
{"selector": selector, "index": index, "urlPattern": url_pattern},
|
||||
{"selector": selector, "index": index, "urlPattern": url_pattern,
|
||||
"openUrl": open_url, "navigateUrl": navigate_url},
|
||||
)
|
||||
request_id = queued["id"]
|
||||
|
||||
@@ -88,28 +95,6 @@ class Dashboard:
|
||||
)
|
||||
|
||||
|
||||
def scale_factor(found: dict, override: float | None) -> float:
|
||||
"""CSS pixels and desktop pixels are the same on macOS and on unscaled Windows,
|
||||
but Windows display scaling and some Linux setups break that. Compare the
|
||||
screen size the browser reports against the one the OS reports."""
|
||||
if override is not None:
|
||||
return override
|
||||
|
||||
try:
|
||||
import pyautogui
|
||||
except ImportError:
|
||||
return 1.0 # `locate` is useful without the mouse library installed
|
||||
|
||||
os_width = pyautogui.size().width
|
||||
css_width = (found.get("screenSize") or {}).get("width")
|
||||
if not css_width:
|
||||
return 1.0
|
||||
ratio = os_width / css_width
|
||||
# Only trust a clean-ish ratio; anything odd means a multi-monitor layout we
|
||||
# shouldn't guess at, so fall back to 1:1 and let --scale override.
|
||||
return ratio if 0.4 < ratio < 4.0 else 1.0
|
||||
|
||||
|
||||
def describe(found: dict, factor: float) -> str:
|
||||
x, y = found["screen"]["x"] * factor, found["screen"]["y"] * factor
|
||||
lines = [
|
||||
@@ -127,13 +112,15 @@ def describe(found: dict, factor: float) -> str:
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("action", choices=["locate", "click"],
|
||||
help="locate = measure only; click = measure then click")
|
||||
parser.add_argument("action", choices=["locate", "click", "type"],
|
||||
help="locate = measure only; click = measure then click; "
|
||||
"type = click the field, then type into it")
|
||||
parser.add_argument("selector", help="CSS selector of the target element")
|
||||
parser.add_argument("--index", type=int, default=0, help="which match, if the selector hits several (default 0)")
|
||||
parser.add_argument("--url", default="", help='Chrome match pattern for the tab, e.g. "https://tradeify.co/*"')
|
||||
parser.add_argument("--open-url", default="", help="page to open if no tab matches --url")
|
||||
parser.add_argument("--api", default=DEFAULT_API, help=f"dashboard URL (default {DEFAULT_API})")
|
||||
parser.add_argument("--timeout", type=float, default=20.0, help="seconds to wait for the extension (default 20)")
|
||||
parser.add_argument("--timeout", type=float, default=45.0, help="seconds to wait for the extension (default 45; a cold page load takes time)")
|
||||
parser.add_argument("--scale", type=float, default=None, help="CSS-to-desktop pixel ratio (default: auto-detect)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="move the cursor to the target but do not press")
|
||||
parser.add_argument("--force", action="store_true", help="click even if something is covering the element")
|
||||
@@ -143,79 +130,113 @@ def main() -> int:
|
||||
help="seed the motion RNG so a run is reproducible (for debugging)")
|
||||
parser.add_argument("--no-activate", action="store_true",
|
||||
help="do not raise the browser first (the click may be eaten by window activation)")
|
||||
parser.add_argument("--text", default=None, help="text to type (action=type)")
|
||||
parser.add_argument("--stdin", action="store_true",
|
||||
help="read the text to type from stdin instead of --text, keeping it out of shell history")
|
||||
parser.add_argument("--clear", action="store_true",
|
||||
help="select-all and delete before typing, rather than appending at the caret")
|
||||
parser.add_argument("--allow-enter", action="store_true",
|
||||
help="permit newlines in the text (each one presses Enter, which may submit the form)")
|
||||
parser.add_argument("--no-verify", action="store_true",
|
||||
help="skip reading the field back after typing")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Anything that moves the mouse or presses a key needs pyautogui; check once,
|
||||
# up front, so a missing dependency reports itself rather than surfacing as an
|
||||
# ImportError from somewhere deeper.
|
||||
if args.action in ("click", "type"):
|
||||
try:
|
||||
import pyautogui # noqa: F401
|
||||
except ImportError:
|
||||
print("error: pyautogui is not installed — run: pip install -r requirements.txt",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# ── resolve and vet the text before touching anything ───────────────────
|
||||
text = ""
|
||||
if args.action == "type":
|
||||
if args.stdin:
|
||||
text = sys.stdin.read()
|
||||
elif args.text is not None:
|
||||
text = args.text
|
||||
else:
|
||||
print("error: type needs --text or --stdin", file=sys.stderr)
|
||||
return 1
|
||||
if not text:
|
||||
print("error: nothing to type", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
import humanize as _h
|
||||
bad = _h.untypeable(text)
|
||||
if bad:
|
||||
print(f"error: pyautogui cannot type {''.join(bad)!r} and would silently "
|
||||
f"skip those characters, leaving a truncated value in the field.",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
if "\n" in text and not args.allow_enter:
|
||||
print("error: the text contains a newline, which presses Enter and may "
|
||||
"submit the form. Pass --allow-enter if that is intended.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
dash = Dashboard(args.api)
|
||||
|
||||
try:
|
||||
if not dash.status().get("enabled"):
|
||||
print("AutoBuyer capture is OFF — turn it on from the dashboard first.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"Locating {args.selector!r} …", flush=True)
|
||||
found = dash.locate(args.selector, args.index, args.url, args.timeout)
|
||||
except DashboardError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Locating {args.selector!r} …", flush=True)
|
||||
|
||||
# `locate` never touches the mouse, so it stays a plain lookup.
|
||||
if args.action == "locate":
|
||||
print(describe(found, scale_factor(found, args.scale)))
|
||||
try:
|
||||
found = dash.locate(args.selector, args.index, args.url, args.timeout, args.open_url)
|
||||
except DashboardError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(describe(found, actions.scale_factor(found, args.scale)))
|
||||
return 0
|
||||
|
||||
if found.get("covered") and not args.force:
|
||||
print(describe(found, 1.0))
|
||||
print("\nRefusing to click: the element is covered — the click would hit "
|
||||
f"{found['coveredBy']} instead. Pass --force to click anyway.", file=sys.stderr)
|
||||
return 3
|
||||
rng = random.Random(args.seed) if args.seed is not None else random.Random()
|
||||
|
||||
try:
|
||||
import pyautogui
|
||||
except ImportError:
|
||||
print("error: pyautogui is not installed — run: pip install -r requirements.txt", file=sys.stderr)
|
||||
actions.perform(
|
||||
dash,
|
||||
args.action,
|
||||
args.selector,
|
||||
index=args.index,
|
||||
url=args.url,
|
||||
open_url=args.open_url,
|
||||
text=text,
|
||||
clear=args.clear,
|
||||
scale=args.scale,
|
||||
timeout=args.timeout,
|
||||
rng=rng,
|
||||
activate=not args.no_activate,
|
||||
verify=not args.no_verify,
|
||||
force=args.force,
|
||||
dry_run=args.dry_run,
|
||||
robotic=args.robotic,
|
||||
on_located=lambda f: print(describe(f, actions.scale_factor(f, args.scale))),
|
||||
report=lambda m: print(f" {m}"),
|
||||
)
|
||||
except DashboardError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Slamming the cursor into a screen corner aborts the script.
|
||||
pyautogui.FAILSAFE = True
|
||||
|
||||
factor = scale_factor(found, args.scale)
|
||||
x = found["screen"]["x"] * factor
|
||||
y = found["screen"]["y"] * factor
|
||||
|
||||
screen_w, screen_h = pyautogui.size()
|
||||
if not (0 <= x < screen_w and 0 <= y < screen_h):
|
||||
print(describe(found, factor))
|
||||
print(f"\nerror: target ({x:.0f}, {y:.0f}) is off-screen ({screen_w}x{screen_h}). "
|
||||
f"Is the Chrome window partly off the display, or on a second monitor?", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
print(describe(found, factor))
|
||||
|
||||
# A click on a background window is eaten by the window manager activating it,
|
||||
# so the first attempt silently does nothing. Raise the browser first, and do it
|
||||
# here rather than before locating: the measurement takes seconds, and focus is
|
||||
# only required at the moment of the press.
|
||||
if not args.no_activate:
|
||||
focused = focus.ensure_frontmost()
|
||||
print(f" focus {focused.detail}")
|
||||
if not focused.ok:
|
||||
print("\nRefusing to click: the browser is not frontmost, so the click "
|
||||
"would be consumed activating its window instead of pressing the "
|
||||
"element. Pass --no-activate to override.", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
if args.robotic:
|
||||
pyautogui.moveTo(x, y, duration=0.25)
|
||||
if not args.dry_run:
|
||||
pyautogui.click()
|
||||
else:
|
||||
import humanize
|
||||
rng = random.Random(args.seed) if args.seed is not None else random.Random()
|
||||
humanize.click(x, y, rng=rng, press=not args.dry_run)
|
||||
except actions.StepError as exc:
|
||||
print(f"\nerror: {exc}", file=sys.stderr)
|
||||
return exc.code
|
||||
|
||||
if args.dry_run:
|
||||
print("\ndry run — cursor moved, no click sent.")
|
||||
else:
|
||||
print("\ndry run — cursor moved, nothing pressed or typed.")
|
||||
elif args.action == "click":
|
||||
print("\nclicked.")
|
||||
else:
|
||||
shown = text if len(text) <= 60 else text[:57] + "\u2026"
|
||||
print(f"\ntyped {shown!r}" + (" (field cleared first)" if args.clear else ""))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ before pressing, and holds the button down for a human interval.
|
||||
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pyautogui
|
||||
@@ -116,6 +117,54 @@ def move(x: float, y: float, rng: random.Random | None = None) -> None:
|
||||
pyautogui.moveTo(x, y, duration=0, _pause=False)
|
||||
|
||||
|
||||
# Typing rhythm.
|
||||
KEY_DELAY = (0.045, 0.130) # between consecutive keystrokes
|
||||
WORD_PAUSE = (0.050, 0.170) # extra beat after a space
|
||||
THINK_CHANCE = 0.045 # occasional longer pause mid-string
|
||||
THINK_PAUSE = (0.22, 0.55)
|
||||
|
||||
# pyautogui.write() can only emit characters it has a keycode for — roughly
|
||||
# printable ASCII. Anything else is *silently skipped*, so we reject it up front
|
||||
# rather than typing a quietly truncated string into a form.
|
||||
TYPEABLE = frozenset(chr(c) for c in range(32, 127)) | {"\t"}
|
||||
|
||||
|
||||
def untypeable(text: str) -> list[str]:
|
||||
"""Characters pyautogui would silently drop. Empty list means safe to type."""
|
||||
return sorted({c for c in text if c not in TYPEABLE and c != "\n"})
|
||||
|
||||
|
||||
def clear_field(rng: random.Random | None = None) -> None:
|
||||
"""Select-all then delete, in the focused field."""
|
||||
rng = rng or random.Random()
|
||||
modifier = "command" if sys.platform == "darwin" else "ctrl"
|
||||
pyautogui.hotkey(modifier, "a", _pause=False)
|
||||
time.sleep(rng.uniform(0.05, 0.12))
|
||||
pyautogui.press("delete", _pause=False)
|
||||
time.sleep(rng.uniform(0.05, 0.12))
|
||||
|
||||
|
||||
def type_text(text: str, rng: random.Random | None = None) -> None:
|
||||
"""Type with a human cadence: uneven keystrokes, a beat after each word, and
|
||||
the occasional pause. Deliberately does NOT simulate typos — a mistyped digit
|
||||
in a trading form that fails to get corrected is a real loss, and the
|
||||
correction is exactly the part that can go wrong."""
|
||||
rng = rng or random.Random()
|
||||
|
||||
for ch in text:
|
||||
if ch == "\n":
|
||||
pyautogui.press("enter", _pause=False)
|
||||
else:
|
||||
pyautogui.write(ch, _pause=False)
|
||||
|
||||
delay = rng.uniform(*KEY_DELAY)
|
||||
if ch == " ":
|
||||
delay += rng.uniform(*WORD_PAUSE)
|
||||
if rng.random() < THINK_CHANCE:
|
||||
delay += rng.uniform(*THINK_PAUSE)
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
def click(x: float, y: float, rng: random.Random | None = None, press: bool = True) -> None:
|
||||
"""Move to (x, y), settle, then press and release.
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Automation runner — the process that makes dashboard buttons do something.
|
||||
|
||||
Leave this running. It polls the dashboard for queued runs, and when one appears
|
||||
it works through that automation's steps, driving the real mouse and keyboard,
|
||||
reporting each step back so the page can show progress.
|
||||
|
||||
python runner.py # against http://localhost:3000
|
||||
python runner.py --api http://vps:3000
|
||||
|
||||
Steps come from the server, so adding a new button means editing
|
||||
lib/automations.ts — nothing here needs to change.
|
||||
|
||||
Stop with Ctrl-C. A run in progress can be halted from the dashboard's Stop
|
||||
button; the runner notices between steps.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import actions
|
||||
from clicker import Dashboard, DashboardError
|
||||
|
||||
POLL_SECONDS = 1.0
|
||||
HEARTBEAT_SECONDS = 2.0
|
||||
|
||||
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
|
||||
# the heartbeat so the dashboard can say "restart your runner" instead of letting
|
||||
# a stale process fail on a step type it has never heard of.
|
||||
VERSION = "0.4.0"
|
||||
|
||||
# Shared with the heartbeat thread: whether a run is currently executing.
|
||||
_busy = threading.Event()
|
||||
|
||||
|
||||
def heartbeat_loop(dash: Dashboard, opts, stop: threading.Event) -> None:
|
||||
"""Check in on our own thread.
|
||||
|
||||
Deliberately not folded into the main poll loop: a single step can block for
|
||||
twenty seconds waiting on the extension, and a runner that goes quiet that
|
||||
long would show as offline in the middle of the run it is executing.
|
||||
"""
|
||||
payload = {"host": socket.gethostname(), "pid": os.getpid(),
|
||||
"dryRun": opts.dry_run, "version": VERSION}
|
||||
while not stop.is_set():
|
||||
try:
|
||||
dash._request("/api/autobuyer/runner", "POST", {**payload, "busy": _busy.is_set()})
|
||||
except DashboardError:
|
||||
pass # the main loop reports connectivity; don't double up
|
||||
stop.wait(HEARTBEAT_SECONDS)
|
||||
|
||||
|
||||
def run_steps(dash: Dashboard, run: dict, opts) -> None:
|
||||
"""Work through one automation. Reports every step; stops on the first
|
||||
failure, because a half-completed purchase flow should not barrel on."""
|
||||
run_id = run["id"]
|
||||
steps = run["steps"]
|
||||
rng = random.Random(opts.seed) if opts.seed is not None else random.Random()
|
||||
|
||||
print(f"\n▶ run #{run_id} — {run['label']} ({len(steps)} steps)")
|
||||
|
||||
for i, step in enumerate(steps):
|
||||
label = step.get("label") or f"{step['action']} {step.get('selector', '')}".strip()
|
||||
print(f" [{i + 1}/{len(steps)}] {label}")
|
||||
|
||||
detail_parts: list[str] = []
|
||||
|
||||
def report(message: str) -> None:
|
||||
detail_parts.append(message)
|
||||
print(f" {message}")
|
||||
|
||||
try:
|
||||
if step["action"] == "wait":
|
||||
time.sleep(float(step.get("seconds", 1)))
|
||||
report(f"waited {step.get('seconds', 1)}s")
|
||||
elif step["action"] == "navigate":
|
||||
# No mouse involved: the extension points the tab at the page and
|
||||
# waits for it to load. Locating <body> confirms it's really there.
|
||||
target = step.get("url", "")
|
||||
dash.locate("body", 0, step.get("urlPattern", "") or opts.url,
|
||||
opts.timeout, step.get("openUrl", "") or "",
|
||||
navigate_url=target)
|
||||
report(f"tab is on {target}")
|
||||
elif step["action"] not in ("click", "type"):
|
||||
# Almost always a stale runner: the server defines the step
|
||||
# vocabulary, so a step type this process has never heard of means
|
||||
# automations.ts has moved on and this file hasn't been restarted.
|
||||
raise actions.StepError(
|
||||
f"unknown step action {step['action']!r} — this runner is "
|
||||
f"v{VERSION}; restart it to pick up newer step types"
|
||||
)
|
||||
else:
|
||||
actions.perform(
|
||||
dash,
|
||||
step["action"],
|
||||
step["selector"],
|
||||
index=int(step.get("index", 0)),
|
||||
url=step.get("urlPattern", "") or opts.url,
|
||||
open_url=step.get("openUrl", "") or "",
|
||||
text=step.get("text"),
|
||||
clear=bool(step.get("clear")),
|
||||
scale=opts.scale,
|
||||
timeout=opts.timeout,
|
||||
rng=rng,
|
||||
activate=not opts.no_activate,
|
||||
dry_run=opts.dry_run,
|
||||
report=report,
|
||||
)
|
||||
except (actions.StepError, DashboardError) as exc:
|
||||
print(f" FAILED: {exc}", file=sys.stderr)
|
||||
post_progress(dash, run_id, i, label, False, str(exc))
|
||||
finish(dash, run_id, str(exc))
|
||||
print(f"✗ run #{run_id} stopped at step {i + 1}")
|
||||
return
|
||||
|
||||
status = post_progress(dash, run_id, i + 1, label, True, "; ".join(detail_parts) or None)
|
||||
|
||||
# The dashboard's Stop button shows up here.
|
||||
if status == "cancelled":
|
||||
print(f"■ run #{run_id} cancelled from the dashboard")
|
||||
return
|
||||
|
||||
finish(dash, run_id, None)
|
||||
print(f"✓ run #{run_id} complete")
|
||||
|
||||
|
||||
def post_progress(dash, run_id, step_index, step, ok, detail):
|
||||
try:
|
||||
res = dash._request("/api/autobuyer/runs/progress", "POST", {
|
||||
"id": run_id, "stepIndex": step_index, "step": step, "ok": ok, "detail": detail,
|
||||
})
|
||||
return res.get("status")
|
||||
except DashboardError as exc:
|
||||
print(f" (could not report progress: {exc})", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def finish(dash, run_id, error):
|
||||
try:
|
||||
dash._request("/api/autobuyer/runs/progress", "POST",
|
||||
{"id": run_id, "finish": True, "error": error})
|
||||
except DashboardError as exc:
|
||||
print(f" (could not report completion: {exc})", file=sys.stderr)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL")
|
||||
parser.add_argument("--url", default="", help="fallback Chrome match pattern for steps that omit one")
|
||||
parser.add_argument("--timeout", type=float, default=45.0, help="seconds to wait for the extension per step (a cold page load takes time)")
|
||||
parser.add_argument("--scale", type=float, default=None, help="CSS-to-desktop pixel ratio")
|
||||
parser.add_argument("--seed", type=int, default=None, help="seed the motion RNG (debugging)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="move the cursor through the steps but never press or type")
|
||||
parser.add_argument("--no-activate", action="store_true", help="do not raise the browser before each step")
|
||||
opts = parser.parse_args()
|
||||
|
||||
try:
|
||||
import pyautogui # noqa: F401
|
||||
except ImportError:
|
||||
print("error: pyautogui is not installed — run: pip install -r requirements.txt", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
dash = Dashboard(opts.api)
|
||||
print(f"Runner watching {opts.api}" + (" [DRY RUN — nothing will be pressed]" if opts.dry_run else ""))
|
||||
print("Waiting for a run. Press a button on the AutoBuyer page. Ctrl-C to stop.")
|
||||
|
||||
stop = threading.Event()
|
||||
beat = threading.Thread(target=heartbeat_loop, args=(dash, opts, stop), daemon=True)
|
||||
beat.start()
|
||||
|
||||
idle_warned = False
|
||||
while True:
|
||||
try:
|
||||
claim = dash._request("/api/autobuyer/runs/claim", "POST", {})
|
||||
except DashboardError as exc:
|
||||
if not idle_warned:
|
||||
print(f" ({exc})", file=sys.stderr)
|
||||
idle_warned = True
|
||||
time.sleep(POLL_SECONDS * 3)
|
||||
continue
|
||||
|
||||
idle_warned = False
|
||||
run = claim.get("run")
|
||||
if not run:
|
||||
time.sleep(POLL_SECONDS)
|
||||
continue
|
||||
|
||||
_busy.set()
|
||||
try:
|
||||
run_steps(dash, run, opts)
|
||||
except Exception as exc: # keep the daemon alive
|
||||
print(f"✗ run failed unexpectedly: {exc}", file=sys.stderr)
|
||||
finish(dash, run["id"], f"runner error: {exc}")
|
||||
finally:
|
||||
_busy.clear()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopped.")
|
||||
sys.exit(0)
|
||||
+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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AutoFirmer Capture",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.",
|
||||
"permissions": ["scripting", "tabs", "storage", "alarms"],
|
||||
"host_permissions": [
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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 };
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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;
|
||||
automations: Automation[];
|
||||
}
|
||||
|
||||
export const FIRMS: Firm[] = [
|
||||
{
|
||||
id: 'tradeify',
|
||||
label: 'Tradeify',
|
||||
urlPattern: 'https://app-f.tradeify.co/*',
|
||||
url: 'https://app-f.tradeify.co/',
|
||||
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' },
|
||||
// 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 };
|
||||
}
|
||||
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'}`;
|
||||
}
|
||||
}
|
||||
@@ -392,6 +392,8 @@ export interface LocateRow {
|
||||
selector: string;
|
||||
match_index: number;
|
||||
url_pattern: string;
|
||||
open_url: string;
|
||||
navigate_url: string;
|
||||
status: 'pending' | 'claimed' | 'done' | 'error';
|
||||
result: string | null;
|
||||
error: string | null;
|
||||
@@ -401,10 +403,10 @@ export interface LocateRow {
|
||||
|
||||
const LOCATE_HISTORY = 20;
|
||||
|
||||
export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string): LocateRow {
|
||||
export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string, openUrl = '', navigateUrl = ''): LocateRow {
|
||||
const res = db.prepare(
|
||||
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, created_at) VALUES (?, ?, ?, ?)'
|
||||
).run(selector, matchIndex, urlPattern, Date.now());
|
||||
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, Date.now());
|
||||
|
||||
db.prepare(`
|
||||
DELETE FROM autobuyer_locate
|
||||
@@ -436,3 +438,150 @@ export function resolveLocateRequest(id: number, result: unknown | null, error:
|
||||
db.prepare('UPDATE autobuyer_locate SET status = ?, result = ?, error = ?, resolved_at = ? WHERE id = ?')
|
||||
.run(error ? 'error' : 'done', result ? JSON.stringify(result) : null, error, Date.now(), id);
|
||||
}
|
||||
|
||||
// ── AutoBuyer automation runs ────────────────────────────────────────────────
|
||||
//
|
||||
// The dashboard queues a run; the Python runner claims it and works through the
|
||||
// steps, reporting progress back so the page can show what is happening.
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS autobuyer_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
step_index INTEGER NOT NULL DEFAULT 0,
|
||||
total_steps INTEGER NOT NULL DEFAULT 0,
|
||||
log TEXT NOT NULL DEFAULT '[]',
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// 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 ''");
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Migration: the page to open when no tab matches the pattern.
|
||||
try {
|
||||
db.exec("ALTER TABLE autobuyer_locate ADD COLUMN open_url TEXT NOT NULL DEFAULT ''");
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
export type RunStatus = 'queued' | 'running' | 'done' | 'error' | 'cancelled';
|
||||
|
||||
export interface RunRow {
|
||||
id: number;
|
||||
automation_id: string;
|
||||
status: RunStatus;
|
||||
step_index: number;
|
||||
total_steps: number;
|
||||
log: string; // JSON { at: number; step: string; ok: boolean; detail?: string }[]
|
||||
error: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const RUN_HISTORY = 30;
|
||||
|
||||
export function createRun(automationId: string, totalSteps: number): RunRow {
|
||||
const now = Date.now();
|
||||
const res = db.prepare(
|
||||
'INSERT INTO autobuyer_runs (automation_id, total_steps, created_at, updated_at) VALUES (?, ?, ?, ?)'
|
||||
).run(automationId, totalSteps, now, now);
|
||||
|
||||
db.prepare(`
|
||||
DELETE FROM autobuyer_runs
|
||||
WHERE id NOT IN (SELECT id FROM autobuyer_runs ORDER BY id DESC LIMIT ?)
|
||||
`).run(RUN_HISTORY);
|
||||
|
||||
return db.prepare('SELECT * FROM autobuyer_runs WHERE id = ?').get(res.lastInsertRowid) as RunRow;
|
||||
}
|
||||
|
||||
export function getRun(id: number): RunRow | undefined {
|
||||
return db.prepare('SELECT * FROM autobuyer_runs WHERE id = ?').get(id) as RunRow | undefined;
|
||||
}
|
||||
|
||||
export function getRecentRuns(limit = 5): RunRow[] {
|
||||
return db.prepare('SELECT * FROM autobuyer_runs ORDER BY id DESC LIMIT ?').all(limit) as RunRow[];
|
||||
}
|
||||
|
||||
export function countActiveRuns(): number {
|
||||
const row = db.prepare("SELECT COUNT(*) AS n FROM autobuyer_runs WHERE status IN ('queued','running')").get() as { n: number };
|
||||
return row.n;
|
||||
}
|
||||
|
||||
/** The runner takes the oldest queued run. Only one runs at a time — two
|
||||
* processes driving the same physical mouse would interleave clicks. */
|
||||
export function claimRun(): RunRow | undefined {
|
||||
const running = db.prepare("SELECT 1 FROM autobuyer_runs WHERE status = 'running'").get();
|
||||
if (running) return undefined;
|
||||
|
||||
const row = db.prepare("SELECT * FROM autobuyer_runs WHERE status = 'queued' ORDER BY id LIMIT 1").get() as RunRow | undefined;
|
||||
if (!row) return undefined;
|
||||
|
||||
db.prepare("UPDATE autobuyer_runs SET status = 'running', updated_at = ? WHERE id = ?").run(Date.now(), row.id);
|
||||
return { ...row, status: 'running' };
|
||||
}
|
||||
|
||||
export function appendRunLog(id: number, entry: unknown, stepIndex: number): void {
|
||||
const row = getRun(id);
|
||||
if (!row) return;
|
||||
let log: unknown[];
|
||||
try { log = JSON.parse(row.log); } catch { log = []; }
|
||||
log.push(entry);
|
||||
db.prepare('UPDATE autobuyer_runs SET log = ?, step_index = ?, updated_at = ? WHERE id = ?')
|
||||
.run(JSON.stringify(log), stepIndex, Date.now(), id);
|
||||
}
|
||||
|
||||
export function finishRun(id: number, status: RunStatus, error: string | null): void {
|
||||
db.prepare('UPDATE autobuyer_runs SET status = ?, error = ?, updated_at = ? WHERE id = ?')
|
||||
.run(status, error, Date.now(), id);
|
||||
}
|
||||
|
||||
/** Cancelling a queued run stops it starting; cancelling a running one is seen
|
||||
* by the runner between steps. */
|
||||
export function cancelRun(id: number): boolean {
|
||||
const row = getRun(id);
|
||||
if (!row || row.status === 'done' || row.status === 'error' || row.status === 'cancelled') return false;
|
||||
finishRun(id, 'cancelled', null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Runner heartbeat ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The runner is a desktop process, so the server can't tell whether it's alive.
|
||||
// It reports in every couple of seconds; if the beats stop, the dashboard greys
|
||||
// out the buttons rather than queueing runs nobody will execute.
|
||||
|
||||
/** Three missed beats. Long enough to ride out a slow tick, short enough that a
|
||||
* killed runner shows as offline before you press anything. */
|
||||
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.4.0';
|
||||
|
||||
export interface RunnerHeartbeat {
|
||||
at: number;
|
||||
host?: string;
|
||||
pid?: number;
|
||||
dryRun?: boolean;
|
||||
busy?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function setRunnerHeartbeat(info: Omit<RunnerHeartbeat, 'at'>): void {
|
||||
setSetting('runner_heartbeat', JSON.stringify({ ...info, at: Date.now() }));
|
||||
}
|
||||
|
||||
export function getRunnerHeartbeat(): RunnerHeartbeat | null {
|
||||
const raw = getSetting('runner_heartbeat');
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw) as RunnerHeartbeat; } catch { return null; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user