← back to Govarbitrage

src/importers/gsa.ts

92 lines

import type { RawListing } from "./ingest";

// Official GSA Auctions API — free, JSON, no scraping/bot-wall. Federal surplus
// auction listings from all participating agencies.
//   GET https://api.gsa.gov/assets/gsaauctions/v2/auctions  (X-API-KEY header)
// The endpoint 303-redirects to a pre-generated active-auctions.json on S3;
// fetch() follows the redirect automatically.
//
// GSA_API_KEY defaults to the public DEMO_KEY (rate-limited). Supply an
// api.data.gov key for production volume.

const GSA_URL = "https://api.gsa.gov/assets/gsaauctions/v2/auctions";

// The API returns lowercase-first field names; type them loosely + read defensively.
interface GsaRow {
  saleNo?: string;
  lotNo?: string;
  itemName?: string;
  itemDescURL?: string;
  lotInfo?: string;
  aucStartDt?: string;
  aucEndDt?: string;
  auctionStatus?: string;
  highBidAmount?: string | number;
  reserve?: string | number;
  aucIncrement?: string | number;
  biddersCount?: string | number;
  propertyCity?: string;
  propertyState?: string;
  propertyZip?: string;
  agencyName?: string;
  bureauName?: string;
  imageURL?: string;
  instruction1?: string;
  instruction2?: string;
  instruction3?: string;
}

const numOr = (v: unknown, d = 0): number => {
  const n = Number(String(v ?? "").replace(/[$,]/g, ""));
  return Number.isFinite(n) ? n : d;
};

function mapRow(r: GsaRow): RawListing | null {
  const sale = r.saleNo?.trim();
  const lot = r.lotNo?.trim();
  const title = r.itemName?.trim();
  if (!sale || !title) return null;

  const terms = [r.instruction1, r.instruction2, r.instruction3, r.reserve ? `Reserve: ${r.reserve}` : "", r.aucIncrement ? `Bid increment: ${r.aucIncrement}` : ""]
    .filter(Boolean)
    .join(" ");

  return {
    source: "GSA_AUCTIONS",
    sourceAuctionId: [sale, lot].filter(Boolean).join("-"),
    sourceUrl: r.itemDescURL || "https://gsaauctions.gov",
    title,
    description: [r.lotInfo, r.agencyName, r.bureauName].filter(Boolean).join(" · ") || undefined,
    category: r.agencyName || undefined,
    condition: "UNKNOWN",
    quantity: 1,
    locationCity: r.propertyCity,
    locationState: r.propertyState,
    locationZip: r.propertyZip,
    currentBid: numOr(r.highBidAmount),
    bidCount: numOr(r.biddersCount),
    closingAt: r.aucEndDt || null,
    imageUrls: r.imageURL ? [r.imageURL] : [],
    auctionTerms: terms || undefined,
  };
}

/** Fetch active GSA auction listings and normalize them to RawListing[]. */
export async function fetchGsaAuctions(opts: { limit?: number; onlyOpen?: boolean } = {}): Promise<RawListing[]> {
  const key = process.env.GSA_API_KEY || "DEMO_KEY";
  const res = await fetch(GSA_URL, {
    headers: { "X-API-KEY": key, Accept: "application/json" },
    // fetch follows the 303 → S3 automatically.
  });
  if (!res.ok) {
    throw new Error(`GSA Auctions API ${res.status}: ${(await res.text()).slice(0, 200)}`);
  }
  const data = (await res.json()) as { Results?: GsaRow[] };
  const rows = (data.Results ?? []).map(mapRow).filter((x): x is RawListing => x !== null);

  const filtered = opts.onlyOpen
    ? rows.filter((r) => r.closingAt && new Date(r.closingAt).getTime() >= Date.now() - 86_400_000)
    : rows;
  return opts.limit ? filtered.slice(0, opts.limit) : filtered;
}