import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedSaleSchema, type NormalizedRecord } from '@rareindex/shared'; import { dateWords } from '../_carlib/index.js'; /** * Hart Davis Hart prices realized (PDF per auction, parsed to markdown tables by Firecrawl). * One raw record per PDF (compact rows); one sale per lot with a hammer price. */ const PARSER_VERSION = '1.0.0'; export const RowSchema = z.object({ lot: z.string(), qty: z.number().nullable(), description: z.string(), estimate: z.string().nullable(), hammer: z.number().nullable(), aggregate: z.number().nullable() }); export type Row = z.infer; export const PdfPayloadSchema = z.object({ kind: z.literal('results_pdf'), url: z.string(), auctionName: z.string().nullable(), saleDate: z.string().nullable(), rows: z.array(RowSchema) }); export type PdfPayload = z.infer; const num = (s: string | undefined): number | null => { const v = Number((s ?? '').replace(/[,$\s]/g, '')); return Number.isFinite(v) && v > 0 ? v : null; }; /** Archive page markdown → [{ pdfUrl, title, dateText }] newest first. */ export function parseArchive(md: string): Array<{ pdfUrl: string; title: string | null; dateText: string | null }> { const out: Array<{ pdfUrl: string; title: string | null; dateText: string | null }> = []; const seen = new Set(); for (const m of md.matchAll(/\[View auction results\]\((https:\/\/hdhauctions\.com\/wp-content\/uploads\/[^)\s]+\.pdf)\)/gi)) { const url = m[1]!; if (seen.has(url)) continue; seen.add(url); const before = md.slice(Math.max(0, m.index! - 1200), m.index); const title = [...before.matchAll(/\*\*([^*\n]{6,120})\*\*/g)].map((x) => x[1]!.trim()).find((t) => !/SOLD|\$|estimate/i.test(t)) ?? null; const dateText = [...before.matchAll(/([A-Z][a-z]+ \d{1,2}(?:\s*[-–&]\s*\d{1,2})?,? \d{4})/g)].at(-1)?.[1] ?? null; out.push({ pdfUrl: url, title, dateText }); } return out; } /** PDF markdown → header facts + table rows (Lot | Qty | Description | Estimate | Hammer | Aggregate). */ export function parseResultsPdf(md: string): { auctionName: string | null; saleDate: string | null; rows: Row[] } { const rows: Row[] = []; for (const line of md.split('\n')) { const m = line.match(/^\|\s*(\d+[A-Z]?)\s*\|\s*(\d*)\s*\|\s*(.+?)\s*\|\s*([\d,]*\s*-?\s*[\d,]*)\s*\|\s*([\d,]*)\s*\|\s*([\d,.]*)\s*\|/); if (!m) continue; const description = m[3]!.replace(/\\/g, '').trim(); if (!description || /^Description$/i.test(description)) continue; rows.push({ lot: m[1]!, qty: m[2] ? Number(m[2]) : null, description, estimate: m[4]?.trim() || null, hammer: num(m[5]), aggregate: num(m[6]) }); } const header = md.slice(0, 4000); const dateText = header.match(/([A-Z][a-z]+ \d{1,2}(?:\s*[-–]\s*\d{1,2})?,? \d{4})/)?.[1] ?? null; const saleDate = dateText ? dateWords(dateText)?.toISOString() ?? null : null; const auctionName = header.match(/^#+\s*(.+)$/m)?.[1]?.replace(/Aggregate:.*$/, '').trim() || null; return { auctionName, saleDate, rows }; } const SIZE_RE = /\((\d+(?:\.\d+)?\s?(?:ml|L|l|cl))\)/; /** "2010 Château Ducru-Beaucaillou" → vintage, producer/name, bottle size. */ export function wineFacts(description: string): { vintage: number | null; name: string; size: string; producer: string | null; categorySlug: 'wine' | 'whisky' | 'cognac' } { const vintage = Number(description.match(/^((?:18|19|20)\d{2})\b/)?.[1]) || null; const sizeM = description.match(SIZE_RE); const size = sizeM ? sizeM[1]!.replace(/\s/g, '') : '750ml'; const name = description.replace(/^(?:18|19|20)\d{2}\s+/, '').replace(SIZE_RE, '').replace(/\s+/g, ' ').trim(); const producer = name.includes(',') ? name.split(',').at(-1)!.trim() : name.split(/\s+/).slice(0, 3).join(' '); const categorySlug = /whisky|whiskey|scotch|bourbon/i.test(description) ? 'whisky' : /cognac|armagnac/i.test(description) ? 'cognac' : 'wine'; return { vintage, name, size, producer: producer || null, categorySlug }; } export class HdhWineConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 3000; async *crawl(ctx: CrawlContext): AsyncIterable { const archiveUrl = String(this.meta.config.archiveUrl ?? 'https://hdhauctions.com/auction-archives/'); const perRun = Number(this.meta.config.pdfsPerRun ?? 1); const done = new Set((ctx.options.cursor?.done as string[] | undefined) ?? []); const archive = await ctx.fetch(archiveUrl, { engines: ['firecrawl'], expect: ['title'], parse: (r) => (r.markdown ? { title: parseArchive(r.markdown)[0]?.pdfUrl ?? null } : null) }); const entries = archive.success && archive.markdown ? parseArchive(archive.markdown) : []; if (!entries.length) { ctx.anomaly('page_fetch_failed', `archive: ${archive.error ?? archive.httpStatus}`); return; } const order = ctx.options.mode === 'backfill' ? [...entries].reverse() : entries; let count = 0; for (const e of order.filter((x) => !done.has(x.pdfUrl)).slice(0, perRun)) { if (ctx.signal?.aborted || this.reached(ctx, count)) break; await this.throttle(); const res = await ctx.fetch(e.pdfUrl, { engines: ['firecrawl'], timeoutMs: 180_000, expect: ['title', 'price', 'date'], parse: (r) => { const p = r.markdown ? parseResultsPdf(r.markdown) : null; const sold = p?.rows.find((x) => x.hammer); return p && p.rows.length ? { title: sold?.description ?? p.rows[0]!.description, price: sold?.hammer ?? null, date: p.saleDate ?? e.dateText } : null; } }); const parsed = res.success && res.markdown ? parseResultsPdf(res.markdown) : null; if (!parsed || !parsed.rows.length) { ctx.anomaly(parsed ? 'empty_page' : 'page_fetch_failed', `${e.pdfUrl}: ${res.error ?? res.httpStatus}`); continue; } const saleDate = parsed.saleDate ?? (e.dateText ? dateWords(e.dateText)?.toISOString() ?? null : null); count++; yield { url: e.pdfUrl, externalId: `pdf:${e.pdfUrl.split('/').pop()}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'results_pdf' as const, url: e.pdfUrl, auctionName: e.title ?? parsed.auctionName, saleDate, rows: parsed.rows }, fetchedAt: res.fetchedAt }; done.add(e.pdfUrl); await ctx.setCursor({ done: [...done], updatedAt: new Date().toISOString() }); } } async normalize(raw: RawRecordLike): Promise { const p = PdfPayloadSchema.parse(raw.payload); if (!p.saleDate) return []; const saleDate = new Date(p.saleDate); const pdfName = p.url.split('/').pop()!.replace(/\.pdf$/i, ''); // Group multi-row lots: the first row of a lot carries the prices; following rows list the other wines. const groups = new Map(); for (const r of p.rows) (groups.get(r.lot) ?? groups.set(r.lot, []).get(r.lot)!).push(r); const out: NormalizedRecord[] = []; for (const [lot, rows] of groups) { const head = rows.find((r) => r.hammer) ?? rows[0]!; if (!head.hammer || head.hammer <= 0) continue; const f = wineFacts(head.description); const bundle = rows.length > 1; const title = bundle ? `${head.description} (+${rows.length - 1} more)` : head.description; const attributes = AssetAttributesSchema.parse({ categorySlug: f.categorySlug, brand: f.producer, name: f.name, year: f.vintage, size: f.size, identifiers: { hdh_lot: `${pdfName}:${lot}` }, metadata: { auction: p.auctionName, estimate: head.estimate, aggregate_usd: head.aggregate, bottles: head.qty, lines: rows.map((r) => `${r.qty ?? ''} × ${r.description}`.trim()) }, }); out.push( NormalizedSaleSchema.parse({ kind: 'sale', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: `${p.url}#lot-${lot}`, externalId: `${pdfName}:${lot}`, rawTitle: title, imageUrls: [], attributes, grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, condition: { condition: null, conditionRaw: null, completeness: null }, observedAt: raw.fetchedAt, confidence: 0.92, parserVersion: PARSER_VERSION, saleType: 'auction', saleDate, price: head.hammer, currency: 'USD', buyerPremiumIncluded: false, quantity: head.qty ?? 1, isBundle: bundle || (head.qty ?? 1) > 1, location: 'Chicago, IL, United States', auctionHouse: 'Hart Davis Hart Wine Co.', lotNumber: lot, }), ); } return out; } } export default function createConnector(meta: ConnectorMeta): HdhWineConnector { return new HdhWineConnector(meta); }