← back to Govarbitrage
extension/content.js
423 lines
/**
* 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(),
};
}