import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import type { CurrencyCode, NormalizedRecord } from '@rareindex/shared'; import { amount, apolloRef, certFromTitle, hibidApolloState, isBundleTitle, isoDate, lotAttributes, makeSale, safeYear, saleGrade } from '../_g7-auctions-na-lib/index.js'; import { hibidCategory, isFirearm } from './categories.js'; const BASE = 'https://hibid.com'; const PARSER_VERSION = '1.0.0'; const PAST_PAGE_LENGTH = 25; const ALLOWED_CURRENCIES = new Set(['USD', 'CAD', 'GBP', 'EUR', 'AUD']); export const AuctioneerSchema = z.object({ id: z.string().nullable(), name: z.string(), city: z.string().nullable(), state: z.string().nullable(), country: z.string().nullable() }); export const AuctionSchema = z.object({ id: z.string(), eventName: z.string(), url: z.string(), bidCloseDateTime: z.string().nullable(), eventDateEnd: z.string().nullable(), currency: z.string().nullable(), buyerPremium: z.string().nullable(), buyerPremiumRate: z.number().nullable(), auctioneer: AuctioneerSchema, }); export const LotSchema = z.object({ id: z.string(), lotNumber: z.string().nullable(), title: z.string(), description: z.string().nullable(), estimateText: z.string().nullable(), image: z.string().nullable(), categoryPath: z.string().nullable(), categoryName: z.string().nullable(), priceRealized: z.number().nullable(), quantitySold: z.number().nullable(), quantity: z.number().nullable(), bidCount: z.number().nullable(), isClosed: z.boolean(), url: z.string(), }); export const PayloadSchema = z.object({ kind: z.literal('hibid_catalog_page'), auction: AuctionSchema, page: z.number(), totalCount: z.number().nullable(), lots: z.array(LotSchema) }); export type Payload = z.infer; export type ParsedLot = z.infer & { mappable: boolean }; type Cache = Record>; export function slugify(s: string): string { return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 90); } function str(v: unknown): string | null { return typeof v === 'string' && v.trim() ? v.trim() : null; } function rootEntries(cache: Cache, prefix: string): Array> { const root = cache['ROOT_QUERY'] ?? {}; return Object.entries(root) .filter(([k]) => k.startsWith(prefix)) .map(([, v]) => apolloRef>(cache, v)) .filter((v): v is Record => v !== null); } export interface PastAuctionRef { id: string; eventName: string; url: string; bidCloseDateTime: string | null; eventDateEnd: string | null; lotCount: number | null; status: string | null; } /** /auctions/past?apage=N → archived auction references + paging. */ export function parsePastAuctions(html: string): { auctions: PastAuctionRef[]; pageNumber: number | null; totalCount: number | null; pageLength: number | null } | null { const cache = hibidApolloState(html); if (!cache) return null; const search = rootEntries(cache, 'auctionSearch(')[0]; const paged = search ? apolloRef>(cache, search.pagedResults) : null; const results = Array.isArray(paged?.results) ? (paged!.results as unknown[]) : []; const auctions: PastAuctionRef[] = []; for (const r of results) { const match = apolloRef>(cache, r); const a = apolloRef>(cache, match?.auction ?? match); if (!a || typeof a.id !== 'number') continue; const name = str(a.eventName) ?? `Auction ${a.id}`; const state = apolloRef>(cache, a.auctionState); auctions.push({ id: String(a.id), eventName: name, url: `${BASE}/catalog/${a.id}/${slugify(name)}`, bidCloseDateTime: str(a.bidCloseDateTime), eventDateEnd: str(a.eventDateEnd), lotCount: typeof a.lotCount === 'number' ? a.lotCount : null, status: str(state?.auctionStatus) }); } return { auctions, pageNumber: typeof paged?.pageNumber === 'number' ? paged.pageNumber : null, totalCount: typeof paged?.totalCount === 'number' ? paged.totalCount : null, pageLength: typeof paged?.pageLength === 'number' ? paged.pageLength : null }; } /** /catalog//?apage=N → auction header + every lot on the page (with a `mappable` flag). */ export function parseCatalog(html: string, page: number): { auction: Payload['auction']; lots: ParsedLot[]; pageNumber: number | null; totalCount: number | null; pageLength: number | null } | null { const cache = hibidApolloState(html); if (!cache) return null; const auctionRaw = rootEntries(cache, 'auction(')[0] ?? Object.entries(cache).find(([k]) => k.startsWith('Auction:'))?.[1] ?? null; if (!auctionRaw || typeof auctionRaw.id !== 'number') return null; const auctioneer = apolloRef>(cache, auctionRaw.auctioneer); const name = str(auctionRaw.eventName) ?? `Auction ${auctionRaw.id}`; const auction: Payload['auction'] = { id: String(auctionRaw.id), eventName: name, url: `${BASE}/catalog/${auctionRaw.id}/${slugify(name)}`, bidCloseDateTime: str(auctionRaw.bidCloseDateTime), eventDateEnd: str(auctionRaw.eventDateEnd), currency: str(auctionRaw.currencyAbbreviation), buyerPremium: str(auctionRaw.buyerPremium), buyerPremiumRate: typeof auctionRaw.buyerPremiumRate === 'number' ? auctionRaw.buyerPremiumRate : null, auctioneer: { id: auctioneer && auctioneer.id !== undefined ? String(auctioneer.id) : null, name: str(auctioneer?.name) ?? 'HiBid auctioneer', city: str(auctioneer?.city), state: str(auctioneer?.state), country: str(auctioneer?.country) }, }; const search = rootEntries(cache, 'lotSearch(')[0]; const paged = search ? apolloRef>(cache, search.pagedResults) : null; const refs = Array.isArray(paged?.results) ? (paged!.results as unknown[]) : Object.keys(cache).filter((k) => k.startsWith('Lot:')).map((k) => ({ __ref: k })); const lots: ParsedLot[] = []; const seen = new Set(); for (const r of refs) { const l = apolloRef>(cache, r); if (!l || typeof l.id !== 'number' || seen.has(String(l.id))) continue; seen.add(String(l.id)); const title = str(l.lead) ?? str((apolloRef>(cache, l.featuredPicture) ?? {}).description); if (!title) continue; const state = apolloRef>(cache, l.lotState) ?? {}; const cats = (Array.isArray(l.category) ? l.category : []).map((c) => apolloRef>(cache, c)).filter((c): c is Record => c !== null); // Most specific category first (longest fullCategory path). cats.sort((a, b) => String(b.fullCategory ?? '').length - String(a.fullCategory ?? '').length); const categoryPath = str(cats[0]?.fullCategory); const categoryName = str(cats[0]?.categoryName); const picture = apolloRef>(cache, l.featuredPicture); const description = str(l.description); const slug = hibidCategory(categoryPath, title); const mappable = slug !== null && !isFirearm(`${categoryPath ?? ''} ${title} ${description ?? ''}`); lots.push({ id: String(l.id), lotNumber: str(l.lotNumber), title, description: description ? description.slice(0, 1500) : null, estimateText: str(l.estimate), image: str(picture?.fullSizeLocation) ?? str(picture?.hdThumbnailLocation), categoryPath, categoryName, priceRealized: amount(state.priceRealized), quantitySold: typeof state.quantitySold === 'number' ? state.quantitySold : null, quantity: typeof l.quantity === 'number' ? l.quantity : null, bidCount: typeof state.bidCount === 'number' ? state.bidCount : null, isClosed: state.isClosed === true || state.isArchived === true, url: `${BASE}/lot/${l.id}/${slugify(title)}`, mappable, }); } return { auction, lots, pageNumber: typeof paged?.pageNumber === 'number' ? paged.pageNumber : null, totalCount: typeof paged?.totalCount === 'number' ? paged.totalCount : null, pageLength: typeof paged?.pageLength === 'number' ? paged.pageLength : null }; } /** * HiBid — public past catalogs of thousands of regional auctioneers. Plain HTTPS on hibid.com; the lot * data is read from the Apollo state the public Angular page embeds (`