← back to Govarbitrage

src/importers/grays-free.ts

89 lines

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

// FREE GraysOnline (Australia) importer via its public Algolia search index —
// harvested App-Id + search key from the site's client (feed-first). $0, no
// scraping. Grays prices are AUD; converted to USD so the valuation engine stays
// coherent (rough fixed rate — refine with a live FX feed if needed).

const APP_ID = process.env.GRAYS_ALGOLIA_APP_ID || "CKPAMVUUBE";
const API_KEY = process.env.GRAYS_ALGOLIA_KEY || "1a31357659d5c40f4641cc0d46c172d0";
const INDEX = "GOL_MAIN";
const AUD_TO_USD = Number(process.env.AUD_USD || 0.66);

interface Hit {
  objectID?: string;
  ObjectTitle?: string;
  ObjectType?: string;
  Category?: string;
  ParentCategory?: string;
  CONDITION?: string;
  UnitQuantity?: number;
  ObjectPrice?: number;
  BidActionCount?: number;
  LOT_DEFAULT_END_TIME?: number;
  LOT_SKU?: string;
  LOT_ID?: number;
  ITEM_URL?: string;
  ImageURLS?: string[];
  SaleSuburb?: string;
  SaleState?: string;
}

function mapHit(h: Hit): RawListing | null {
  if (!h.ObjectTitle || !(h.LOT_SKU || h.LOT_ID)) return null;
  const closing = h.LOT_DEFAULT_END_TIME ? new Date(h.LOT_DEFAULT_END_TIME * 1000).toISOString() : null;
  return {
    source: "GRAYS_AU",
    sourceAuctionId: String(h.LOT_SKU || h.LOT_ID),
    sourceUrl: h.ITEM_URL ? `https://www.grays.com${h.ITEM_URL}` : "https://www.grays.com",
    title: h.ObjectTitle.trim(),
    category: h.Category || h.ParentCategory || undefined,
    condition: (h.CONDITION || "").toUpperCase().includes("NEW") ? "NEW" : "UNKNOWN",
    quantity: h.UnitQuantity && h.UnitQuantity > 0 ? Math.floor(h.UnitQuantity) : 1,
    locationCity: h.SaleSuburb,
    locationState: h.SaleState,
    // AUD → USD so downstream USD math is coherent.
    currentBid: h.ObjectPrice ? Math.round(h.ObjectPrice * AUD_TO_USD * 100) / 100 : 0,
    bidCount: h.BidActionCount ?? 0,
    closingAt: closing,
    imageUrls: Array.isArray(h.ImageURLS) ? h.ImageURLS.slice(0, 6) : [],
    auctionTerms: "GraysOnline (AU). Prices shown converted AUD→USD; freight from Australia applies.",
  };
}

/** Fetch newest Grays lots via Algolia, paging until `limit`. Free, $0. */
export async function fetchGraysFree(opts: { limit?: number } = {}): Promise<RawListing[]> {
  const limit = opts.limit ?? 100;
  const perPage = 100;
  const out: RawListing[] = [];
  for (let page = 0; out.length < limit && page < 20; page++) {
    const res = await fetch(`https://${APP_ID}-dsn.algolia.net/1/indexes/*/queries`, {
      method: "POST",
      headers: {
        "X-Algolia-Application-Id": APP_ID,
        "X-Algolia-API-Key": API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        requests: [
          {
            indexName: INDEX,
            query: "",
            params: `hitsPerPage=${perPage}&page=${page}&filters=${encodeURIComponent("ObjectType:LOT OR ObjectType:RETAIL")}`,
          },
        ],
      }),
    });
    if (!res.ok) throw new Error(`Grays Algolia ${res.status}: ${(await res.text()).slice(0, 160)}`);
    const j = (await res.json()) as { results?: { hits?: Hit[] }[] };
    const hits = j.results?.[0]?.hits ?? [];
    if (!hits.length) break;
    for (const h of hits) {
      const m = mapHit(h);
      if (m) out.push(m);
    }
    if (hits.length < perPage) break;
  }
  return out.slice(0, limit);
}