← back to Govarbitrage
auto-save: 2026-07-09T22:42:31 (5 files) — README.md extension/ src/app/selling-avenues/ src/importers/ src/lib/selling-avenues.ts
19346849eb140a79c77b6c5a0c2e4c2b2f36f1c3 · 2026-07-09 22:42:36 -0700 · Steve Abrams
Files touched
A README.mdA extension/content.jsA extension/manifest.jsonA extension/popup.htmlA src/app/selling-avenues/page.tsxA src/importers/csv.tsA src/importers/ingest.tsA src/lib/selling-avenues.ts
Diff
commit 19346849eb140a79c77b6c5a0c2e4c2b2f36f1c3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 9 22:42:36 2026 -0700
auto-save: 2026-07-09T22:42:31 (5 files) — README.md extension/ src/app/selling-avenues/ src/importers/ src/lib/selling-avenues.ts
---
README.md | 199 ++++++++++++++++++
extension/content.js | 422 +++++++++++++++++++++++++++++++++++++++
extension/manifest.json | 19 ++
extension/popup.html | 198 ++++++++++++++++++
src/app/selling-avenues/page.tsx | 98 +++++++++
src/importers/csv.ts | 71 +++++++
src/importers/ingest.ts | 144 +++++++++++++
src/lib/selling-avenues.ts | 116 +++++++++++
8 files changed, 1267 insertions(+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a63bc07
--- /dev/null
+++ b/README.md
@@ -0,0 +1,199 @@
+# GovArbitrage
+
+**GovArbitrage** discovers government-surplus auction listings, researches and
+values them, scores them for resale arbitrage, and surfaces the best
+opportunities on a dense analyst dashboard.
+
+It answers one question for every lot: *"If I win this at the current bid, what
+does it cost me all-in, what can I resell it for, how fast, and how much do I
+actually make?"* — then ranks every listing across nine named opportunity
+profiles so you can sort by whatever kind of flip you're hunting.
+
+The heavy lifting is done by **deterministic, explainable engines** (valuation,
+costs, scoring, freight, demand). AI is used only for the fuzzy step —
+identifying what a product actually *is* from a messy auction title — and it runs
+**local-first on Ollama at $0/query**, with an opt-in Anthropic fallback.
+
+---
+
+## Features
+
+- **Listing intake** from multiple government auction sources (GovDeals, Public
+ Surplus, GSA Auctions, county/state/university surplus, Municibid, Bid4Assets,
+ CSV import, browser-extension capture).
+- **AI product identification** — infers manufacturer, model, category, and a
+ new-retail anchor from the listing title/description (local Ollama text +
+ vision models; Anthropic opt-in fallback; deterministic heuristic floor).
+- **Full valuation ladder** — from a single retail anchor + condition + demand,
+ derives new/replacement/average retail, a used-market spread, wholesale /
+ liquidation / sell-today channel values, 7- / 30- / 90-day time-horizon values,
+ expected sale price, probability of sale, days-until-sold, and a confidence
+ score.
+- **20+ line-item cost breakdown** — buyer's premium, sales tax, shipping,
+ freight, insurance, packing, pickup labor, testing, repairs, certification,
+ marketplace + payment fees, storage, photography, listing labor — rolled up
+ into total investment, expected net profit, ROI, and annualized return.
+- **Recommended max bid** — back-solved from a target ROI given all
+ bid-independent costs and resale fees, so you know the ceiling before you bid.
+- **Nine scoring profiles** — Overall Opportunity, Best Arbitrage, Quick Flip,
+ Collector, Local Pickup, Easy Freight, Parts Only, High Confidence, High
+ Profit — each built from seven component sub-scores, each with a plain-English
+ explanation of *why* it scored the way it did, plus risk and drop-ship
+ feasibility ratings.
+- **Dense TanStack dashboard** — ~40 sortable/hideable columns, per-profile
+ re-ranking, server-side search + filtering (source / condition / risk /
+ closing-soon), CSV export, and a live countdown to auction close.
+- **Listing detail view** — valuation ladder, full cost breakdown, per-profile
+ score explanations, comparables, auction terms, and event history.
+- **Compliant buyer workflow** — contingent-offer interest pages and leads that
+ never represent ownership before the auction is won (see
+ [Compliance](#compliance)).
+- **Audit trail** — every listing carries a typed event log; API credentials are
+ stored AES-256-GCM encrypted at rest; an `AuditLog` records privileged actions.
+
+---
+
+## Tech stack
+
+| Layer | Technology |
+| ---------------- | ----------------------------------------------------------------- |
+| Framework | **Next.js 16** (App Router, server components, Turbopack) |
+| Language | **TypeScript** 5.7 |
+| UI runtime | **React 19** |
+| ORM | **Prisma 6** (`@prisma/client` 6.19) |
+| Database | **PostgreSQL 14** |
+| Styling | **Tailwind CSS v4** (`@tailwindcss/postcss`) |
+| Components | shadcn/ui-style primitives (`class-variance-authority`, `clsx`, `tailwind-merge`, `lucide-react`) |
+| Data grid | **TanStack Table** (`@tanstack/react-table` 8) |
+| Validation | **Zod** |
+| CSV | `csv-parse` |
+| Testing | **Vitest** (engine unit tests) + **Playwright** (e2e) |
+| Background jobs | **BullMQ + Redis** (optional; direct/in-process mode without it) |
+| Local AI | **Ollama** (text + vision models) |
+| Cloud AI (opt-in)| Anthropic Claude (fallback only) |
+
+---
+
+## Prerequisites
+
+- **Node.js 20+**
+- **PostgreSQL** running locally (a `govarbitrage` database — Postgres 14+)
+- **Ollama** running locally for AI identification (optional; the app falls back
+ to a deterministic heuristic when no model is reachable). Pull the configured
+ models, e.g. `ollama pull qwen3:14b` and `ollama pull qwen2.5vl:7b`.
+- **Redis** (optional) only if you want BullMQ-backed background research.
+
+---
+
+## Setup
+
+```bash
+# 1. Install dependencies
+npm install
+
+# 2. Create the database
+createdb govarbitrage
+
+# 3. Configure environment
+cp .env.example .env
+# Edit .env if your Postgres user / connection differs.
+
+# 4. Create the schema (no migration history needed for local dev)
+npx prisma db push
+
+# 5. Seed realistic sample listings + full research/valuation/scoring
+npm run db:seed
+
+# 6. Run the dev server
+npm run dev
+```
+
+The app runs on **http://localhost:3000** by default.
+
+---
+
+## npm scripts
+
+| Script | Command | What it does |
+| ------------------- | ------------------------------ | ----------------------------------------------------- |
+| `npm run dev` | `next dev` | Start the dev server (http://localhost:3000) |
+| `npm run build` | `prisma generate && next build`| Generate the Prisma client, then production build |
+| `npm run start` | `next start` | Serve the production build |
+| `npm run lint` | `eslint .` | Lint the codebase |
+| `npm run typecheck` | `tsc --noEmit` | Type-check without emitting |
+| `npm run test` | `vitest run` | Run the engine unit tests once |
+| `npm run test:watch`| `vitest` | Run tests in watch mode |
+| `npm run db:generate` | `prisma generate` | (Re)generate the Prisma client |
+| `npm run db:push` | `prisma db push` | Push the schema to the database (no migration files) |
+| `npm run db:migrate`| `prisma migrate dev` | Create + apply a dev migration |
+| `npm run db:seed` | `tsx prisma/seed.ts` | Seed sample listings + run the research pipeline |
+| `npm run worker` | `tsx src/worker/index.ts` | Run the BullMQ research worker (optional; requires Redis) |
+| `npm run e2e` | `playwright test` | Run Playwright end-to-end tests |
+
+> The research worker (`npm run worker`) is the BullMQ-backed background runner.
+> When `REDIS_URL` is unset, research runs in direct/in-process mode instead
+> (invoked directly through `src/pipeline/research.ts`), which is fine at this
+> scale.
+
+---
+
+## Environment variables
+
+From `.env.example`:
+
+| Variable | Default (example) | Purpose |
+| --------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| `DATABASE_URL` | `postgresql://<user>@localhost:5432/govarbitrage?schema=public` | Postgres connection string. |
+| `OLLAMA_BASE_URL` | `http://localhost:11434` | Local Ollama endpoint. |
+| `OLLAMA_TEXT_MODEL` | `qwen3:14b` | Ollama model for text-based product identification. |
+| `OLLAMA_VISION_MODEL` | `qwen2.5vl:7b` | Ollama vision model (used when listing images are supplied). |
+| `ANTHROPIC_API_KEY` | *(blank)* | Cloud fallback key. Leave blank to stay 100% local / $0. |
+| `AI_PROVIDER` | `ollama` | `ollama` (default, local, free) or `anthropic` (opt-in, paid). |
+| `REDIS_URL` | *(blank)* | Optional. Set to enable BullMQ background jobs; unset = in-process. |
+| `AUTH_SECRET` | `dev-only-change-me` | Session/auth secret. Change in production. |
+| `ENCRYPTION_KEY` | 32-byte hex | AES-256-GCM key for encrypting stored API credentials at rest. |
+| `NODE_ENV` | `development` | Node environment. |
+
+---
+
+## How the AI layer works
+
+Product identification is the one genuinely fuzzy step in the pipeline, so it's
+the only place AI is used. It runs **local-first**:
+
+1. **Ollama text model** (`OLLAMA_TEXT_MODEL`, default `qwen3:14b`) — the default
+ path. Runs on this machine, `$0`/query. If listing images are supplied, the
+ **vision model** (`OLLAMA_VISION_MODEL`, default `qwen2.5vl:7b`) is tried
+ first.
+2. **Anthropic Claude** — an **opt-in** fallback, used only when
+ `AI_PROVIDER=anthropic` *and* `ANTHROPIC_API_KEY` is set, and only if the
+ local model didn't return a result.
+3. **Deterministic heuristic** — if no model is reachable at all, the pipeline
+ falls back to the keyword-driven demand/retail heuristic in
+ `src/engines/demand.ts`. This keeps the app fully functional and reproducible
+ offline; the AI estimate is also *blended* with the heuristic floor to avoid
+ wild outliers.
+
+Everything downstream of identification (valuation, costs, scoring) is
+deterministic math, so the numbers are reproducible and auditable rather than a
+black box.
+
+---
+
+## Compliance
+
+GovArbitrage's buyer workflow is deliberately constrained so you never
+misrepresent ownership of an item you have not yet won:
+
+- Buyer-interest pages and leads are **contingent by default**
+ (`contingent = true`) and carry a standing disclaimer:
+
+ > *"This item is not yet owned. Any offer is contingent on winning the
+ > government auction and is non-binding until the auction is won and the item
+ > is in hand."*
+
+- The workflow **only accepts contingent offers** and **never represents
+ ownership before an auction is won**. Detail views label the buyer section
+ explicitly: *"Buyer Interest (contingent — no ownership represented pre-win)."*
+
+This is a hard product rule, not a setting.
diff --git a/extension/content.js b/extension/content.js
new file mode 100644
index 0000000..c71fd96
--- /dev/null
+++ b/extension/content.js
@@ -0,0 +1,422 @@
+/**
+ * GovArbitrage Capture — page extractor.
+ *
+ * This file exposes a single function, extractGovArbitrageListing(), that is
+ * injected into the active tab via chrome.scripting.executeScript and returns
+ * a structured listing object. It must be entirely self-contained (no imports,
+ * no closure over popup state) because it runs in the page's isolated world.
+ *
+ * It is written to NEVER throw — every DOM access is wrapped in try/catch or
+ * guarded with optional chaining, so a partial page still yields a partial (but
+ * valid) object.
+ *
+ * Emitted shape (matches the app's Prisma Listing model / EXTENSION source):
+ * {
+ * source: "GOVDEALS" | "PUBLIC_SURPLUS" | "GSA_AUCTIONS" | "OTHER",
+ * sourceAuctionId: string,
+ * sourceUrl: string,
+ * title: string,
+ * description: string,
+ * category: string,
+ * currentBid: number, // dollars, e.g. 125.5
+ * bidCount: number,
+ * closingAt: string | null, // ISO 8601
+ * locationCity: string,
+ * locationState: string,
+ * locationZip: string,
+ * imageUrls: string[],
+ * auctionTerms: string,
+ * capturedAt: string // ISO 8601, when the extension scraped it
+ * }
+ */
+function extractGovArbitrageListing() {
+ 'use strict';
+
+ // ----------------------------------------------------------------- helpers
+ const safe = (fn, fallback) => {
+ try {
+ const v = fn();
+ return v === undefined || v === null ? fallback : v;
+ } catch (_e) {
+ return fallback;
+ }
+ };
+
+ const text = (el) => {
+ try {
+ return (el && (el.textContent || el.innerText) || '').replace(/\s+/g, ' ').trim();
+ } catch (_e) {
+ return '';
+ }
+ };
+
+ const q = (sel, root) => safe(() => (root || document).querySelector(sel), null);
+ const qa = (sel, root) => safe(() => Array.from((root || document).querySelectorAll(sel)), []);
+
+ const meta = (prop) =>
+ safe(
+ () =>
+ (
+ q(`meta[property="${prop}"]`) ||
+ q(`meta[name="${prop}"]`)
+ )?.getAttribute('content') || '',
+ ''
+ );
+
+ // Parse a currency-ish string ("$1,250.00", "USD 1250") into a number.
+ const parseMoney = (str) => {
+ if (str === null || str === undefined) return 0;
+ try {
+ const m = String(str).replace(/[^0-9.,]/g, '');
+ if (!m) return 0;
+ // Strip thousands separators, keep the last dot as decimal.
+ const cleaned = m.replace(/,(?=\d{3}(\D|$))/g, '').replace(/,/g, '');
+ const n = parseFloat(cleaned);
+ return Number.isFinite(n) ? n : 0;
+ } catch (_e) {
+ return 0;
+ }
+ };
+
+ const parseInt10 = (str) => {
+ try {
+ const n = parseInt(String(str).replace(/[^0-9]/g, ''), 10);
+ return Number.isFinite(n) ? n : 0;
+ } catch (_e) {
+ return 0;
+ }
+ };
+
+ const toISO = (str) => {
+ if (!str) return null;
+ try {
+ const d = new Date(str);
+ if (!isNaN(d.getTime())) return d.toISOString();
+ } catch (_e) {
+ /* fall through */
+ }
+ return null;
+ };
+
+ const absUrl = (u) => {
+ if (!u) return '';
+ try {
+ return new URL(u, location.href).href;
+ } catch (_e) {
+ return u;
+ }
+ };
+
+ const params = safe(() => new URLSearchParams(location.search), new URLSearchParams());
+ const host = safe(() => location.hostname.toLowerCase(), '');
+
+ // ------------------------------------------------------------------ source
+ let source = 'OTHER';
+ if (host.includes('govdeals.com')) source = 'GOVDEALS';
+ else if (host.includes('publicsurplus.com')) source = 'PUBLIC_SURPLUS';
+ else if (host.includes('gsaauctions.gov')) source = 'GSA_AUCTIONS';
+
+ // ---------------------------------------------------- source auction / lot id
+ const idFromQuery = () => {
+ const keys = [
+ 'index',
+ 'itemid',
+ 'itemId',
+ 'assetId',
+ 'assetid',
+ 'auctionId',
+ 'auctionid',
+ 'id',
+ 'lotId',
+ 'lotid',
+ 'sku',
+ 'a',
+ ];
+ for (const k of keys) {
+ const v = params.get(k);
+ if (v) return v;
+ }
+ return '';
+ };
+
+ const idFromPath = () => {
+ const path = safe(() => location.pathname, '') || '';
+ // common patterns: /asset/12345, /item/12345, /auction/98765, /.../12345
+ const patterns = [
+ /\/(?:asset|item|items|auction|auctions|lot|lots|listing|listings)\/([A-Za-z0-9_-]+)/i,
+ /\/(\d{4,})(?:[/?#]|$)/,
+ ];
+ for (const re of patterns) {
+ const m = path.match(re);
+ if (m && m[1]) return m[1];
+ }
+ return '';
+ };
+
+ let sourceAuctionId = idFromQuery() || idFromPath();
+ if (!sourceAuctionId) {
+ // Last resort: look for a visible "Item #" / "Auction #" label on the page.
+ const bodyText = safe(() => document.body.innerText, '') || '';
+ const m =
+ bodyText.match(/(?:item|auction|lot|asset)\s*(?:#|no\.?|number)?\s*:?\s*([A-Za-z0-9-]{3,})/i) || null;
+ if (m && m[1]) sourceAuctionId = m[1];
+ }
+ sourceAuctionId = String(sourceAuctionId || '').trim();
+
+ // ------------------------------------------------------------------- title
+ let title =
+ meta('og:title') ||
+ text(q('h1')) ||
+ safe(() => document.title, '') ||
+ '';
+ // Trim trailing " | GovDeals" style site suffixes.
+ title = title.replace(/\s*[|\-–—]\s*(GovDeals|Public Surplus|GSA Auctions).*$/i, '').trim();
+
+ // ------------------------------------------------------------- description
+ let description =
+ meta('og:description') ||
+ meta('description') ||
+ '';
+ if (!description) {
+ // Try common description containers.
+ const descEl =
+ q('#description') ||
+ q('.description') ||
+ q('[class*="description" i]') ||
+ q('[id*="description" i]') ||
+ q('[class*="itemDetail" i]');
+ description = text(descEl);
+ }
+
+ // --------------------------------------------------------------- category
+ let category =
+ meta('og:category') ||
+ meta('article:section') ||
+ '';
+ if (!category) {
+ // Breadcrumb trail is the most reliable category signal.
+ const crumbs = qa('[class*="breadcrumb" i] a, nav[aria-label*="breadcrumb" i] a, .breadcrumbs a');
+ const parts = crumbs.map(text).filter(Boolean);
+ if (parts.length) {
+ // Drop a leading "Home" crumb, take the deepest meaningful one.
+ const filtered = parts.filter((p) => !/^home$/i.test(p));
+ category = (filtered[filtered.length - 1] || '').trim();
+ }
+ }
+
+ // -------------------------------------------------------------- current bid
+ const findBid = () => {
+ // 1. Site-specific selectors first.
+ const bidSelectors = [
+ '#currentBidAmount',
+ '.current-bid',
+ '.currentBid',
+ '[class*="currentBid" i]',
+ '[class*="current-bid" i]',
+ '[id*="currentBid" i]',
+ '[data-testid*="bid" i]',
+ ];
+ for (const sel of bidSelectors) {
+ const el = q(sel);
+ if (el) {
+ const v = parseMoney(text(el));
+ if (v > 0) return v;
+ }
+ }
+ // 2. Label-adjacent scan: find a "Current Bid" / "High Bid" label, read
+ // the nearest dollar amount after it.
+ const labelRe = /(current\s*bid|high\s*bid|winning\s*bid|current\s*price|bid\s*amount)/i;
+ const candidates = qa('td, th, span, div, p, li, dt, dd, label, strong, b');
+ for (let i = 0; i < candidates.length; i++) {
+ const t = text(candidates[i]);
+ if (!labelRe.test(t)) continue;
+ // dollar value may be inside the same node...
+ const inline = t.match(/\$[\s]*([0-9][0-9.,]*)/);
+ if (inline) {
+ const v = parseMoney(inline[0]);
+ if (v > 0) return v;
+ }
+ // ...or in a sibling / parent's next cell.
+ const sib = candidates[i].nextElementSibling;
+ if (sib) {
+ const v = parseMoney(text(sib));
+ if (v > 0) return v;
+ }
+ }
+ // 3. Global fallback: first plausible dollar figure on the page.
+ const body = safe(() => document.body.innerText, '') || '';
+ const m = body.match(/\$[\s]*([0-9][0-9.,]*)/);
+ return m ? parseMoney(m[0]) : 0;
+ };
+ const currentBid = safe(findBid, 0);
+
+ // ---------------------------------------------------------------- bid count
+ const findBidCount = () => {
+ const sels = ['[class*="bidCount" i]', '[class*="bid-count" i]', '[id*="bidCount" i]', '.bids'];
+ for (const sel of sels) {
+ const el = q(sel);
+ if (el) {
+ const v = parseInt10(text(el));
+ if (v > 0) return v;
+ }
+ }
+ const body = safe(() => document.body.innerText, '') || '';
+ const m = body.match(/(\d+)\s*bids?\b/i) || body.match(/bids?\s*[:#]?\s*(\d+)/i);
+ return m ? parseInt10(m[1]) : 0;
+ };
+ const bidCount = safe(findBidCount, 0);
+
+ // ---------------------------------------------------------------- closing at
+ const findClosing = () => {
+ // Prefer machine-readable <time datetime="...">.
+ const timeEls = qa('time[datetime]');
+ for (const el of timeEls) {
+ const iso = toISO(el.getAttribute('datetime'));
+ if (iso) return iso;
+ }
+ // Labelled selectors.
+ const sels = [
+ '[class*="closingDate" i]',
+ '[class*="closing-date" i]',
+ '[class*="endDate" i]',
+ '[class*="end-date" i]',
+ '[class*="timeLeft" i]',
+ '[id*="closing" i]',
+ '[id*="endDate" i]',
+ ];
+ for (const sel of sels) {
+ const el = q(sel);
+ const iso = toISO(el?.getAttribute?.('datetime') || text(el));
+ if (iso) return iso;
+ }
+ // Label-adjacent text scan.
+ const labelRe = /(closing|close|ends?|end\s*date|end\s*time|auction\s*ends?)/i;
+ const nodes = qa('td, th, span, div, p, li, dt, dd, label, strong, b');
+ for (let i = 0; i < nodes.length; i++) {
+ const t = text(nodes[i]);
+ if (!labelRe.test(t)) continue;
+ const iso = toISO(t.replace(labelRe, '').replace(/[:#-]/g, ' ').trim());
+ if (iso) return iso;
+ const sib = nodes[i].nextElementSibling;
+ if (sib) {
+ const iso2 = toISO(text(sib));
+ if (iso2) return iso2;
+ }
+ }
+ return null;
+ };
+ const closingAt = safe(findClosing, null);
+
+ // --------------------------------------------------------------- location
+ const findLocation = () => {
+ let city = '';
+ let state = '';
+ let zip = '';
+
+ // Look for an explicit location label / container.
+ const locEl =
+ q('[class*="location" i]') ||
+ q('[id*="location" i]') ||
+ q('[class*="itemLocation" i]');
+ let locText = text(locEl);
+
+ if (!locText) {
+ const labelRe = /(location|located\s*in|city\/state|item\s*location)/i;
+ const nodes = qa('td, th, span, div, p, li, dt, dd, label, strong, b');
+ for (let i = 0; i < nodes.length; i++) {
+ const t = text(nodes[i]);
+ if (labelRe.test(t)) {
+ const sib = nodes[i].nextElementSibling;
+ locText = (sib ? text(sib) : t.replace(labelRe, '').trim()) || '';
+ if (locText) break;
+ }
+ }
+ }
+
+ // Zip.
+ const zipM = locText.match(/\b(\d{5})(?:-\d{4})?\b/);
+ if (zipM) zip = zipM[1];
+
+ // "City, ST 12345" or "City, ST".
+ const csM = locText.match(/([A-Za-z .'-]+),\s*([A-Z]{2})\b/);
+ if (csM) {
+ city = csM[1].trim();
+ state = csM[2].trim();
+ }
+
+ return { locationCity: city, locationState: state, locationZip: zip };
+ };
+ const loc = safe(findLocation, { locationCity: '', locationState: '', locationZip: '' });
+
+ // ---------------------------------------------------------------- images
+ const findImages = () => {
+ const urls = new Set();
+
+ const og = meta('og:image');
+ if (og) urls.add(absUrl(og));
+
+ // Gallery / prominent images. Prefer larger images and skip obvious icons.
+ const imgs = qa('img');
+ for (const img of imgs) {
+ const raw =
+ img.getAttribute('data-src') ||
+ img.getAttribute('data-lazy') ||
+ img.currentSrc ||
+ img.src ||
+ '';
+ if (!raw) continue;
+ const u = absUrl(raw);
+ if (!/^https?:/i.test(u)) continue;
+ // Skip sprites / icons / tracking pixels / tiny thumbs by name or size.
+ if (/(sprite|icon|logo|pixel|blank|spacer|placeholder)/i.test(u)) continue;
+ const w = img.naturalWidth || img.width || 0;
+ const h = img.naturalHeight || img.height || 0;
+ if ((w && w < 80) || (h && h < 80)) continue;
+ urls.add(u);
+ }
+
+ return Array.from(urls).slice(0, 24);
+ };
+ const imageUrls = safe(findImages, []);
+
+ // ------------------------------------------------------------ auction terms
+ const findTerms = () => {
+ const sels = [
+ '[class*="terms" i]',
+ '[id*="terms" i]',
+ '[class*="paymentTerms" i]',
+ '[class*="removalTerms" i]',
+ '[class*="specialInstructions" i]',
+ ];
+ const chunks = [];
+ for (const sel of sels) {
+ for (const el of qa(sel)) {
+ const t = text(el);
+ if (t && t.length > 10) chunks.push(t);
+ }
+ }
+ // De-dup and cap length.
+ const joined = Array.from(new Set(chunks)).join('\n\n');
+ return joined.slice(0, 5000);
+ };
+ const auctionTerms = safe(findTerms, '');
+
+ // ------------------------------------------------------------------ result
+ return {
+ source,
+ sourceAuctionId,
+ sourceUrl: safe(() => location.href, ''),
+ title: (title || '').slice(0, 500),
+ description: (description || '').slice(0, 20000),
+ category: (category || '').slice(0, 200),
+ currentBid,
+ bidCount,
+ closingAt,
+ locationCity: loc.locationCity || '',
+ locationState: loc.locationState || '',
+ locationZip: loc.locationZip || '',
+ imageUrls,
+ auctionTerms,
+ capturedAt: new Date().toISOString(),
+ };
+}
diff --git a/extension/manifest.json b/extension/manifest.json
new file mode 100644
index 0000000..70bf2ea
--- /dev/null
+++ b/extension/manifest.json
@@ -0,0 +1,19 @@
+{
+ "manifest_version": 3,
+ "name": "GovArbitrage Capture",
+ "version": "0.1.0",
+ "description": "Capture a government-surplus auction page (GovDeals, Public Surplus, GSA Auctions, or generic) and send the extracted listing into the GovArbitrage app.",
+ "permissions": ["activeTab", "scripting", "storage"],
+ "host_permissions": [
+ "https://www.govdeals.com/*",
+ "https://www.publicsurplus.com/*",
+ "https://gsaauctions.gov/*"
+ ],
+ "action": {
+ "default_popup": "popup.html",
+ "default_title": "GovArbitrage Capture"
+ },
+ "background": {
+ "service_worker": "background.js"
+ }
+}
diff --git a/extension/popup.html b/extension/popup.html
new file mode 100644
index 0000000..9550cd9
--- /dev/null
+++ b/extension/popup.html
@@ -0,0 +1,198 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>GovArbitrage Capture</title>
+ <style>
+ :root {
+ --bg: #0f141a;
+ --panel: #161d26;
+ --panel-2: #1d2733;
+ --border: #2a3644;
+ --text: #e6edf3;
+ --muted: #8b98a5;
+ --accent: #3fb950;
+ --accent-hover: #2ea043;
+ --blue: #388bfd;
+ --blue-hover: #2f7ce0;
+ --danger: #f85149;
+ }
+ * {
+ box-sizing: border-box;
+ }
+ html,
+ body {
+ margin: 0;
+ padding: 0;
+ }
+ body {
+ width: 400px;
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 13px;
+ line-height: 1.45;
+ }
+ .wrap {
+ padding: 14px;
+ }
+ h1 {
+ font-size: 15px;
+ font-weight: 600;
+ margin: 0 0 12px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+ h1 .dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: var(--accent);
+ display: inline-block;
+ }
+ label {
+ display: block;
+ font-size: 11px;
+ color: var(--muted);
+ margin: 0 0 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+ input[type="text"] {
+ width: 100%;
+ padding: 7px 9px;
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 13px;
+ outline: none;
+ }
+ input[type="text"]:focus {
+ border-color: var(--blue);
+ }
+ .field {
+ margin-bottom: 12px;
+ }
+ .row {
+ display: flex;
+ gap: 8px;
+ }
+ button {
+ flex: 1;
+ padding: 9px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ color: var(--text);
+ background: var(--panel-2);
+ transition: background 0.12s ease, border-color 0.12s ease;
+ }
+ button:hover {
+ background: #26313d;
+ }
+ button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ button.primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #05130a;
+ }
+ button.primary:hover {
+ background: var(--accent-hover);
+ }
+ button.send {
+ background: var(--blue);
+ border-color: var(--blue);
+ color: #04101f;
+ }
+ button.send:hover {
+ background: var(--blue-hover);
+ }
+ .preview-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 14px 0 6px;
+ }
+ .preview-head label {
+ margin: 0;
+ }
+ .summary {
+ font-size: 11px;
+ color: var(--muted);
+ }
+ pre#preview {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 10px;
+ margin: 0;
+ max-height: 240px;
+ overflow: auto;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 11px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: #c8d3de;
+ }
+ pre#preview:empty::before {
+ content: "No capture yet — click “Capture this page”.";
+ color: var(--muted);
+ }
+ #status {
+ margin-top: 10px;
+ min-height: 16px;
+ font-size: 12px;
+ }
+ #status.ok {
+ color: var(--accent);
+ }
+ #status.err {
+ color: var(--danger);
+ }
+ #status.info {
+ color: var(--muted);
+ }
+ .spacer {
+ height: 8px;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="wrap">
+ <h1><span class="dot"></span> GovArbitrage Capture</h1>
+
+ <div class="field">
+ <label for="apiBase">API base URL</label>
+ <input type="text" id="apiBase" placeholder="http://localhost:3000" spellcheck="false" />
+ </div>
+
+ <div class="row">
+ <button id="captureBtn" class="primary">Capture this page</button>
+ </div>
+
+ <div class="preview-head">
+ <label>Extracted data</label>
+ <span class="summary" id="summary"></span>
+ </div>
+ <pre id="preview"></pre>
+
+ <div class="spacer"></div>
+
+ <div class="row">
+ <button id="sendBtn" class="send" disabled>Send to GovArbitrage</button>
+ </div>
+
+ <div id="status" class="info"></div>
+ </div>
+
+ <script src="popup.js"></script>
+ </body>
+</html>
diff --git a/src/app/selling-avenues/page.tsx b/src/app/selling-avenues/page.tsx
new file mode 100644
index 0000000..48847a3
--- /dev/null
+++ b/src/app/selling-avenues/page.tsx
@@ -0,0 +1,98 @@
+import Link from "next/link";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import {
+ SOURCING,
+ SELLING,
+ COMPLIANCE,
+ WORKED_EXAMPLE,
+ type AvenueGroup,
+} from "@/lib/selling-avenues";
+
+export const metadata = { title: "Selling Avenues — GovArbitrage" };
+
+function Groups({ groups }: { groups: AvenueGroup[] }) {
+ return (
+ <div className="grid gap-3 md:grid-cols-3">
+ {groups.map((g) => (
+ <Card key={g.heading}>
+ <CardHeader>
+ <CardTitle className="text-foreground">{g.heading}</CardTitle>
+ <p className="text-xs text-muted-foreground">{g.blurb}</p>
+ </CardHeader>
+ <CardContent className="space-y-2">
+ {g.avenues.map((a) => (
+ <div key={a.url} className="text-sm">
+ <a
+ href={a.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="font-medium text-primary hover:underline"
+ >
+ {a.name} ↗
+ </a>
+ <p className="text-xs text-muted-foreground">{a.note}</p>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ );
+}
+
+export default function SellingAvenuesPage() {
+ return (
+ <main className="mx-auto max-w-6xl space-y-6 p-5">
+ <div className="text-sm">
+ <Link href="/" className="text-primary hover:underline">
+ ← Dashboard
+ </Link>
+ </div>
+ <header>
+ <h1 className="text-2xl font-semibold tracking-tight">Sourcing & Selling Avenues</h1>
+ <p className="text-sm text-muted-foreground">
+ Where to buy government surplus, where to resell it, and how to validate demand
+ <em> before </em> you bid — with the compliance rules that keep “sell before you buy” legal.
+ </p>
+ </header>
+
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-foreground">{WORKED_EXAMPLE.title}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-sm text-muted-foreground">{WORKED_EXAMPLE.body}</p>
+ </CardContent>
+ </Card>
+
+ <section className="space-y-3">
+ <h2 className="text-lg font-semibold">Where to source</h2>
+ <Groups groups={SOURCING} />
+ </section>
+
+ <section className="space-y-3">
+ <h2 className="text-lg font-semibold">Where to sell & validate demand</h2>
+ <Groups groups={SELLING} />
+ </section>
+
+ <section className="space-y-3">
+ <h2 className="text-lg font-semibold">
+ Compliance — selling before you own <Badge variant="warning">read this</Badge>
+ </h2>
+ <div className="grid gap-3 md:grid-cols-2">
+ {COMPLIANCE.map((r) => (
+ <Card key={r.title}>
+ <CardHeader>
+ <CardTitle className="text-foreground">{r.title}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-sm text-muted-foreground">{r.body}</p>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </section>
+ </main>
+ );
+}
diff --git a/src/importers/csv.ts b/src/importers/csv.ts
new file mode 100644
index 0000000..5e255fe
--- /dev/null
+++ b/src/importers/csv.ts
@@ -0,0 +1,71 @@
+import { parse } from "csv-parse/sync";
+import type { RawListing } from "./ingest";
+
+// Column aliases → RawListing fields, so operators can paste varied CSVs.
+const FIELD_ALIASES: Record<string, keyof RawListing> = {
+ source: "source",
+ auction: "sourceAuctionId",
+ "auction #": "sourceAuctionId",
+ auction_id: "sourceAuctionId",
+ sourceauctionid: "sourceAuctionId",
+ url: "sourceUrl",
+ link: "sourceUrl",
+ title: "title",
+ name: "title",
+ description: "description",
+ desc: "description",
+ category: "category",
+ manufacturer: "manufacturer",
+ mfr: "manufacturer",
+ brand: "manufacturer",
+ model: "model",
+ condition: "condition",
+ quantity: "quantity",
+ qty: "quantity",
+ weight: "weightLbs",
+ weightlbs: "weightLbs",
+ dimensions: "dimensions",
+ city: "locationCity",
+ state: "locationState",
+ zip: "locationZip",
+ bid: "currentBid",
+ currentbid: "currentBid",
+ "current bid": "currentBid",
+ bidcount: "bidCount",
+ bids: "bidCount",
+ closing: "closingAt",
+ closingat: "closingAt",
+ "closing date": "closingAt",
+ images: "imageUrls",
+ imageurls: "imageUrls",
+ terms: "auctionTerms",
+};
+
+const NUMERIC: (keyof RawListing)[] = ["quantity", "weightLbs", "currentBid", "bidCount"];
+
+/** Parse a CSV string into normalized RawListing rows (header-driven, alias-aware). */
+export function parseListingsCsv(text: string): RawListing[] {
+ const records = parse(text, {
+ columns: (header: string[]) => header.map((h) => h.trim().toLowerCase()),
+ skip_empty_lines: true,
+ trim: true,
+ relax_column_count: true,
+ }) as Record<string, string>[];
+
+ return records.map((rec) => {
+ const raw: Record<string, unknown> = {};
+ for (const [key, value] of Object.entries(rec)) {
+ const field = FIELD_ALIASES[key];
+ if (!field || value === "" || value == null) continue;
+ if (NUMERIC.includes(field)) {
+ const num = Number(String(value).replace(/[$,]/g, ""));
+ if (!Number.isNaN(num)) raw[field] = num;
+ } else if (field === "imageUrls") {
+ raw[field] = String(value).split(/[|;]/).map((s) => s.trim()).filter(Boolean);
+ } else {
+ raw[field] = value;
+ }
+ }
+ return raw as unknown as RawListing;
+ });
+}
diff --git a/src/importers/ingest.ts b/src/importers/ingest.ts
new file mode 100644
index 0000000..8f6fb72
--- /dev/null
+++ b/src/importers/ingest.ts
@@ -0,0 +1,144 @@
+import { prisma } from "@/lib/db";
+import { runResearch } from "@/pipeline/research";
+import type { AuctionSource, Condition } from "@prisma/client";
+
+// Normalized import shape every importer (CSV, extension, URL scrape) produces.
+export interface RawListing {
+ source: string;
+ sourceAuctionId: string;
+ sourceUrl?: string;
+ title: string;
+ description?: string;
+ category?: string;
+ manufacturer?: string;
+ model?: string;
+ condition?: string;
+ quantity?: number;
+ weightLbs?: number;
+ dimensions?: string;
+ locationCity?: string;
+ locationState?: string;
+ locationZip?: string;
+ currentBid?: number;
+ bidCount?: number;
+ closingAt?: string | Date | null;
+ imageUrls?: string[];
+ auctionTerms?: string;
+}
+
+const SOURCE_MAP: Record<string, AuctionSource> = {
+ GOVDEALS: "GOVDEALS",
+ PUBLIC_SURPLUS: "PUBLIC_SURPLUS",
+ PUBLICSURPLUS: "PUBLIC_SURPLUS",
+ GSA_AUCTIONS: "GSA_AUCTIONS",
+ GSA: "GSA_AUCTIONS",
+ COUNTY: "COUNTY",
+ STATE_SURPLUS: "STATE_SURPLUS",
+ UNIVERSITY_SURPLUS: "UNIVERSITY_SURPLUS",
+ MUNICIBID: "MUNICIBID",
+ BID4ASSETS: "BID4ASSETS",
+ CSV: "CSV",
+ EXTENSION: "EXTENSION",
+};
+
+const CONDITION_MAP: Record<string, Condition> = {
+ NEW: "NEW",
+ LIKE_NEW: "LIKE_NEW",
+ LIKENEW: "LIKE_NEW",
+ USED_GOOD: "USED_GOOD",
+ GOOD: "USED_GOOD",
+ USED: "USED_GOOD",
+ USED_FAIR: "USED_FAIR",
+ FAIR: "USED_FAIR",
+ FOR_PARTS: "FOR_PARTS",
+ PARTS: "FOR_PARTS",
+ SALVAGE: "FOR_PARTS",
+ UNKNOWN: "UNKNOWN",
+};
+
+export function normalizeSource(s: string | undefined): AuctionSource {
+ return SOURCE_MAP[(s || "").toUpperCase().replace(/[\s-]/g, "_")] ?? "OTHER";
+}
+
+export function normalizeCondition(c: string | undefined): Condition {
+ return CONDITION_MAP[(c || "").toUpperCase().replace(/[\s-]/g, "_")] ?? "UNKNOWN";
+}
+
+export interface IngestResult {
+ listingId: string;
+ created: boolean;
+ research?: Awaited<ReturnType<typeof runResearch>>;
+}
+
+/**
+ * Ingest one normalized listing: upsert by (source, sourceAuctionId), then run
+ * the research pipeline (local AI identification + engines). Idempotent.
+ */
+export async function ingest(raw: RawListing, opts: { research?: boolean } = {}): Promise<IngestResult> {
+ if (!raw.title || !raw.sourceAuctionId) {
+ throw new Error("Import requires at least title and sourceAuctionId");
+ }
+ const source = normalizeSource(raw.source);
+ const closingAt = raw.closingAt ? new Date(raw.closingAt) : null;
+
+ const existing = await prisma.listing.findUnique({
+ where: { source_sourceAuctionId: { source, sourceAuctionId: raw.sourceAuctionId } },
+ });
+
+ const data = {
+ source,
+ sourceAuctionId: raw.sourceAuctionId,
+ sourceUrl: raw.sourceUrl ?? null,
+ title: raw.title,
+ description: raw.description ?? null,
+ category: raw.category ?? null,
+ manufacturer: raw.manufacturer ?? null,
+ model: raw.model ?? null,
+ condition: normalizeCondition(raw.condition),
+ quantity: raw.quantity && raw.quantity > 0 ? Math.floor(raw.quantity) : 1,
+ weightLbs: raw.weightLbs ?? null,
+ dimensions: raw.dimensions ?? null,
+ locationCity: raw.locationCity ?? null,
+ locationState: raw.locationState ?? null,
+ locationZip: raw.locationZip ?? null,
+ currentBid: raw.currentBid ?? 0,
+ bidCount: raw.bidCount ?? 0,
+ closingAt: closingAt && !isNaN(closingAt.getTime()) ? closingAt : null,
+ imageUrls: raw.imageUrls ?? [],
+ auctionTerms: raw.auctionTerms ?? null,
+ };
+
+ const listing = existing
+ ? await prisma.listing.update({ where: { id: existing.id }, data })
+ : await prisma.listing.create({ data: { ...data, researchStatus: "PENDING" } });
+
+ await prisma.listingEvent.create({
+ data: {
+ listingId: listing.id,
+ type: existing ? "MANUAL_EDIT" : "IMPORTED",
+ message: existing ? `Re-imported from ${source}` : `Imported from ${source}`,
+ },
+ });
+
+ let research;
+ if (opts.research !== false) {
+ // Live imports use the local AI identifier; failures degrade to heuristic.
+ research = await runResearch(listing.id, { useAI: true, writeIdentity: true });
+ }
+
+ return { listingId: listing.id, created: !existing, research };
+}
+
+/** Bulk ingest with per-row error capture. */
+export async function ingestMany(rows: RawListing[], opts: { research?: boolean } = {}) {
+ const results: { ok: boolean; listingId?: string; created?: boolean; error?: string; title: string }[] = [];
+ for (const row of rows) {
+ try {
+ const r = await ingest(row, opts);
+ results.push({ ok: true, listingId: r.listingId, created: r.created, title: row.title });
+ } catch (e) {
+ results.push({ ok: false, error: (e as Error).message, title: row.title || "(untitled)" });
+ }
+ }
+ return results;
+}
diff --git a/src/lib/selling-avenues.ts b/src/lib/selling-avenues.ts
new file mode 100644
index 0000000..89316f9
--- /dev/null
+++ b/src/lib/selling-avenues.ts
@@ -0,0 +1,116 @@
+// Structured "where to source" and "where to sell / validate demand before you
+// buy" reference, with real links. Rendered at /selling-avenues and mirrored in
+// docs/SELLING-AVENUES.md. Compliance-first: you may validate demand and take
+// CONTINGENT interest before winning, but you must never represent ownership of
+// an item you have not yet won.
+
+export interface Avenue {
+ name: string;
+ url: string;
+ note: string;
+}
+
+export interface AvenueGroup {
+ heading: string;
+ blurb: string;
+ avenues: Avenue[];
+}
+
+export const SOURCING: AvenueGroup[] = [
+ {
+ heading: "Federal surplus",
+ blurb: "US government agency surplus — often no buyer's premium.",
+ avenues: [
+ { name: "GSA Auctions", url: "https://gsaauctions.gov", note: "Federal personal property; frequently $0 buyer premium." },
+ { name: "GovDeals", url: "https://www.govdeals.com", note: "Largest gov-surplus marketplace (state/county/federal); ~10% premium." },
+ { name: "GovPlanet / AllSurplus", url: "https://www.govplanet.com", note: "Heavy equipment, vehicles, military rolling stock." },
+ ],
+ },
+ {
+ heading: "State & county surplus",
+ blurb: "State DGS/facilities programs and county auctions.",
+ avenues: [
+ { name: "Public Surplus", url: "https://www.publicsurplus.com", note: "Municipal, school-district and agency surplus nationwide." },
+ { name: "California DGS Surplus", url: "https://www.dgs.ca.gov/OFAM/Surplus-Property", note: "State of California surplus personal property." },
+ { name: "Texas Facilities Commission Surplus", url: "https://www.tfc.texas.gov/divisions/supportserv/prog/statesurplus/", note: "Texas state surplus program." },
+ { name: "Municibid", url: "https://www.municibid.com", note: "Local government surplus — vehicles, equipment." },
+ { name: "Bid4Assets", url: "https://www.bid4assets.com", note: "County tax-defaulted property + surplus." },
+ ],
+ },
+ {
+ heading: "University surplus",
+ blurb: "University asset-recovery programs, strong for lab & IT gear.",
+ avenues: [
+ { name: "UW Surplus", url: "https://surplus.uw.edu", note: "University of Washington." },
+ { name: "MSU Surplus Store", url: "https://surplus.msu.edu", note: "Michigan State University." },
+ { name: "UC Davis Aggie Surplus", url: "https://aggiesurplus.ucdavis.edu", note: "UC Davis." },
+ ],
+ },
+];
+
+export const SELLING: AvenueGroup[] = [
+ {
+ heading: "Retail resale marketplaces",
+ blurb: "Where the flipped item actually gets sold.",
+ avenues: [
+ { name: "eBay", url: "https://www.ebay.com", note: "Deepest buyer pool for used equipment; ~13% final-value fee." },
+ { name: "Facebook Marketplace", url: "https://www.facebook.com/marketplace", note: "Best for local, heavy, freight-averse items." },
+ { name: "Amazon Renewed", url: "https://www.amazon.com/renewed", note: "Refurb electronics at scale (approval required)." },
+ { name: "DOTmed / LabX", url: "https://www.dotmed.com", note: "Medical & lab equipment verticals — higher-value buyers." },
+ { name: "MachineryTrader", url: "https://www.machinerytrader.com", note: "Industrial / heavy equipment." },
+ ],
+ },
+ {
+ heading: "Wholesale & liquidation exits",
+ blurb: "Faster, lower-price exits when you don't want to retail one-by-one.",
+ avenues: [
+ { name: "B-Stock", url: "https://www.bstock.com", note: "Bulk B2B liquidation auctions." },
+ { name: "Liquidation.com", url: "https://www.liquidation.com", note: "Pallet/lot liquidation." },
+ ],
+ },
+ {
+ heading: "Demand validation (BEFORE you buy)",
+ blurb:
+ "Use these to prove resale price and demand before committing to a bid — this is research, not a sale.",
+ avenues: [
+ { name: "eBay Terapeak / Sold listings", url: "https://www.ebay.com/sh/research", note: "Free sold-price history — the single best comp source." },
+ { name: "Google Shopping", url: "https://shopping.google.com", note: "New-retail anchor across sellers." },
+ { name: "PriceCharting", url: "https://www.pricecharting.com", note: "For collectibles/electronics with model-level pricing." },
+ ],
+ },
+];
+
+export interface ComplianceRule {
+ title: string;
+ body: string;
+}
+
+export const COMPLIANCE: ComplianceRule[] = [
+ {
+ title: "Never represent ownership before you win",
+ body:
+ "You do not own an auction lot until the auction closes in your favor and you have paid. Any marketing before that must be framed as CONTINGENT interest ('I intend to acquire this; contingent on winning'), never 'for sale now'.",
+ },
+ {
+ title: "eBay pre-sale rules",
+ body:
+ "eBay allows pre-sale listings only for items you have a firm commitment to supply and can ship within 40 business days (listing must state the ship date). You cannot list an auction lot you might lose. Practically: validate demand and collect contingent buyer interest off-platform, then list once you actually win and hold the item.",
+ },
+ {
+ title: "Contingent buyer interest is fine",
+ body:
+ "Collecting names, emails and non-binding offers on a 'buyer interest' page is compliant as long as it is clearly contingent and non-binding until you own the item. GovArbitrage's buyer workflow enforces the contingent flag and shows a disclaimer on every interest page.",
+ },
+ {
+ title: "Government auction terms bind you",
+ body:
+ "Gov surplus is sold AS-IS, often pickup-only, with removal deadlines and payment windows. Model those costs (pickup labor, freight, storage) before bidding — the cost engine does this. Missing a removal deadline can forfeit the item and your payment.",
+ },
+];
+
+// The worked example Steve gave: a dental chair bought cheap vs. new retail.
+export const WORKED_EXAMPLE = {
+ title: "Worked example — the dental chair",
+ body:
+ "An A-dec 511 dental patient chair sells new for ~$20,000. A county-clinic surplus unit in used-good condition might hammer at $500 on GovDeals. But 'buy $500, sell $20,000' is fiction. Realistic used A-dec 511 units sell for ~$6,000–9,000 (see eBay/DOTmed sold comps). Against that, subtract the 10% buyer's premium, ~$500 in freight (320 lb, pickup-only), pickup labor, testing, refurbishment, ~13% marketplace fees and payment fees. GovArbitrage runs exactly this math per listing and reports the honest expected net profit, ROI, and a recommended maximum bid — so you bid to a target return instead of a fantasy spread.",
+};
← cd72087 Phase 4b: ESLint 9 flat config (Next 16 native) — lint passe
·
back to Govarbitrage
·
Phase 5: importers (CSV/extension/URL) + worker + compliant 7b18a95 →