← back to Govarbitrage
src/importers/scrape.ts
81 lines
import type { RawListing } from "./ingest";
// Best-effort single-URL importer. Uses Playwright (a devDependency) to render
// the page, then extracts fields with host-specific selectors and generic
// fallbacks (og: tags, $-regex). Live auction-site DOMs change often, so this is
// intentionally defensive: whatever it can't find is left undefined and the
// research pipeline's AI identifier fills gaps from the title/description.
function sourceFromHost(host: string): string {
if (host.includes("govdeals")) return "GOVDEALS";
if (host.includes("publicsurplus")) return "PUBLIC_SURPLUS";
if (host.includes("gsaauctions")) return "GSA_AUCTIONS";
if (host.includes("municibid")) return "MUNICIBID";
if (host.includes("bid4assets")) return "BID4ASSETS";
return "OTHER";
}
function auctionIdFromUrl(u: URL): string {
const q =
u.searchParams.get("itemid") ||
u.searchParams.get("auc") ||
u.searchParams.get("id") ||
u.searchParams.get("ac");
if (q) return q;
const segs = u.pathname.split("/").filter(Boolean);
return segs[segs.length - 1] || u.pathname;
}
export async function scrapeUrl(url: string): Promise<RawListing> {
const u = new URL(url);
const source = sourceFromHost(u.hostname);
// Dynamic import so the main app bundle never hard-depends on Playwright.
let chromium: typeof import("playwright").chromium;
try {
({ chromium } = await import("playwright"));
} catch {
throw new Error(
"Playwright is not installed. Run `npx playwright install chromium` to enable URL import.",
);
}
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ userAgent: "Mozilla/5.0 GovArbitrageBot/0.1" });
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
const extracted = await page.evaluate(() => {
const meta = (p: string) =>
(document.querySelector(`meta[property="${p}"]`) as HTMLMetaElement | null)?.content ||
(document.querySelector(`meta[name="${p}"]`) as HTMLMetaElement | null)?.content ||
undefined;
const text = document.body?.innerText || "";
const priceMatch = text.match(/\$\s?([\d,]+(?:\.\d{2})?)/);
const images = Array.from(document.querySelectorAll("img"))
.map((i) => (i as HTMLImageElement).src)
.filter((s) => s && s.startsWith("http"))
.slice(0, 6);
const ogImage = meta("og:image");
return {
title: meta("og:title") || document.querySelector("h1")?.textContent?.trim() || document.title,
description: meta("og:description") || undefined,
currentBid: priceMatch ? Number(priceMatch[1].replace(/,/g, "")) : undefined,
imageUrls: ogImage ? [ogImage, ...images] : images,
};
});
return {
source,
sourceAuctionId: auctionIdFromUrl(u),
sourceUrl: url,
title: extracted.title || `${source} lot ${auctionIdFromUrl(u)}`,
description: extracted.description,
currentBid: extracted.currentBid,
imageUrls: extracted.imageUrls,
};
} finally {
await browser.close();
}
}