← 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;
}