← back to Govarbitrage
src/importers/govplanet-free.ts
228 lines
import type { RawListing } from "./ingest";
// FREE GovPlanet importer. $0, no browser at runtime.
//
// GovPlanet runs the IronPlanet / Ritchie Bros commerce stack (Java, `.ips`
// URLs). It is NOT on the Liquidity Services `maestro.lqdt1.com` API (verified:
// businessIds GP/GOVPLANET/IP/IRONPLANET all return 0 results). Its category
// landing pages are JS-hydrated, BUT the item search page embeds a per-item
// `quickviews.push({...})` JSON object with every field we need — served to
// plain curl (HTTP 200, no bot wall). So the crack is: fetch the search HTML,
// extract each quickviews object with a brace/string-aware scanner, JSON.parse.
//
// Verified 2026-07-10. Endpoints (each returns ~60 items):
// https://www.govplanet.com/buy-now
// https://www.govplanet.com/searchResults.ips?keyword=<kw> (empty kw = all)
// quickviews object fields:
// equipId, description (title), price/convPrice (USD), currency ("USD"),
// eumeLocation, itemPageUri, photoThumb, timeLeft (relative), bidCnt, features
// Prices are already USD (US government surplus) — no FX conversion needed.
// Native pagination is AJAX-only, so we broaden coverage across the two base
// endpoints plus a set of high-value category keywords, deduped by equipId.
const BASE = "https://www.govplanet.com";
const UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
// High-value US-resale categories to broaden coverage beyond the ~60-item
// default page (GovPlanet's on-page pagination is AJAX-only).
const KEYWORDS = [
"", // all
"generator",
"truck",
"trailer",
"forklift",
"excavator",
"loader",
"trailer",
"tractor",
"compressor",
"welder",
"hmmwv",
"humvee",
];
function stripTags(s: string): string {
return s
.replace(/<[^>]*>/g, " ")
.replace(/&/g, "&")
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/ /g, " ")
.replace(/\s+/g, " ")
.trim();
}
function toNumber(s: string | undefined): number {
if (!s) return 0;
const m = /([\d,]+(?:\.\d+)?)/.exec(s.replace(/[^0-9.,]/g, ""));
return m ? Number(m[1].replace(/,/g, "")) : 0;
}
// Parse a relative "timeLeft" like "2 days", "4 hrs", "15 mins" -> ISO close.
// GovPlanet often renders " " (blank) for offer-only items -> null.
function parseRelativeClose(raw: string | undefined): string | null {
if (!raw) return null;
const txt = stripTags(raw).toLowerCase();
if (!txt || /ended|closed/.test(txt)) return null;
const units: [RegExp, number][] = [
[/(\d+)\s*day/, 86400_000],
[/(\d+)\s*h(?:ou)?r/, 3600_000],
[/(\d+)\s*min/, 60_000],
[/(\d+)\s*sec/, 1000],
];
let deltaMs = 0;
let matched = false;
for (const [re, ms] of units) {
const m = re.exec(txt);
if (m) {
deltaMs += Number(m[1]) * ms;
matched = true;
}
}
if (!matched) return null;
return new Date(Date.now() + deltaMs).toISOString();
}
/**
* Extract every `quickviews.push({...})` object from a GovPlanet search page.
* Uses a string/brace-aware scanner because values contain unescaped `)` and
* `{` inside strings (e.g. "(2,424 mi away)"), which a greedy regex would break.
*/
function extractQuickviews(html: string): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [];
const marker = "quickviews.push(";
let idx = html.indexOf(marker);
while (idx !== -1) {
let i = idx + marker.length;
// Expect the object to start at '{'.
while (i < html.length && html[i] !== "{") i++;
if (i >= html.length) break;
const start = i;
let depth = 0;
let inStr = false;
let quote = "";
for (; i < html.length; i++) {
const ch = html[i];
if (inStr) {
if (ch === "\\") {
i++; // skip escaped char
continue;
}
if (ch === quote) inStr = false;
} else {
if (ch === '"' || ch === "'") {
inStr = true;
quote = ch;
} else if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) {
i++;
break;
}
}
}
}
const objStr = html.slice(start, i);
try {
out.push(JSON.parse(objStr));
} catch {
// Some pages use single-quoted values in a few fields; best-effort skip.
}
idx = html.indexOf(marker, i);
}
return out;
}
function qvToListing(qv: Record<string, unknown>): RawListing | null {
const id = String(qv.equipId || qv.itemId || "").trim();
const title = stripTags(String(qv.description || "")).trim();
if (!id || !title) return null;
const priceHtml = String(qv.price || qv.convPrice || "");
const currentBid = toNumber(priceHtml);
const uri = String(qv.itemPageUri || `/item/${id}`);
const sourceUrl = uri.startsWith("http") ? uri : `${BASE}${uri}`;
const state = String(qv.eumeLocation || "").trim() || undefined;
const img = qv.photoThumb ? String(qv.photoThumb) : undefined;
const bidCount = toNumber(String(qv.bidCnt || "")) || 0;
const closingAt = parseRelativeClose(qv.timeLeft ? String(qv.timeLeft) : undefined);
const features = stripTags(String(qv.features || "")) || undefined;
return {
source: "GOVPLANET",
sourceAuctionId: id,
sourceUrl,
title,
description: features,
locationState: state,
currentBid,
bidCount,
closingAt,
imageUrls: img ? [img] : [],
};
}
async function fetchSearch(keyword: string): Promise<string> {
const url = keyword
? `${BASE}/searchResults.ips?keyword=${encodeURIComponent(keyword)}`
: `${BASE}/buy-now`;
const res = await fetch(url, {
headers: {
"User-Agent": UA,
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
});
if (!res.ok) throw new Error(`GovPlanet "${keyword || "buy-now"}" -> HTTP ${res.status}`);
return res.text();
}
export interface FetchGovPlanetOpts {
limit?: number;
}
/**
* Fetch up to `limit` active GovPlanet items by scanning the buy-now page and a
* set of high-value category keyword searches, deduped by equipId. All prices
* are USD. $0 — plain HTTPS, no browser, no API key.
*/
export async function fetchGovPlanetFree(
opts: FetchGovPlanetOpts = {},
): Promise<RawListing[]> {
const limit = opts.limit ?? 100;
const seen = new Set<string>();
const rows: RawListing[] = [];
// Always start with the dedicated buy-now page, then keyword searches.
const queries = ["", ...KEYWORDS];
const uniqueQueries = [...new Set(queries)];
for (const kw of uniqueQueries) {
if (rows.length >= limit) break;
let html: string;
try {
html = await fetchSearch(kw);
} catch (e) {
console.warn(`[govplanet-free] ${(e as Error).message}`);
continue;
}
const qvs = extractQuickviews(html);
for (const qv of qvs) {
const listing = qvToListing(qv);
if (!listing) continue;
if (seen.has(listing.sourceAuctionId)) continue;
seen.add(listing.sourceAuctionId);
rows.push(listing);
if (rows.length >= limit) break;
}
await new Promise((r) => setTimeout(r, 350));
}
return rows.slice(0, limit);
}