Add autobuyer page capture, browser extension, and desktop clicker
Builds the pipeline the autobuyer needs: see the page, find an element, click it. extension/ — MV3 Chromium extension. Polls /api/autobuyer/status and, while on, scrapes the target tab's HTML and posts it back. Also serves locate requests: focuses the window, scrolls the element into view, and reports its position. host_permissions is scoped to tradeify plus localhost so it cannot read other sites — an empty target pattern would otherwise capture whatever tab happened to be active, including banking or mail. app/api/autobuyer/ — status toggle, capture store, and the locate request queue. CORS is open because the extension's origin changes every time an unpacked extension is reloaded. app/autobuyer/page.tsx — ON switch, source view (default) and a rendered view. The render uses sandbox="allow-scripts" without allow-same-origin: the page's own JS is needed because sites ship content at opacity:0 and fade it in, but the frame must not reach the dashboard's same-origin API routes, which serve firm credentials. clicker/ — Python CLI. Asks the extension where a selector is, adds the element rect to the window's screen position and the browser chrome height to get desktop coordinates, then clicks with a human motion model (curved path, eased velocity, occasional overshoot, dwell before press). Raises the browser application first, since macOS consumes a click on an unfocused window rather than delivering it. Refuses to click when the element is covered by an overlay, when the coordinates fall off-screen, or when the browser cannot be confirmed frontmost. Verified: API round-trips, capture pruning, locate claim-once semantics, motion geometry and timing, and focus activation — the last two against stubs, since pyautogui and pyobjc are not installed here. NOT verified end to end: Chrome is still running a stale build of the extension, so a locate request has never completed against a real page and no real click has been sent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
24a54c6642
commit
54221bbc0c
@@ -317,3 +317,122 @@ export function setBannedSymbol(firmId: number, symbol: string, banned: boolean)
|
||||
db.prepare('DELETE FROM firm_banned_symbols WHERE firm_id = ? AND symbol = ?').run(firmId, symbol);
|
||||
}
|
||||
}
|
||||
|
||||
// ── AutoBuyer captures ───────────────────────────────────────────────────────
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS autobuyer_captures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
html TEXT NOT NULL,
|
||||
viewport TEXT NOT NULL DEFAULT '{}',
|
||||
elements TEXT NOT NULL DEFAULT '[]',
|
||||
captured_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
seedSetting.run('autobuyer_enabled', '0');
|
||||
|
||||
export interface CaptureRow {
|
||||
id: number;
|
||||
url: string;
|
||||
title: string;
|
||||
html: string;
|
||||
viewport: string; // JSON ViewportMetrics
|
||||
elements: string; // JSON ElementPosition[]
|
||||
captured_at: number;
|
||||
}
|
||||
|
||||
/** Number of captures kept on disk — the extension overwrites constantly, so only
|
||||
* a short tail is useful and an unbounded table would grow by megabytes a minute. */
|
||||
const CAPTURE_HISTORY = 5;
|
||||
|
||||
export function saveCapture(c: Omit<CaptureRow, 'id'>): void {
|
||||
db.prepare(
|
||||
'INSERT INTO autobuyer_captures (url, title, html, viewport, elements, captured_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(c.url, c.title, c.html, c.viewport, c.elements, c.captured_at);
|
||||
|
||||
db.prepare(`
|
||||
DELETE FROM autobuyer_captures
|
||||
WHERE id NOT IN (SELECT id FROM autobuyer_captures ORDER BY id DESC LIMIT ?)
|
||||
`).run(CAPTURE_HISTORY);
|
||||
}
|
||||
|
||||
export function getLatestCapture(): CaptureRow | undefined {
|
||||
return db.prepare('SELECT * FROM autobuyer_captures ORDER BY id DESC LIMIT 1').get() as CaptureRow | undefined;
|
||||
}
|
||||
|
||||
export function clearCaptures(): void {
|
||||
db.prepare('DELETE FROM autobuyer_captures').run();
|
||||
}
|
||||
|
||||
// ── AutoBuyer locate/click requests ──────────────────────────────────────────
|
||||
//
|
||||
// The Python clicker can't see inside Chrome, and the extension can't move the
|
||||
// mouse. So they meet here: Python enqueues "where is <selector>?", the extension
|
||||
// focuses the tab, measures the element and writes back desktop coordinates.
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS autobuyer_locate (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
selector TEXT NOT NULL,
|
||||
match_index INTEGER NOT NULL DEFAULT 0,
|
||||
url_pattern TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
resolved_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
export interface LocateRow {
|
||||
id: number;
|
||||
selector: string;
|
||||
match_index: number;
|
||||
url_pattern: string;
|
||||
status: 'pending' | 'claimed' | 'done' | 'error';
|
||||
result: string | null;
|
||||
error: string | null;
|
||||
created_at: number;
|
||||
resolved_at: number | null;
|
||||
}
|
||||
|
||||
const LOCATE_HISTORY = 20;
|
||||
|
||||
export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string): LocateRow {
|
||||
const res = db.prepare(
|
||||
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, created_at) VALUES (?, ?, ?, ?)'
|
||||
).run(selector, matchIndex, urlPattern, Date.now());
|
||||
|
||||
db.prepare(`
|
||||
DELETE FROM autobuyer_locate
|
||||
WHERE id NOT IN (SELECT id FROM autobuyer_locate ORDER BY id DESC LIMIT ?)
|
||||
`).run(LOCATE_HISTORY);
|
||||
|
||||
return db.prepare('SELECT * FROM autobuyer_locate WHERE id = ?').get(res.lastInsertRowid) as LocateRow;
|
||||
}
|
||||
|
||||
export function getLocateRequest(id: number): LocateRow | undefined {
|
||||
return db.prepare('SELECT * FROM autobuyer_locate WHERE id = ?').get(id) as LocateRow | undefined;
|
||||
}
|
||||
|
||||
export function countPendingLocate(): number {
|
||||
const row = db.prepare("SELECT COUNT(*) AS n FROM autobuyer_locate WHERE status = 'pending'").get() as { n: number };
|
||||
return row.n;
|
||||
}
|
||||
|
||||
/** Hand the oldest pending request to the extension, marking it claimed so a
|
||||
* slow round-trip doesn't cause the same click to be served twice. */
|
||||
export function claimLocateRequest(): LocateRow | undefined {
|
||||
const row = db.prepare("SELECT * FROM autobuyer_locate WHERE status = 'pending' ORDER BY id LIMIT 1").get() as LocateRow | undefined;
|
||||
if (!row) return undefined;
|
||||
db.prepare("UPDATE autobuyer_locate SET status = 'claimed' WHERE id = ?").run(row.id);
|
||||
return { ...row, status: 'claimed' };
|
||||
}
|
||||
|
||||
export function resolveLocateRequest(id: number, result: unknown | null, error: string | null): void {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user