← back to Govarbitrage

src/importers/apify-govdeals.ts

149 lines

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

// GovDeals via the Apify actor `parseforge/govdeals-scraper` — bypasses GovDeals'
// Akamai bot-wall (Apify handles it). We READ finished datasets (free); running
// the actor for FRESH data costs Apify compute (~$0.0037/listing) and is gated.
//
// Env: APIFY_TOKEN (required). Optionally APIFY_GOVDEALS_ACTOR (default the known
// actor) and APIFY_GOVDEALS_DATASET (a specific dataset id to read).

const API = "https://api.apify.com/v2";
const DEFAULT_ACTOR = process.env.APIFY_GOVDEALS_ACTOR || "SZdBCF9FpwOzvByvM"; // parseforge/govdeals-scraper

interface ApifyItem {
  title?: string;
  url?: string;
  imageUrl?: string;
  photos?: (string | { url?: string })[];
  accountId?: number;
  assetId?: number;
  auctionId?: number;
  category?: string;
  parentCategory?: string;
  make?: string | null;
  model?: string | null;
  condition?: string | null;
  quantity?: number;
  weight?: number | null;
  description?: string;
  locationCity?: string;
  locationState?: string;
  locationZip?: string;
  currentBid?: number;
  bidCount?: number;
  auctionEnd?: string;
  auctionEndUtc?: string;
  specialInstructions?: string;
  removalInstructions?: string;
  paymentInstructions?: string;
}

const CONDITION_MAP: Record<string, RawListing["condition"]> = {
  N: "NEW",
  U: "USED_GOOD",
  R: "LIKE_NEW",
  F: "USED_FAIR",
  S: "FOR_PARTS",
  P: "FOR_PARTS",
};

const stripHtml = (s?: string) =>
  (s || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim() || undefined;

function photoUrls(item: ApifyItem): string[] {
  if (Array.isArray(item.photos) && item.photos.length) {
    return item.photos.map((p) => (typeof p === "string" ? p : p?.url || "")).filter(Boolean).slice(0, 8);
  }
  return item.imageUrl ? [item.imageUrl] : [];
}

function mapItem(it: ApifyItem): RawListing | null {
  const title = it.title?.trim();
  if (!title || it.assetId == null) return null;
  return {
    source: "GOVDEALS",
    sourceAuctionId: `${it.accountId ?? "x"}-${it.assetId}`,
    sourceUrl: it.url || "https://www.govdeals.com",
    title,
    description: stripHtml(it.description),
    category: it.category || it.parentCategory || undefined,
    manufacturer: it.make || undefined,
    model: it.model || undefined,
    condition: CONDITION_MAP[(it.condition || "").trim().toUpperCase()] || "UNKNOWN",
    quantity: it.quantity && it.quantity > 0 ? it.quantity : 1,
    weightLbs: it.weight ?? undefined,
    locationCity: it.locationCity,
    locationState: it.locationState,
    locationZip: it.locationZip,
    currentBid: it.currentBid ?? 0,
    bidCount: it.bidCount ?? 0,
    closingAt: it.auctionEndUtc || it.auctionEnd || null,
    imageUrls: photoUrls(it),
    auctionTerms: [it.specialInstructions, it.removalInstructions, it.paymentInstructions]
      .map(stripHtml)
      .filter(Boolean)
      .join(" ") || undefined,
  };
}

async function apifyJson(path: string): Promise<unknown> {
  const token = process.env.APIFY_TOKEN;
  if (!token) throw new Error("APIFY_TOKEN not set");
  const sep = path.includes("?") ? "&" : "?";
  const res = await fetch(`${API}${path}${sep}token=${token}&clean=true`);
  if (!res.ok) throw new Error(`Apify ${res.status}: ${(await res.text()).slice(0, 160)}`);
  return res.json();
}

/** Read an already-scraped dataset by id (free). */
export async function fetchApifyDataset(datasetId: string, limit = 200): Promise<RawListing[]> {
  const items = (await apifyJson(`/datasets/${datasetId}/items?limit=${limit}`)) as ApifyItem[];
  return items.map(mapItem).filter((x): x is RawListing => x !== null);
}

/** Read the most recent SUCCEEDED run's dataset for the GovDeals actor (free). */
export async function fetchApifyLastRun(limit = 200): Promise<RawListing[]> {
  const items = (await apifyJson(
    `/acts/${DEFAULT_ACTOR}/runs/last/dataset/items?status=SUCCEEDED&limit=${limit}`,
  )) as ApifyItem[];
  return items.map(mapItem).filter((x): x is RawListing => x !== null);
}

/**
 * Trigger a NEW GovDeals scrape run (PAID — ~$0.0037/listing) and return the
 * scraped rows plus the run's actual USD cost. Steve-authorized for the daily
 * 100-listing job. Polls up to ~8 min for completion.
 */
export async function triggerApifyRun(
  maxItems = 100,
): Promise<{ rows: RawListing[]; costUsd: number; runId: string }> {
  const token = process.env.APIFY_TOKEN;
  if (!token) throw new Error("APIFY_TOKEN not set");

  const startRes = await fetch(`${API}/acts/${DEFAULT_ACTOR}/runs?token=${token}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ maxItems, sortOrder: "asc" }),
  });
  if (!startRes.ok) throw new Error(`Apify run start ${startRes.status}: ${(await startRes.text()).slice(0, 160)}`);
  const run = ((await startRes.json()) as { data: { id: string; status: string; defaultDatasetId: string } }).data;

  const deadline = Date.now() + 8 * 60 * 1000;
  let status = run.status;
  let datasetId = run.defaultDatasetId;
  let costUsd = 0;
  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, 5000));
    const res = await fetch(`${API}/actor-runs/${run.id}?token=${token}`);
    const s = ((await res.json()) as { data: { status: string; defaultDatasetId: string; usageTotalUsd?: number } }).data;
    status = s.status;
    datasetId = s.defaultDatasetId;
    costUsd = s.usageTotalUsd ?? 0;
    if (["SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"].includes(status)) break;
  }
  if (status !== "SUCCEEDED") throw new Error(`Apify run ${run.id} ended ${status}`);

  const rows = await fetchApifyDataset(datasetId, maxItems);
  return { rows, costUsd, runId: run.id };
}