import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared'; import { makeSale } from '../../firecrawl/_carlib/index.js'; import { isNumisBundle, numisAttributes, numisCategory } from '../../firecrawl/_g5-numismatics-lib/index.js'; /** * David Lawrence Rare Coins — sold auction lots from the public Collectibles Showcase JSON API that the * davidlawrence.com SPA itself calls (no key: the tenant id is resolved from /consumer/client?url=…). * Every lot carries grading service, grade, certification number, PCGS number and sale price in cents. * DLRC states "DLRC has no buyer's fee" → the sale price is the full amount paid. */ const API = 'https://api.collectiblesshowcase.com'; const SITE = 'https://www.davidlawrence.com'; const PARSER_VERSION = '1.0.0'; const PAGE_SIZE = 100; export const LotSchema = z.object({ id: z.number(), auctionId: z.number().nullable(), lotId: z.number().nullable(), lotNumber: z.number().nullable(), totalBids: z.number().nullable().optional(), lotCurrentBid: z.number().nullable().optional(), lotClosesAt: z.string().nullable(), soldInAuction: z.boolean().nullable().optional(), itemType: z.string().nullable().optional(), name: z.string(), shortDescription: z.string().nullable().optional(), description: z.string().nullable().optional(), seriesName: z.string().nullable().optional(), groupName: z.string().nullable().optional(), categoryName: z.string().nullable().optional(), certificationNumber: z.string().nullable().optional(), gradingService: z.string().nullable().optional(), grade: z.number().nullable().optional(), fullGrade: z.string().nullable().optional(), isPlus: z.boolean().nullable().optional(), isCac: z.boolean().nullable().optional(), isEPQ: z.boolean().nullable().optional(), price: z.number().nullable().optional(), salePrice: z.number().nullable().optional(), status: z.string().nullable().optional(), catalogEntry: z .object({ id: z.number().nullable().optional(), pcgsNumber: z.number().nullable().optional(), title: z.string().nullable().optional(), denomination: z.string().nullable().optional(), coinDate: z.number().nullable().optional(), mintMark: z.string().nullable().optional(), designation: z.string().nullable().optional(), majorVariety: z.string().nullable().optional(), dieVariety: z.string().nullable().optional(), strikeType: z.string().nullable().optional(), mintage: z.number().nullable().optional(), numistaTypeId: z.union([z.number(), z.string()]).nullable().optional(), kmNumber: z.string().nullable().optional(), categoryName: z.string().nullable().optional(), }) .nullable() .optional(), images: z.array(z.object({ url: z.string().nullable().optional(), thumbnailUrl: z.string().nullable().optional() })).default([]), }); export type Lot = z.infer; export const PayloadSchema = z.object({ kind: z.literal('past_auction_page'), url: z.string(), auctionId: z.number().nullable(), auctionTitle: z.string().nullable(), pageNumber: z.number().int(), totalPages: z.number().int().nullable(), totalItems: z.number().int().nullable(), lots: z.array(LotSchema), }); export type Payload = z.infer; const ApiPage = z.object({ pagination: z.object({ totalItemsCount: z.number(), pageSize: z.number(), pageNumber: z.number(), totalPages: z.number() }).nullable(), payload: z.array(z.unknown()) }); const PastAuctions = z.object({ payload: z.array(z.object({ id: z.number(), title: z.string(), estimatedClosesAt: z.string().nullable(), totalLotCount: z.number().nullable() })) }); /** "1793 1/2C NGC/CAC Poor 01" + structured fields → grader slug, grade token. */ export function dlrcGrade(lot: Pick): { grader: string | null; grade: string | null; qualifier: string | null; certificationNumber: string | null } { const svc = lot.gradingService?.toUpperCase() ?? null; const grader = svc && /^(PCGS|NGC|ANACS|ICCS|PMG|ICG|CAC)$/.test(svc) ? svc.toLowerCase() : svc ? svc.toLowerCase() : null; let grade = lot.fullGrade ?? (lot.grade ? String(lot.grade) : null); if (grade && lot.isPlus && !grade.includes('+')) grade = grade.replace(/^([A-Z]+\d{1,2})/, '$1+'); const details = /details/i.test(lot.name) ? lot.name.match(/\b(\w+ Details(?: \([^)]*\))?)/i)?.[1] ?? 'Details' : null; const qualifier = [lot.isCac ? 'CAC' : null, details].filter(Boolean).join(' · ') || null; return { grader, grade, qualifier, certificationNumber: lot.certificationNumber ?? null }; } interface Cursor { doneAuctions?: number[]; inProgress?: { auctionId: number | null; title: string | null; pageNumber: number; totalPages: number | null } | null; /** backfill over the unfiltered archive (includes pre-2023 legacy lots without an auction id) */ archivePage?: number; done?: boolean; updatedAt?: string; } export class DlrcConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1500; override readonly urlPatterns = [/davidlawrence\.com\//i]; private clientId: string | null = null; private async headers(ctx: CrawlContext): Promise | null> { if (this.clientId) return { 'x-client-id': this.clientId }; const configured = this.meta.config.clientId; if (configured) this.clientId = String(configured); else { await this.throttle(); const res = await ctx.fetch(`${API}/consumer/client?url=${encodeURIComponent(SITE)}`, { engines: ['api'], minQuality: 0, force: true }); const id = (res.json as { payload?: { id?: number } } | null)?.payload?.id; if (!res.success || !id) { ctx.anomaly('page_fetch_failed', `client id lookup: ${res.error ?? res.httpStatus}`); return null; } this.clientId = String(id); } return { 'x-client-id': this.clientId }; } async *crawl(ctx: CrawlContext): AsyncIterable { const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 2); const pagesPerRun = Number(this.meta.config.pagesPerRun ?? 40); const cursor = { ...((ctx.options.cursor ?? {}) as Cursor) }; const headers = await this.headers(ctx); if (!headers) return; let pages = 0; let yielded = 0; const fetchLots = async (params: Record) => { const q = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)] as [string, string])); const url = `${API}/consumer/inventory/past/auction?${q}`; await this.throttle(); const res = await ctx.fetch(url, { engines: ['api'], headers, expect: ['title', 'price', 'identifiers'], parse: (r) => { const first = (r.json as { payload?: Array> } | null)?.payload?.[0]; return first ? { title: first.name, price: first.salePrice, identifiers: first.certificationNumber ? { cert: first.certificationNumber } : null } : null; } }); pages++; if (!res.success || !res.json) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); return null; } const parsed = ApiPage.safeParse(res.json); if (!parsed.success) { ctx.anomaly('schema_drift', `${url}: ${parsed.error.issues[0]?.message}`); return null; } return { url, res, page: parsed.data }; }; if (ctx.options.mode === 'backfill') { // Whole archive, catalogue order (includes legacy 2016+ sales without an auction id). let pageNumber = Math.max(1, cursor.archivePage ?? 1); let totalPages: number | null = null; while (pages < pagesPerRun && !ctx.signal?.aborted && !this.reached(ctx, yielded)) { const got = await fetchLots({ pageNumber, pageSize: PAGE_SIZE }); if (!got) break; totalPages = got.page.pagination?.totalPages ?? totalPages; const lots = got.page.payload; if (lots.length) { yielded++; const payload: Payload = { kind: 'past_auction_page', url: got.url, auctionId: null, auctionTitle: null, pageNumber, totalPages, totalItems: got.page.pagination?.totalItemsCount ?? null, lots: lots.map((l) => LotSchema.parse(l)) }; yield { url: got.url, externalId: `archive:p${pageNumber}`, kind: 'sale', engine: got.res.engine, httpStatus: got.res.httpStatus, payload, fetchedAt: got.res.fetchedAt }; } const finished = !lots.length || (totalPages !== null && pageNumber >= totalPages); pageNumber++; await ctx.progress({ page: pageNumber - 1, totalPages, itemsProcessed: yielded }); await ctx.setCursor({ ...cursor, archivePage: pageNumber, done: finished, updatedAt: new Date().toISOString() }); if (finished) return; } return; } // Incremental: newest closed auctions not yet done, lots filtered per auction. await this.throttle(); const list = await ctx.fetch(`${API}/consumer/auction/past`, { engines: ['api'], headers, minQuality: 0 }); pages++; const parsedList = list.success ? PastAuctions.safeParse(list.json) : null; if (!parsedList?.success) { ctx.anomaly('page_fetch_failed', `/consumer/auction/past: ${list.error ?? list.httpStatus}`); return; } const done = new Set(cursor.doneAuctions ?? []); const auctions = [...parsedList.data.payload].sort((a, b) => (b.estimatedClosesAt ?? '').localeCompare(a.estimatedClosesAt ?? '')); const queue = [...(cursor.inProgress ? [cursor.inProgress] : []), ...auctions.filter((a) => !done.has(a.id) && a.id !== cursor.inProgress?.auctionId).map((a) => ({ auctionId: a.id as number | null, title: a.title as string | null, pageNumber: 1, totalPages: null as number | null }))]; let finished = 0; for (const state of queue) { if (ctx.signal?.aborted || finished >= auctionsPerRun || pages >= pagesPerRun || this.reached(ctx, yielded)) break; let complete = false; while (pages < pagesPerRun && !ctx.signal?.aborted && !this.reached(ctx, yielded)) { const got = await fetchLots({ auctions: state.auctionId ?? '', pageNumber: state.pageNumber, pageSize: PAGE_SIZE }); if (!got) break; state.totalPages = got.page.pagination?.totalPages ?? state.totalPages; const lots = got.page.payload; if (lots.length) { yielded++; const payload: Payload = { kind: 'past_auction_page', url: got.url, auctionId: state.auctionId, auctionTitle: state.title, pageNumber: state.pageNumber, totalPages: state.totalPages, totalItems: got.page.pagination?.totalItemsCount ?? null, lots: lots.map((l) => LotSchema.parse(l)) }; yield { url: got.url, externalId: `auction:${state.auctionId}:p${state.pageNumber}`, kind: 'sale', engine: got.res.engine, httpStatus: got.res.httpStatus, payload, fetchedAt: got.res.fetchedAt }; } if (!lots.length || (state.totalPages !== null && state.pageNumber >= state.totalPages)) { complete = true; break; } state.pageNumber++; cursor.inProgress = state; await ctx.setCursor({ ...cursor, doneAuctions: [...done].slice(-400), updatedAt: new Date().toISOString() }); } if (complete) { if (state.auctionId !== null) done.add(state.auctionId); finished++; cursor.inProgress = null; } else cursor.inProgress = state; await ctx.setCursor({ ...cursor, doneAuctions: [...done].slice(-400), updatedAt: new Date().toISOString() }); } } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const out: NormalizedSale[] = []; for (const lot of p.lots) { const cents = lot.salePrice ?? (lot.soldInAuction ? lot.lotCurrentBid ?? null : null); if (!cents || cents <= 0 || (lot.status && lot.status !== 'SOLD' && !lot.soldInAuction)) continue; const closes = lot.lotClosesAt ? new Date(lot.lotClosesAt) : null; if (!closes || Number.isNaN(closes.getTime())) continue; const saleDate = new Date(Date.UTC(closes.getUTCFullYear(), closes.getUTCMonth(), closes.getUTCDate())); const ce = lot.catalogEntry ?? null; const categorySlug = lot.itemType === 'CURRENCY' || /currency|banknote|paper money/i.test(`${lot.categoryName ?? ''} ${lot.groupName ?? ''}`) ? 'banknotes' : numisCategory(lot.name, lot.categoryName ?? lot.groupName, 'coins'); const g = dlrcGrade(lot); const identifiers: Record = { dlrc_inventory_id: String(lot.id) }; if (ce?.pcgsNumber) identifiers.pcgs_number = String(ce.pcgsNumber); if (g.certificationNumber && g.grader) identifiers[`${g.grader}_cert`] = g.certificationNumber; if (ce?.numistaTypeId) identifiers.numista_id = String(ce.numistaTypeId); const attributes = numisAttributes({ categorySlug, title: lot.name, section: lot.seriesName ?? lot.groupName ?? null, series: lot.seriesName ?? null, country: lot.itemType === 'US_COIN' || lot.categoryName === 'U.S. Coins' ? 'US' : undefined, identifiers, metadata: { auction_id: lot.auctionId, auction_title: p.auctionTitle, lot_id: lot.lotId, item_type: lot.itemType, group: lot.groupName, category: lot.categoryName, catalog_title: ce?.title ?? null, designation: ce?.designation ?? null, strike_type: ce?.strikeType ?? null, major_variety: ce?.majorVariety || null, die_variety: ce?.dieVariety || null, mintage: ce?.mintage ?? null, total_bids: lot.totalBids ?? null, is_cac: lot.isCac ?? null, buyer_premium: "none — 'DLRC has no buyer's fee' (site notice); sale price is the total paid", }, }); if (ce?.coinDate && !attributes.year) attributes.year = ce.coinDate; if (ce?.mintMark && !attributes.variant) attributes.variant = `${ce.mintMark} mint`; if (ce?.mintMark) attributes.metadata.mint_mark = ce.mintMark; if (ce?.denomination) attributes.metadata.denomination = ce.denomination; const sale = makeSale({ meta: this.meta, sourceUrl: `${SITE}/inventory/${lot.id}`, externalId: String(lot.id), rawTitle: lot.name, description: [lot.shortDescription, lot.description].filter(Boolean).join(' — ') || null, attributes, price: cents / 100, currency: 'USD', saleDate, buyerPremiumIncluded: true, auctionHouse: 'David Lawrence Rare Coins', lotNumber: lot.lotNumber !== null ? String(lot.lotNumber) : null, imageUrls: lot.images.map((i) => i.url ?? i.thumbnailUrl).filter((u): u is string => Boolean(u)).slice(0, 4), observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, confidence: g.certificationNumber ? 0.95 : 0.85, isBundle: isNumisBundle(lot.name), location: 'US', }); sale.grade = g; out.push(sale); } return out; } } export default (meta: ConnectorMeta) => new DlrcConnector(meta);