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
@@ -49,3 +49,7 @@ scripts/lucid-cookies.json
|
|||||||
scripts/lucid-config.json
|
scripts/lucid-config.json
|
||||||
scripts/*.png
|
scripts/*.png
|
||||||
autotrader.sqlite
|
autotrader.sqlite
|
||||||
|
|
||||||
|
# python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -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, '"')}">`;
|
||||||
|
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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 p-8">
|
<div className="min-h-screen bg-slate-50 p-8">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-slate-900 mb-6">AutoBuyer</h1>
|
<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'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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 60–170ms dwell after arriving, before the press
|
||||||
|
- the button held down 55–120ms 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*.
|
||||||
@@ -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())
|
||||||
@@ -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()})")
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -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();
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch]));
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
@@ -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);
|
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