← 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(),
  };
}