diff --git a/.gitignore b/.gitignore index 943b8b6..2fd0289 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ scripts/lucid-cookies.json scripts/lucid-config.json scripts/*.png autotrader.sqlite + +# python +__pycache__/ +*.pyc diff --git a/app/api/autobuyer/capture/route.ts b/app/api/autobuyer/capture/route.ts new file mode 100644 index 0000000..d8cd763 --- /dev/null +++ b/app/api/autobuyer/capture/route.ts @@ -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=` 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(json: string, fallback: T): T { + try { return JSON.parse(json) as T; } catch { return fallback; } +} diff --git a/app/api/autobuyer/cors.ts b/app/api/autobuyer/cors.ts new file mode 100644 index 0000000..e2af0c8 --- /dev/null +++ b/app/api/autobuyer/cors.ts @@ -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://`. 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 }); +} diff --git a/app/api/autobuyer/locate/claim/route.ts b/app/api/autobuyer/locate/claim/route.ts new file mode 100644 index 0000000..6646a1a --- /dev/null +++ b/app/api/autobuyer/locate/claim/route.ts @@ -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(); +} diff --git a/app/api/autobuyer/locate/result/route.ts b/app/api/autobuyer/locate/result/route.ts new file mode 100644 index 0000000..a573830 --- /dev/null +++ b/app/api/autobuyer/locate/result/route.ts @@ -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(); +} diff --git a/app/api/autobuyer/locate/route.ts b/app/api/autobuyer/locate/route.ts new file mode 100644 index 0000000..f6426bb --- /dev/null +++ b/app/api/autobuyer/locate/route.ts @@ -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(); +} diff --git a/app/api/autobuyer/status/route.ts b/app/api/autobuyer/status/route.ts new file mode 100644 index 0000000..83fa367 --- /dev/null +++ b/app/api/autobuyer/status/route.ts @@ -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(); +} diff --git a/app/autobuyer/page.tsx b/app/autobuyer/page.tsx index 5124938..f8756ee 100644 --- a/app/autobuyer/page.tsx +++ b/app/autobuyer/page.tsx @@ -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; + 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 + * into , so prepending works even if the markup has no explicit head. */ +function withBaseTag(html: string, url: string): string { + const base = ``; + const head = html.match(/]*>/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(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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(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 (

AutoBuyer

-
- Coming soon + + {/* ── Capture switch ── */} +
+
+
+

Page capture

+

+ While on, the AutoFirmer Capture extension posts the target tab's HTML here every few seconds +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* ── Latest capture ── */} +

+ Latest capture +

+ + {!enabled ? ( +
+ Capture is off +
+ ) : !capture ? ( +
+ Waiting for the extension… open a page in Chrome with the extension installed +
+ ) : ( +
+
+ {capture.title || '(untitled)'} + {capture.url} + + {(capture.html.length / 1024).toFixed(1)} KB + + {new Date(capture.capturedAt).toLocaleTimeString()} + {stale && ' (stale)'} + + + {(['rendered', 'source'] as const).map((v) => ( + + ))} + + + +
+ + {capture.viewport?.innerWidth != null && ( +
+ viewport {capture.viewport.innerWidth}×{capture.viewport.innerHeight} + scroll {Math.round(capture.viewport.scrollX ?? 0)},{Math.round(capture.viewport.scrollY ?? 0)} + window@screen {capture.viewport.screenX},{capture.viewport.screenY} + chrome {capture.viewport.chromeHeight}px + dpr {capture.viewport.devicePixelRatio} +
+ )} + + {capture.elements?.length > 0 && ( +
+

+ Matched elements ({capture.elements.length}) +

+
+ + + + + + + + + + + + + + {capture.elements.map((el) => ( + + + + + + + + + + ))} + +
tagtextwindow x,ysizepage x,yscreen x,yvis
{el.tag}{el.id ? `#${el.id}` : ''}{el.text}{Math.round(el.viewport.x)},{Math.round(el.viewport.y)}{Math.round(el.viewport.width)}×{Math.round(el.viewport.height)}{Math.round(el.page.x)},{Math.round(el.page.y)}{Math.round(el.screen.x)},{Math.round(el.screen.y)}{el.inViewport ? '✓' : el.visible ? 'off-screen' : 'hidden'}
+
+
+ )} + + {view === 'rendered' ? ( + <> + {pinned && pinned.capturedAt !== capture.capturedAt && ( +
+ Frozen snapshot from {new Date(pinned.capturedAt).toLocaleTimeString()} — click Rendered again to refresh +
+ )} + {/* 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. */} +