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:
Brandon Li
2026-08-27 16:15:18 -05:00
co-authored by Claude Opus 5
parent 24a54c6642
commit 54221bbc0c
19 changed files with 1752 additions and 2 deletions
+4
View File
@@ -49,3 +49,7 @@ scripts/lucid-cookies.json
scripts/lucid-config.json
scripts/*.png
autotrader.sqlite
# python
__pycache__/
*.pyc
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest } from 'next/server';
import { saveCapture, getLatestCapture, clearCaptures } from '@/lib/db';
import { corsJson, corsPreflight } from '../cors';
/** Guard against a runaway page dumping tens of megabytes into SQLite every poll. */
const MAX_HTML_BYTES = 8 * 1024 * 1024;
interface CapturePayload {
url?: string;
title?: string;
html?: string;
viewport?: unknown;
elements?: unknown;
capturedAt?: number;
}
/** Written by the Chromium extension each time it scrapes the target tab. */
export async function POST(req: NextRequest) {
try {
const body = await req.json() as CapturePayload;
if (typeof body.html !== 'string' || typeof body.url !== 'string') {
return corsJson({ error: '`url` and `html` are required' }, { status: 400 });
}
if (body.html.length > MAX_HTML_BYTES) {
return corsJson({ error: `HTML exceeds ${MAX_HTML_BYTES} bytes` }, { status: 413 });
}
const capturedAt = typeof body.capturedAt === 'number' ? body.capturedAt : Date.now();
saveCapture({
url: body.url,
title: typeof body.title === 'string' ? body.title : '',
html: body.html,
viewport: JSON.stringify(body.viewport ?? {}),
elements: JSON.stringify(body.elements ?? []),
captured_at: capturedAt,
});
return corsJson({ ok: true, capturedAt, bytes: body.html.length });
} catch (err: any) {
return corsJson({ error: err?.message ?? 'Failed to save capture' }, { status: 500 });
}
}
/** Read by the AutoBuyer page. Pass `?since=<capturedAt>` to skip re-sending
* an unchanged capture — the HTML blob is megabytes and the page polls often. */
export async function GET(req: NextRequest) {
const row = getLatestCapture();
if (!row) return corsJson({ capture: null });
const since = Number(req.nextUrl.searchParams.get('since'));
if (Number.isFinite(since) && since > 0 && since >= row.captured_at) {
return corsJson({ unchanged: true });
}
return corsJson({
capture: {
url: row.url,
title: row.title,
html: row.html,
viewport: safeParse(row.viewport, {}),
elements: safeParse(row.elements, []),
capturedAt: row.captured_at,
},
});
}
export async function DELETE() {
clearCaptures();
return corsJson({ ok: true });
}
export async function OPTIONS() {
return corsPreflight();
}
function safeParse<T>(json: string, fallback: T): T {
try { return JSON.parse(json) as T; } catch { return fallback; }
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
/** The Chromium extension calls these routes from a background service worker,
* whose Origin is `chrome-extension://<id>`. The id changes every time the
* unpacked extension is reloaded, so we allow any origin — these routes are
* only ever reachable on the LAN behind the port-3000 firewall rule. */
export const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
} as const;
export function corsJson(body: unknown, init?: { status?: number }) {
return NextResponse.json(body, { status: init?.status ?? 200, headers: CORS_HEADERS });
}
export function corsPreflight() {
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
}
+21
View File
@@ -0,0 +1,21 @@
import { claimLocateRequest } from '@/lib/db';
import { corsJson, corsPreflight } from '../../cors';
/** The extension takes the oldest pending request. Claiming marks it in-flight so
* the next poll doesn't run the same click a second time. */
export async function POST() {
const row = claimLocateRequest();
if (!row) return corsJson({ request: null });
return corsJson({
request: {
id: row.id,
selector: row.selector,
index: row.match_index,
urlPattern: row.url_pattern,
},
});
}
export async function OPTIONS() {
return corsPreflight();
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest } from 'next/server';
import { resolveLocateRequest, getLocateRequest } from '@/lib/db';
import { corsJson, corsPreflight } from '../../cors';
/** The extension reports back where the element landed, or why it couldn't. */
export async function POST(req: NextRequest) {
try {
const body = await req.json() as { id?: unknown; result?: unknown; error?: unknown };
const id = Number(body.id);
if (!Number.isInteger(id) || id <= 0) {
return corsJson({ error: '`id` is required' }, { status: 400 });
}
if (!getLocateRequest(id)) {
return corsJson({ error: 'No such request' }, { status: 404 });
}
const error = typeof body.error === 'string' && body.error ? body.error : null;
resolveLocateRequest(id, error ? null : (body.result ?? null), error);
return corsJson({ ok: true });
} catch (err: any) {
return corsJson({ error: err?.message ?? 'Failed to record result' }, { status: 500 });
}
}
export async function OPTIONS() {
return corsPreflight();
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest } from 'next/server';
import { createLocateRequest, getLocateRequest } from '@/lib/db';
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 };
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 row = createLocateRequest(body.selector.trim(), index, urlPattern);
return corsJson({ id: row.id, status: row.status });
} catch (err: any) {
return corsJson({ error: err?.message ?? 'Failed to queue request' }, { status: 500 });
}
}
/** Python polls `?id=` until the extension resolves it. */
export async function GET(req: NextRequest) {
const id = Number(req.nextUrl.searchParams.get('id'));
if (!Number.isInteger(id) || id <= 0) {
return corsJson({ error: '`id` is required' }, { status: 400 });
}
const row = getLocateRequest(id);
if (!row) return corsJson({ error: 'No such request' }, { status: 404 });
return corsJson({
id: row.id,
selector: row.selector,
status: row.status,
result: row.result ? JSON.parse(row.result) : null,
error: row.error,
});
}
export async function OPTIONS() {
return corsPreflight();
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest } from 'next/server';
import { getSetting, setSetting, countPendingLocate } from '@/lib/db';
import { corsJson, corsPreflight } from '../cors';
/** Polled by the Chromium extension every few seconds, and by the AutoBuyer page.
* `pendingLocate` rides along so the extension learns about queued clicks without
* a second request on every tick. */
export async function GET() {
return corsJson({
enabled: getSetting('autobuyer_enabled') === '1',
pendingLocate: countPendingLocate(),
});
}
/** Flipped by the ON/OFF button on the AutoBuyer page. */
export async function PATCH(req: NextRequest) {
try {
const body = await req.json() as { enabled?: unknown };
if (typeof body.enabled !== 'boolean') {
return corsJson({ error: '`enabled` must be a boolean' }, { status: 400 });
}
setSetting('autobuyer_enabled', body.enabled ? '1' : '0');
return corsJson({ enabled: body.enabled });
} catch (err: any) {
return corsJson({ error: err?.message ?? 'Failed to set status' }, { status: 500 });
}
}
export async function OPTIONS() {
return corsPreflight();
}
+271 -2
View File
@@ -1,11 +1,280 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
interface ElementPosition {
index: number;
tag: string;
id: string | null;
text: string;
visible: boolean;
inViewport: boolean;
viewport: { x: number; y: number; width: number; height: number };
page: { x: number; y: number };
screen: { x: number; y: number };
}
interface ViewportMetrics {
scrollX: number;
scrollY: number;
innerWidth: number;
innerHeight: number;
screenX: number;
screenY: number;
chromeHeight: number;
devicePixelRatio: number;
}
interface Capture {
url: string;
title: string;
html: string;
viewport: Partial<ViewportMetrics>;
elements: ElementPosition[];
capturedAt: number;
}
const POLL_MS = 2000;
/** Point relative URLs (stylesheets, images, fonts) at the origin the snapshot came
* from, otherwise the render comes out unstyled. The HTML parser hoists a leading
* <base> into <head>, so prepending works even if the markup has no explicit head. */
function withBaseTag(html: string, url: string): string {
const base = `<base href="${url.replace(/"/g, '&quot;')}">`;
const head = html.match(/<head\b[^>]*>/i);
if (!head || head.index === undefined) return base + html;
const at = head.index + head[0].length;
return html.slice(0, at) + base + html.slice(at);
}
export default function AutoBuyer() {
const [enabled, setEnabled] = useState(false);
const [capture, setCapture] = useState<Capture | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<'rendered' | 'source'>('source');
// The extension posts a new capture every few seconds, and re-feeding srcDoc would
// 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);
// Tracks the newest capture we already hold, so the poll can skip re-downloading it.
const lastAtRef = useRef(0);
const poll = useCallback(async () => {
try {
const s = await fetch('/api/autobuyer/status', { cache: 'no-store' }).then((r) => r.json());
setEnabled(!!s.enabled);
if (!s.enabled) return;
const url = lastAtRef.current
? `/api/autobuyer/capture?since=${lastAtRef.current}`
: '/api/autobuyer/capture';
const data = await fetch(url, { cache: 'no-store' }).then((r) => r.json());
if (data.unchanged || !data.capture) return;
lastAtRef.current = data.capture.capturedAt;
setCapture(data.capture);
setError(null);
} catch (err: any) {
setError(err?.message ?? 'Poll failed');
}
}, []);
useEffect(() => {
poll();
const id = setInterval(poll, POLL_MS);
return () => clearInterval(id);
}, [poll]);
async function toggle() {
setBusy(true);
try {
const res = await fetch('/api/autobuyer/status', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !enabled }),
});
const data = await res.json();
setEnabled(!!data.enabled);
setError(null);
} catch (err: any) {
setError(err?.message ?? 'Failed to toggle');
} finally {
setBusy(false);
}
}
function showRendered() {
setView('rendered');
if (capture) setPinned(capture);
}
async function clearCapture() {
await fetch('/api/autobuyer/capture', { method: 'DELETE' });
lastAtRef.current = 0;
setCapture(null);
setPinned(null);
}
const stale = capture ? Date.now() - capture.capturedAt > 15000 : false;
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-slate-900 mb-6">AutoBuyer</h1>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
Coming soon
{/* ── Capture switch ── */}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-6">
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-800">Page capture</p>
<p className="text-xs text-slate-400 mt-0.5">
While on, the AutoFirmer Capture extension posts the target tab&apos;s HTML here every few seconds
</p>
</div>
<button
onClick={toggle}
disabled={busy}
className={`text-sm px-4 py-1.5 rounded-lg font-medium transition-colors disabled:opacity-50 ${
enabled
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-slate-200 hover:bg-slate-300 text-slate-700'
}`}
>
{busy ? '…' : enabled ? 'ON' : 'OFF'}
</button>
</div>
</div>
{error && (
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
{error}
</div>
)}
{/* ── Latest capture ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Latest capture
</h2>
{!enabled ? (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
Capture is off
</div>
) : !capture ? (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
Waiting for the extension open a page in Chrome with the extension installed
</div>
) : (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 px-4 py-3 border-b border-slate-100">
<span className="text-sm font-medium text-slate-800">{capture.title || '(untitled)'}</span>
<span className="text-xs font-mono text-slate-400 break-all">{capture.url}</span>
<span className="ml-auto flex items-center gap-3 text-xs text-slate-400">
<span>{(capture.html.length / 1024).toFixed(1)} KB</span>
<span className={stale ? 'text-amber-600' : ''}>
{new Date(capture.capturedAt).toLocaleTimeString()}
{stale && ' (stale)'}
</span>
<span className="flex rounded-md border border-slate-200 overflow-hidden">
{(['rendered', 'source'] as const).map((v) => (
<button
key={v}
onClick={() => (v === 'rendered' ? showRendered() : setView('source'))}
className={`px-2 py-0.5 capitalize transition-colors ${
view === v
? 'bg-slate-700 text-white'
: 'text-slate-500 hover:bg-slate-50'
}`}
>
{v}
</button>
))}
</span>
<button
onClick={clearCapture}
className="text-slate-400 hover:text-red-500 transition-colors"
>
Clear
</button>
</span>
</div>
{capture.viewport?.innerWidth != null && (
<div className="px-4 py-2 border-b border-slate-100 flex flex-wrap gap-x-5 gap-y-1 text-xs font-mono text-slate-500">
<span>viewport {capture.viewport.innerWidth}×{capture.viewport.innerHeight}</span>
<span>scroll {Math.round(capture.viewport.scrollX ?? 0)},{Math.round(capture.viewport.scrollY ?? 0)}</span>
<span>window@screen {capture.viewport.screenX},{capture.viewport.screenY}</span>
<span>chrome {capture.viewport.chromeHeight}px</span>
<span>dpr {capture.viewport.devicePixelRatio}</span>
</div>
)}
{capture.elements?.length > 0 && (
<div className="px-4 py-3 border-b border-slate-100">
<p className="text-xs uppercase tracking-wider text-slate-400 mb-2">
Matched elements ({capture.elements.length})
</p>
<div className="overflow-x-auto">
<table className="text-xs font-mono text-slate-600">
<thead className="text-slate-400">
<tr>
<th className="text-left pr-4 font-normal">tag</th>
<th className="text-left pr-4 font-normal">text</th>
<th className="text-right pr-4 font-normal">window x,y</th>
<th className="text-right pr-4 font-normal">size</th>
<th className="text-right pr-4 font-normal">page x,y</th>
<th className="text-right pr-4 font-normal">screen x,y</th>
<th className="text-left font-normal">vis</th>
</tr>
</thead>
<tbody>
{capture.elements.map((el) => (
<tr key={el.index} className="border-t border-slate-50">
<td className="pr-4 py-1">{el.tag}{el.id ? `#${el.id}` : ''}</td>
<td className="pr-4 py-1 max-w-xs truncate">{el.text}</td>
<td className="pr-4 py-1 text-right">{Math.round(el.viewport.x)},{Math.round(el.viewport.y)}</td>
<td className="pr-4 py-1 text-right">{Math.round(el.viewport.width)}×{Math.round(el.viewport.height)}</td>
<td className="pr-4 py-1 text-right">{Math.round(el.page.x)},{Math.round(el.page.y)}</td>
<td className="pr-4 py-1 text-right">{Math.round(el.screen.x)},{Math.round(el.screen.y)}</td>
<td className="py-1">{el.inViewport ? '✓' : el.visible ? 'off-screen' : 'hidden'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{view === 'rendered' ? (
<>
{pinned && pinned.capturedAt !== capture.capturedAt && (
<div className="px-4 py-1.5 border-b border-slate-100 text-xs text-amber-600">
Frozen snapshot from {new Date(pinned.capturedAt).toLocaleTimeString()} click Rendered again to refresh
</div>
)}
{/* allow-scripts WITHOUT allow-same-origin: the frame runs the
page's own JS inside an opaque origin, so it cannot reach this
dashboard's DOM, storage, or same-origin API routes — /api/firms
serves firm credentials. Granting both flags together is what
would let a frame drop its own sandbox.
Scripts are needed because most sites ship content at opacity:0
and fade it in with JS — blocked, they render blank. */}
<iframe
sandbox="allow-scripts"
srcDoc={withBaseTag((pinned ?? capture).html, (pinned ?? capture).url)}
title="Captured page"
className="block w-full h-[70vh] bg-white"
/>
</>
) : (
<pre className="max-h-[70vh] overflow-auto bg-slate-900 text-slate-200 text-xs font-mono p-4 whitespace-pre-wrap break-all">
{capture.html}
</pre>
)}
</div>
)}
</div>
</div>
);
+126
View File
@@ -0,0 +1,126 @@
# Clicker
Moves the real desktop mouse and clicks an element you name with a CSS selector.
The extension can see the DOM but can't move the mouse; this script can move the
mouse but can't see inside Chrome. They meet at the dashboard API:
```
clicker --POST /api/autobuyer/locate------> "where is button.buy?"
extension --POST /api/autobuyer/locate/claim takes the request
extension raises the Chrome window, brings
the tab to the front, scrolls the
element to centre, measures it
extension --POST /api/autobuyer/locate/result desktop x,y
clicker --GET /api/autobuyer/locate?id=---> reads the answer
clicker moves the mouse, clicks
```
## Setup
```bash
pip install -r requirements.txt
```
On **macOS** you must grant Accessibility permission to whatever runs the script
(Terminal, iTerm, VS Code) — System Settings → Privacy & Security → Accessibility.
Without it `pyautogui` moves nothing and fails silently.
## Use
The AutoBuyer switch on the dashboard is the master arm: the script refuses to run
while it's off.
```bash
# Measure only — no mouse movement. Start here.
python clicker.py locate "button.buy"
# Move the cursor to the target but don't press.
python clicker.py click "button.buy" --dry-run
# Actually click.
python clicker.py click "button.buy"
# Pin it to a specific tab, and pick the 3rd match.
python clicker.py click ".trade-btn" --index 2 --url "https://tradeify.co/*"
```
| Flag | Meaning |
|---|---|
| `--url` | Chrome match pattern for the tab. Without it, the active tab is used. |
| `--index` | Which match, when the selector hits several (default 0). |
| `--api` | Dashboard URL (default `http://localhost:3000`). |
| `--timeout` | Seconds to wait for the extension (default 20). |
| `--scale` | CSS-to-desktop pixel ratio. Auto-detected; override if clicks land off. |
| `--dry-run` | Move the cursor, don't press. |
| `--force` | Click even when something is covering the element. |
| `--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. |
## Window focus
A click on a window that isn't focused is consumed by the window manager
*activating* that window — it never reaches the control underneath. That's why an
automated click against a background Chrome appears to do nothing the first time
and work the second: the first click only brought Chrome forward.
The extension calls `chrome.windows.update({focused: true})`, but that only orders
windows **within** Chrome. If the frontmost *application* is your terminal — which
it is, since that's where you launched this — Chrome is still in the background.
So `focus.py` raises the browser application itself immediately before the press,
then confirms it actually came forward before committing to the click. If it can't
verify, it refuses rather than firing a click that would be eaten (exit code 3).
On macOS this uses `NSWorkspace` via pyobjc, which needs no Automation permission —
activating an app is not scripting it. Without pyobjc it falls back to `osascript`,
which does prompt for Automation permission the first time.
## Cursor motion
`humanize.py` moves the pointer the way a hand does rather than teleporting:
- a curved (cubic Bézier) path instead of a straight line, bowing to one side
- eased velocity — accelerate out, coast, decelerate in
- sub-pixel tremor that decays near the target, so the landing stays exact
- long throws (>260px) sometimes overshoot slightly and pull back
- a 60170ms dwell after arriving, before the press
- the button held down 55120ms rather than an instant down/up
This is about reliability as much as appearance. Plenty of web controls only arm
once they have actually been hovered — dropdowns, tooltip-gated buttons, custom
widgets — and a cursor that arrives and presses in the same tick can outrun the
page's own `mousemove` handlers. The dwell is what lets those catch up.
Note `pyautogui.PAUSE` is set to 0 on import: it otherwise sleeps 0.1s after
*every* call, which would add tens of seconds across a stepped path.
Exit codes: `0` ok, `1` error, `2` capture switch off, `3` refused to click
(covered element, or coordinates off-screen).
## Safety
- **Failsafe**: slam the cursor into a screen corner to abort mid-run.
- **Covered elements**: before reporting, the extension checks
`document.elementFromPoint()` at the target centre. If a cookie banner or modal
is on top, the click is refused rather than sent into the overlay — `--force`
overrides.
- **Off-screen check**: coordinates outside the display bounds are refused, which
catches a Chrome window on a second monitor or partly off the edge.
- The element is scrolled to the centre of the viewport before measuring, so a
target below the fold is handled rather than mis-clicked.
## Known limits
- **Latency** is bounded by the extension's poll interval (default 3s), so a click
takes a few seconds to fire. Drop the interval in the extension popup if that
matters.
- **Multi-monitor**: coordinates come from `window.screenX/screenY`, which are
relative to the primary display's origin. A Chrome window on a secondary monitor
usually still works, but verify with `locate` before trusting `click`.
- **Display scaling**: the scale factor is inferred by comparing the screen width
the OS reports against the one the browser reports. On macOS and unscaled Windows
this is 1:1. If clicks land at a consistent offset, set `--scale` explicitly.
- The measurement and the click are separate moments. If the page moves the element
in between (a re-render, a late-loading banner), the click lands where it *was*.
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Desktop mouse control for the AutoFirmer autobuyer.
The browser extension can see the DOM but can't move the mouse; this script can
move the mouse but can't see inside Chrome. They meet at the dashboard API:
clicker --POST /api/autobuyer/locate--> "where is button.buy?"
extension raises the window, scrolls the
element into view, measures it
clicker --GET /api/autobuyer/locate--> desktop x,y
clicker moves the real mouse and clicks
Usage:
python clicker.py locate "button.buy" # measure only, no clicking
python clicker.py click "button.buy" # measure, then really click
python clicker.py click ".btn" --index 2 --url "https://tradeify.co/*"
python clicker.py click "button.buy" --dry-run # move the cursor, don't press
Requires the AutoBuyer page switch to be ON — that's the master arming switch.
"""
import argparse
import json
import random
import sys
import time
import urllib.error
import urllib.request
import focus
DEFAULT_API = "http://localhost:3000"
POLL_INTERVAL = 0.25
class DashboardError(RuntimeError):
pass
class Dashboard:
"""Thin client for the autobuyer endpoints."""
def __init__(self, base: str, timeout: float = 10.0):
self.base = base.rstrip("/")
self.timeout = timeout
def _request(self, path: str, method: str = "GET", payload: dict | None = None) -> dict:
data = json.dumps(payload).encode() if payload is not None else None
headers = {"Content-Type": "application/json"} if data else {}
req = urllib.request.Request(self.base + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")
try:
message = json.loads(body).get("error", body)
except json.JSONDecodeError:
message = body
raise DashboardError(f"{method} {path} -> HTTP {exc.code}: {message}") from None
except urllib.error.URLError as exc:
raise DashboardError(f"Cannot reach {self.base}{exc.reason}") from None
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."""
queued = self._request(
"/api/autobuyer/locate",
"POST",
{"selector": selector, "index": index, "urlPattern": url_pattern},
)
request_id = queued["id"]
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
row = self._request(f"/api/autobuyer/locate?id={request_id}")
if row["status"] == "done":
return row["result"]
if row["status"] == "error":
raise DashboardError(f"Extension could not locate it: {row['error']}")
time.sleep(POLL_INTERVAL)
raise DashboardError(
f"No answer within {timeout:g}s. Is the extension installed, is Chrome "
f"running, and is the AutoBuyer switch ON?"
)
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 = [
f" matched <{found['tag']}> {found['text']!r}"
+ (f" (1 of {found['matchCount']})" if found["matchCount"] > 1 else ""),
f" page {found['url']}",
f" viewport x={found['viewport']['x']:.0f} y={found['viewport']['y']:.0f}"
f" {found['viewport']['width']:.0f}x{found['viewport']['height']:.0f}",
f" desktop x={x:.0f} y={y:.0f}" + (f" (scale {factor:g})" if factor != 1 else ""),
]
if found.get("covered"):
lines.append(f" WARNING something is on top of it: {found['coveredBy']}")
return "\n".join(lines)
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("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("--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("--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")
parser.add_argument("--robotic", action="store_true",
help="straight-line move and instant click, skipping the human motion model")
parser.add_argument("--seed", type=int, default=None,
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)")
args = parser.parse_args()
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
if args.action == "locate":
print(describe(found, 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
try:
import pyautogui
except ImportError:
print("error: pyautogui is not installed — run: pip install -r requirements.txt", 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)
if args.dry_run:
print("\ndry run — cursor moved, no click sent.")
else:
print("\nclicked.")
return 0
if __name__ == "__main__":
sys.exit(main())
+140
View File
@@ -0,0 +1,140 @@
"""Bring the browser to the front before clicking.
macOS (and Windows, to a lesser degree) treats a click on an unfocused window as
an activation gesture: the click raises the window and is swallowed there, never
reaching the control underneath. So an automated click against a background Chrome
does nothing at all the first time, then works on the second attempt — which looks
like a flaky clicker but is really the window manager doing its job.
The extension already calls chrome.windows.update({focused: true}), but that only
orders windows *within* Chrome. If the frontmost application is your terminal —
which it is, because that's where this script was launched — Chrome as a whole is
still in the background. This module raises the application itself.
"""
import subprocess
import sys
import time
# Chrome ships under several bundle ids; accept whichever is installed.
MAC_BUNDLES = (
"com.google.Chrome",
"com.google.Chrome.beta",
"com.google.Chrome.dev",
"com.google.Chrome.canary",
"com.brave.Browser",
"com.microsoft.edgemac",
)
SETTLE = 0.20 # let the window manager finish raising before measuring or clicking
class FocusResult:
def __init__(self, ok: bool, detail: str):
self.ok = ok
self.detail = detail
def __bool__(self) -> bool:
return self.ok
def _mac_workspace():
"""NSWorkspace via pyobjc. Unlike AppleScript this needs no Automation
permission — activating an app is not scripting it."""
try:
from AppKit import NSWorkspace
except ImportError:
return None
return NSWorkspace.sharedWorkspace()
def frontmost() -> str | None:
"""Bundle id (macOS) or process name of the frontmost application."""
if sys.platform == "darwin":
ws = _mac_workspace()
if ws is None:
return None
app = ws.frontmostApplication()
return app.bundleIdentifier() if app else None
return None
def _mac_activate() -> FocusResult:
ws = _mac_workspace()
if ws is None:
# pyobjc's AppKit isn't present. osascript works but may prompt for
# Automation permission the first time.
try:
subprocess.run(
["osascript", "-e", 'tell application "Google Chrome" to activate'],
check=True, capture_output=True, timeout=5,
)
return FocusResult(True, "activated via osascript")
except Exception as exc:
return FocusResult(False, f"could not activate Chrome ({exc})")
running = {a.bundleIdentifier(): a for a in ws.runningApplications()}
for bundle in MAC_BUNDLES:
app = running.get(bundle)
if app is None:
continue
# NSApplicationActivateIgnoringOtherApps — take focus even though the
# terminal currently owns it.
app.activateWithOptions_(1 << 1)
return FocusResult(True, f"activated {bundle}")
return FocusResult(False, "no Chrome-family browser is running")
def _other_activate() -> FocusResult:
"""Windows/Linux: pyautogui already depends on pygetwindow, so use it."""
try:
import pygetwindow
except ImportError:
return FocusResult(False, "pygetwindow unavailable — cannot raise the browser")
wins = [w for w in pygetwindow.getAllWindows()
if w.title and "Chrome" in w.title and w.visible]
if not wins:
return FocusResult(False, "no Chrome window found")
try:
win = wins[0]
if getattr(win, "isMinimized", False):
win.restore()
win.activate()
return FocusResult(True, f"activated window {win.title[:40]!r}")
except Exception as exc:
return FocusResult(False, f"could not activate window ({exc})")
def activate_browser() -> FocusResult:
"""Raise the browser application above everything else."""
result = _mac_activate() if sys.platform == "darwin" else _other_activate()
if result.ok:
time.sleep(SETTLE)
return result
def ensure_frontmost(timeout: float = 1.5) -> FocusResult:
"""Raise the browser and, where we can check, confirm it actually came forward.
Returning ok=False does not mean the click will fail — only that we could not
verify. The caller decides whether to proceed.
"""
result = activate_browser()
if not result.ok:
return result
if sys.platform != "darwin":
return result # no cheap way to verify; activation call succeeded
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
front = frontmost()
if front is None:
return FocusResult(True, result.detail + " (unverified)")
if front in MAC_BUNDLES:
return FocusResult(True, f"{front} is frontmost")
time.sleep(0.05)
return FocusResult(False, f"browser did not come to the front (frontmost is {frontmost()})")
+135
View File
@@ -0,0 +1,135 @@
"""Human-like cursor motion for pyautogui.
A straight-line teleport followed by an instant click is not just conspicuous — it
is unreliable. Plenty of web UIs only arm a control once it has actually been
hovered (dropdowns, custom widgets, tooltip-gated buttons), and a cursor that
arrives and presses in the same tick can beat the page's own mousemove handlers.
So the motion here does what a hand does: accelerates out, coasts, decelerates in
along a slightly curved path, occasionally overshoots and corrects, pauses a beat
before pressing, and holds the button down for a human interval.
"""
import math
import random
import time
import pyautogui
# pyautogui sleeps PAUSE seconds after *every* call. With a stepped path that
# would add tens of seconds, so we take over timing entirely.
pyautogui.PAUSE = 0
# Motion feel. Durations in seconds, distances in pixels.
MIN_DURATION = 0.15
MAX_DURATION = 1.70
CURVE_STRENGTH = 0.18 # lateral bow, as a fraction of travel distance
TREMOR = 0.7 # sub-pixel hand tremor
OVERSHOOT_ABOVE = 260.0 # only long throws overshoot
OVERSHOOT_CHANCE = 0.55
DWELL = (0.06, 0.17) # settle after arriving, before pressing
HOLD = (0.055, 0.12) # how long the button stays down
def _ease(t: float) -> float:
"""Smootherstep: zero velocity at both ends, quick through the middle."""
return t * t * t * (t * (t * 6 - 15) + 10)
def _bezier(p0, p1, p2, p3, t):
"""Cubic Bezier — the bow that keeps the path off a dead-straight line."""
u = 1 - t
return (
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
)
def _duration_for(distance: float, rng: random.Random) -> float:
"""Farther costs more, but sub-linearly — pointing time grows roughly with the
square root of distance over the range a screen covers. Calibrated so a nudge
of ~40px takes ~0.2s and a throw across a large display takes ~0.8s; a log
curve here would spend a full second creeping 40 pixels."""
base = 0.10 + 0.018 * math.sqrt(distance)
return max(MIN_DURATION, min(MAX_DURATION, base * rng.uniform(0.85, 1.2)))
def _glide(start, end, duration: float, rng: random.Random) -> None:
"""One curved, eased sweep from start to end."""
dx, dy = end[0] - start[0], end[1] - start[1]
distance = math.hypot(dx, dy)
if distance < 1:
return
# Control points pushed perpendicular to the direction of travel, so the path
# bows to one side the way an arm swings rather than tracking a ruler.
nx, ny = -dy / distance, dx / distance
bow = distance * CURVE_STRENGTH * rng.uniform(-1, 1)
c1 = (start[0] + dx * 0.3 + nx * bow, start[1] + dy * 0.3 + ny * bow)
c2 = (start[0] + dx * 0.7 + nx * bow * rng.uniform(0.4, 1.0),
start[1] + dy * 0.7 + ny * bow * rng.uniform(0.4, 1.0))
steps = max(14, min(95, int(distance / 5)))
step_time = duration / steps
next_at = time.perf_counter()
for i in range(1, steps + 1):
t = _ease(i / steps)
x, y = _bezier(start, c1, c2, end, t)
# Tremor fades out as we close in, so the landing stays accurate.
if i < steps:
decay = 1 - (i / steps)
x += rng.gauss(0, TREMOR) * decay
y += rng.gauss(0, TREMOR) * decay
pyautogui.moveTo(x, y, duration=0, _pause=False)
next_at += step_time
slack = next_at - time.perf_counter()
if slack > 0:
time.sleep(slack)
def move(x: float, y: float, rng: random.Random | None = None) -> None:
"""Move the cursor to (x, y) the way a hand would."""
rng = rng or random.Random()
start = pyautogui.position()
distance = math.hypot(x - start[0], y - start[1])
if distance < 1:
return
duration = _duration_for(distance, rng)
# A long throw usually lands slightly past the mark and gets pulled back.
if distance > OVERSHOOT_ABOVE and rng.random() < OVERSHOOT_CHANCE:
angle = math.atan2(y - start[1], x - start[0]) + rng.uniform(-0.35, 0.35)
past = rng.uniform(6, 16)
overshoot = (x + math.cos(angle) * past, y + math.sin(angle) * past)
_glide(start, overshoot, duration * 0.82, rng)
time.sleep(rng.uniform(0.02, 0.06))
_glide(pyautogui.position(), (x, y), rng.uniform(0.10, 0.19), rng)
else:
_glide(start, (x, y), duration, rng)
# Land exactly on target — accumulated float error must not cost us the click.
pyautogui.moveTo(x, y, duration=0, _pause=False)
def click(x: float, y: float, rng: random.Random | None = None, press: bool = True) -> None:
"""Move to (x, y), settle, then press and release.
With press=False the cursor travels and dwells but no button event is sent.
"""
rng = rng or random.Random()
move(x, y, rng)
# A beat between arriving and pressing: this is what lets hover handlers,
# CSS transitions and lazily-armed controls catch up before the press.
time.sleep(rng.uniform(*DWELL))
if not press:
return
pyautogui.mouseDown(_pause=False)
time.sleep(rng.uniform(*HOLD))
pyautogui.mouseUp(_pause=False)
+6
View File
@@ -0,0 +1,6 @@
pyautogui>=0.9.54
# macOS backends: Quartz drives the mouse, Cocoa (AppKit) raises the browser
# window without needing AppleScript automation permission.
pyobjc-core>=10.0; sys_platform == "darwin"
pyobjc-framework-Quartz>=10.0; sys_platform == "darwin"
pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin"
+53
View File
@@ -0,0 +1,53 @@
# AutoFirmer Capture (Chromium extension)
Polls the dashboard for an on/off flag and, while it's on, scrapes the target
tab's HTML and posts it back to the dashboard.
## Install (unpacked)
1. Start the dashboard (`npm run dev`) so `http://localhost:3000` is up.
2. Open `chrome://extensions`, enable **Developer mode** (top right).
3. **Load unpacked** → select this `extension/` folder.
4. Click the extension icon to open the popup and set:
- **Dashboard URL** — default `http://localhost:3000`. Use the VPS IP if the
dashboard runs elsewhere.
- **Poll interval** — seconds between status checks (default 3).
- **Target URL pattern** — a Chrome match pattern like
`https://*.tradovate.com/*`. Leave blank to capture whichever tab is active.
- **Element selector** — optional CSS selector; matching elements get their
on-screen position measured alongside the HTML.
The badge shows `ON` (green) while capturing, `!` (red) if the dashboard is
unreachable, and nothing when the switch is off.
## Flow
```
AutoBuyer page --PATCH /api/autobuyer/status--> SQLite settings
extension --GET /api/autobuyer/status--> { enabled }
extension --POST /api/autobuyer/capture-> html + viewport + element rects
AutoBuyer page --GET /api/autobuyer/capture-> renders the HTML
```
## Scope
`host_permissions` is deliberately narrow — `https://*.tradeify.co/*` plus localhost
for the dashboard. The extension is technically incapable of reading any other site,
so an accidental capture of your bank or mail tab can't happen. The default
**Target URL pattern** matches, so it only ever captures the broker tab regardless
of which tab is focused.
To automate a different broker, add its pattern to `host_permissions` in
`manifest.json`, update the popup's target pattern, and reload the extension. Avoid
going back to `<all_urls>` — that re-enables scraping whatever tab is active.
## Notes
- The extension never captures the dashboard's own pages — otherwise it would
just mirror its own output back.
- `chrome://`, `about:` and Web Store pages cannot be scripted by any extension;
they're skipped.
- MV3 service workers are torn down when idle. Each poll makes an extension API
call, which keeps the worker alive; a 30-second alarm revives it if Chrome
kills it anyway. So worst-case cadence is 30s, normal cadence is the poll
interval.
+343
View File
@@ -0,0 +1,343 @@
/* AutoFirmer Capture — MV3 service worker.
*
* Loop: poll GET /api/autobuyer/status. While `enabled` is true, scrape the
* target tab's HTML and POST it to /api/autobuyer/capture. The dashboard's
* AutoBuyer page renders whatever landed there.
*/
const DEFAULTS = {
apiBase: 'http://localhost:3000',
pollSeconds: 3,
// Chrome match pattern, e.g. "https://*.tradovate.com/*". Empty = whichever
// tab is active in the last focused window — which would mean scraping your
// banking or email tab if you switched to one. Default it to the broker.
targetUrlPattern: 'https://*.tradeify.co/*',
// Optional CSS selector — matching elements get their on-screen position
// measured alongside the HTML.
elementSelector: '',
};
let timer = null;
let pendingLocate = 0;
// ── Config / state ──────────────────────────────────────────────────────────
async function getConfig() {
const stored = await chrome.storage.local.get(Object.keys(DEFAULTS));
const cfg = { ...DEFAULTS, ...stored };
cfg.apiBase = String(cfg.apiBase).replace(/\/+$/, '');
return cfg;
}
/** Mirror the worker's status into storage so the popup can render it. */
async function setState(patch) {
const { state: prev } = await chrome.storage.local.get('state');
await chrome.storage.local.set({ state: { ...(prev || {}), ...patch, updatedAt: Date.now() } });
}
function setBadge(connected, enabled) {
chrome.action.setBadgeText({ text: !connected ? '!' : enabled ? 'ON' : '' });
chrome.action.setBadgeBackgroundColor({ color: !connected ? '#ef4444' : '#22c55e' });
}
// ── The injected scraper ────────────────────────────────────────────────────
/** Runs in the page's own world. Must be self-contained — nothing from this
* file's scope is available inside it. */
function pageCapture(selector) {
// getBoundingClientRect() measures from the top-left of the viewport, so
// rect.left / rect.top ARE the element's position relative to the window.
// + scrollX/scrollY -> position within the document
// + screenX/screenY and the chrome height -> absolute desktop coordinates,
// which is what an OS-level clicker needs.
const chromeHeight = window.outerHeight - window.innerHeight;
const elements = selector
? Array.from(document.querySelectorAll(selector)).slice(0, 200).map((el, index) => {
const r = el.getBoundingClientRect();
return {
index,
tag: el.tagName.toLowerCase(),
id: el.id || null,
text: (el.textContent || '').trim().slice(0, 80),
// Off-screen or display:none elements still return a rect — all zeroes.
visible: r.width > 0 && r.height > 0,
inViewport: r.top < window.innerHeight && r.bottom > 0 && r.left < window.innerWidth && r.right > 0,
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
page: { x: r.left + window.scrollX, y: r.top + window.scrollY },
// Centre of the element in desktop coordinates (CSS pixels —
// multiply by devicePixelRatio for physical pixels on HiDPI).
screen: {
x: window.screenX + r.left + r.width / 2,
y: window.screenY + chromeHeight + r.top + r.height / 2,
},
};
})
: [];
return {
url: location.href,
title: document.title,
html: document.documentElement.outerHTML,
viewport: {
scrollX: window.scrollX,
scrollY: window.scrollY,
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
outerWidth: window.outerWidth,
outerHeight: window.outerHeight,
screenX: window.screenX,
screenY: window.screenY,
chromeHeight,
devicePixelRatio: window.devicePixelRatio,
},
elements,
};
}
// ── Capture pipeline ────────────────────────────────────────────────────────
async function pickTab(cfg) {
if (cfg.targetUrlPattern) {
try {
const tabs = await chrome.tabs.query({ url: cfg.targetUrlPattern });
return tabs.find((t) => /^https?:/.test(t.url || ''));
} catch {
throw new Error(`Invalid target URL pattern: ${cfg.targetUrlPattern}`);
}
}
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
// chrome://, about:, the Web Store and PDF viewers can't be scripted.
if (!tab || !/^https?:/.test(tab.url || '')) return undefined;
// Skip the dashboard itself, otherwise it just captures its own output.
if (tab.url.startsWith(cfg.apiBase)) return undefined;
return tab;
}
async function captureAndSend(cfg) {
const tab = await pickTab(cfg);
if (!tab) {
await setState({ connected: true, enabled: true, error: null, waiting: 'No eligible tab to capture' });
return;
}
const [injection] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: pageCapture,
args: [cfg.elementSelector || ''],
});
const result = injection?.result;
if (!result) throw new Error('Injection returned nothing');
const res = await fetch(`${cfg.apiBase}/api/autobuyer/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...result,
// Stamp the build so the dashboard side can tell which version of this
// worker is actually live — Chrome keeps running the old one until the
// extension is reloaded, which is invisible from the server otherwise.
viewport: { ...result.viewport, extVersion: chrome.runtime.getManifest().version },
capturedAt: Date.now(),
}),
});
if (!res.ok) throw new Error(`Capture POST failed: HTTP ${res.status}`);
await setState({
connected: true,
enabled: true,
error: null,
waiting: null,
lastCapture: { url: result.url, title: result.title, bytes: result.html.length, at: Date.now() },
});
}
// ── Locate: turn a CSS selector into desktop coordinates ────────────────────
/** Runs in the page. Scrolls the element into view, then reports where it ended up. */
function pageLocate(selector, index) {
const matches = document.querySelectorAll(selector);
if (matches.length === 0) return { error: `No element matches ${selector}` };
const el = matches[index];
if (!el) return { error: `Selector ${selector} matched ${matches.length} element(s), no index ${index}` };
// Centre it in the viewport first — an element scrolled off-screen has a rect,
// but clicking those coordinates would hit whatever is actually displayed there.
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) {
return { error: `Element ${selector} has zero size (hidden or display:none)` };
}
if (r.bottom <= 0 || r.top >= window.innerHeight || r.right <= 0 || r.left >= window.innerWidth) {
return { error: `Element ${selector} is outside the viewport even after scrolling` };
}
const chromeHeight = window.outerHeight - window.innerHeight;
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
// What sits at that point? If it's not our element (or a descendant), something
// is covering it — a cookie banner, a modal — and the click would hit that instead.
const atPoint = document.elementFromPoint(cx, cy);
const covered = atPoint && atPoint !== el && !el.contains(atPoint);
return {
tag: el.tagName.toLowerCase(),
text: (el.textContent || '').trim().slice(0, 80),
matchCount: matches.length,
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 },
// Desktop coordinates of the element's centre, in CSS pixels.
screen: { x: window.screenX + cx, y: window.screenY + chromeHeight + cy },
// Lets the Python side work out whether the OS uses a different pixel
// scale than CSS does (Windows display scaling, some Linux setups).
screenSize: { width: window.screen.width, height: window.screen.height },
devicePixelRatio: window.devicePixelRatio,
chromeHeight,
url: location.href,
};
}
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;
}
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;
}
/** Returns true if a request was handled, false if the queue was empty. */
async function serveLocateRequest(cfg) {
const claim = await fetch(`${cfg.apiBase}/api/autobuyer/locate/claim`, { method: 'POST' });
if (!claim.ok) throw new Error(`Claim failed: HTTP ${claim.status}`);
const { request } = await claim.json();
if (!request) return false;
let result = null;
let error = null;
try {
const tab = await resolveLocateTab(cfg, request);
// 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.
// Only un-minimise — passing state:'normal' unconditionally would drop a
// maximised or fullscreen window out of that state on every single click.
const win = await chrome.windows.get(tab.windowId);
const focus = win.state === 'minimized'
? { focused: true, state: 'normal' }
: { focused: true };
await chrome.windows.update(tab.windowId, focus);
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 };
} catch (err) {
error = err.message;
}
await fetch(`${cfg.apiBase}/api/autobuyer/locate/result`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: request.id, result, error }),
});
return true;
}
async function tick() {
const cfg = await getConfig();
let enabled;
try {
const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const status = await res.json();
enabled = !!status.enabled;
pendingLocate = Number(status.pendingLocate) || 0;
} catch (err) {
setBadge(false, false);
await setState({ connected: false, enabled: false, error: `Cannot reach ${cfg.apiBase}${err.message}` });
return;
}
setBadge(true, enabled);
if (!enabled) {
await setState({ connected: true, enabled: false, error: null, waiting: null });
return;
}
// Serve queued clicks before capturing — the Python side is waiting on these,
// and a capture of a megabyte page shouldn't sit in front of them.
// Failures inside a request are reported back to the caller; a failure of the
// queue plumbing itself lands here, and must not take the capture loop down.
try {
for (let i = 0; i < pendingLocate; i++) {
if (!(await serveLocateRequest(cfg))) break;
}
} catch (err) {
await setState({ connected: true, enabled: true, error: `Locate queue: ${err.message}` });
}
try {
await captureAndSend(cfg);
} catch (err) {
await setState({ connected: true, enabled: true, error: err.message });
}
}
// ── Scheduling ──────────────────────────────────────────────────────────────
async function restart() {
if (timer) clearInterval(timer);
const cfg = await getConfig();
// Re-check after the await: restart() is called from several places at startup,
// and without this a concurrent call leaks an untracked interval — two polling
// loops in one worker, capturing everything twice.
if (timer) clearInterval(timer);
const ms = Math.max(1, Number(cfg.pollSeconds) || DEFAULTS.pollSeconds) * 1000;
timer = setInterval(tick, ms);
tick();
}
chrome.runtime.onInstalled.addListener(restart);
chrome.runtime.onStartup.addListener(restart);
// The setInterval above only survives while the worker is alive. Each tick makes
// an extension API call, which resets the idle timer, so in practice the loop
// keeps itself running — this alarm is the safety net that revives it if Chrome
// tears the worker down anyway. 30s is the shortest period Chrome honours.
chrome.alarms.create('keepalive', { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener(() => {
if (timer) tick(); else restart();
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return;
if (Object.keys(DEFAULTS).some((k) => k in changes)) restart();
});
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type !== 'capture-now') return;
getConfig()
.then(captureAndSend)
.then(() => sendResponse({ ok: true }))
.catch((err) => sendResponse({ ok: false, error: err.message }));
return true; // keep the message channel open for the async response
});
restart();
+19
View File
@@ -0,0 +1,19 @@
{
"manifest_version": 3,
"name": "AutoFirmer Capture",
"version": "0.2.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": [
"https://*.tradeify.co/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "AutoFirmer Capture"
}
}
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
body { width: 300px; margin: 0; padding: 14px; font: 13px/1.4 system-ui, sans-serif; color: #1e293b; }
h1 { font-size: 14px; margin: 0 0 10px; }
label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #64748b; margin: 10px 0 3px; }
input { width: 100%; box-sizing: border-box; padding: 5px 7px; font: 12px ui-monospace, monospace;
border: 1px solid #e2e8f0; border-radius: 6px; background: #f8fafc; }
button { margin-top: 12px; padding: 6px 10px; font-size: 12px; font-weight: 500; border: 0;
border-radius: 6px; background: #3b82f6; color: #fff; cursor: pointer; }
button.ghost { background: #e2e8f0; color: #334155; margin-left: 6px; }
#status { margin-bottom: 10px; padding: 8px; border-radius: 6px; background: #f1f5f9; font-size: 12px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.muted { color: #64748b; font-size: 11px; }
.err { color: #dc2626; font-size: 11px; margin-top: 4px; word-break: break-word; }
</style>
</head>
<body>
<h1>AutoFirmer Capture</h1>
<div id="status"></div>
<label for="apiBase">Dashboard URL</label>
<input id="apiBase" placeholder="http://localhost:3000" />
<label for="pollSeconds">Poll interval (seconds)</label>
<input id="pollSeconds" type="number" min="1" max="120" />
<label for="targetUrlPattern">Target URL pattern (blank = active tab)</label>
<input id="targetUrlPattern" placeholder="https://*.tradeify.co/*" />
<label for="elementSelector">Element selector (optional)</label>
<input id="elementSelector" placeholder="button.buy" />
<button id="save">Save</button>
<button id="now" class="ghost">Capture now</button>
<script src="popup.js"></script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
const FIELDS = ['apiBase', 'pollSeconds', 'targetUrlPattern', 'elementSelector'];
const DEFAULTS = {
apiBase: 'http://localhost:3000',
pollSeconds: 3,
targetUrlPattern: 'https://*.tradeify.co/*',
elementSelector: '',
};
async function load() {
const cfg = { ...DEFAULTS, ...(await chrome.storage.local.get(FIELDS)) };
for (const f of FIELDS) document.getElementById(f).value = cfg[f];
render();
}
async function render() {
const { state = {} } = await chrome.storage.local.get('state');
const color = !state.connected ? '#ef4444' : state.enabled ? '#22c55e' : '#94a3b8';
const label = !state.connected ? 'Disconnected' : state.enabled ? 'ON — capturing' : 'Connected — off';
const cap = state.lastCapture;
const detail = state.waiting
? `<div class="muted">${esc(state.waiting)}</div>`
: cap
? `<div class="muted">${esc(cap.title || cap.url)}<br>${(cap.bytes / 1024).toFixed(1)} KB · ${new Date(cap.at).toLocaleTimeString()}</div>`
: '';
document.getElementById('status').innerHTML =
`<span class="dot" style="background:${color}"></span><strong>${label}</strong>` +
detail +
(state.error ? `<div class="err">${esc(state.error)}</div>` : '');
}
document.getElementById('save').addEventListener('click', async () => {
const patch = {};
for (const f of FIELDS) patch[f] = document.getElementById(f).value.trim();
patch.pollSeconds = Number(patch.pollSeconds) || DEFAULTS.pollSeconds;
patch.apiBase = patch.apiBase || DEFAULTS.apiBase;
await chrome.storage.local.set(patch);
setTimeout(render, 400);
});
document.getElementById('now').addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'capture-now' }, () => setTimeout(render, 200));
});
chrome.storage.onChanged.addListener((c, area) => { if (area === 'local' && 'state' in c) render(); });
function esc(s) {
return String(s).replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[ch]));
}
load();
+119
View File
@@ -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);
}