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 { dateWords, lotAttributes, makeSale } from '../../firecrawl/_carlib/index.js'; import { safeYear } from '../_auction-lib/categories.js'; /** * Daniel F. Kelleher Auctions โ€” prices realized hosted on Stamp Auction Network (SAN). * Plain HTTPS. See meta.json accessNotes. */ const SAN = 'https://StampAuctionNetwork.com'; const PARSER_VERSION = '1.0.0'; const MON: Record = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }; export const SaleSchema = z.object({ sale: z.string(), title: z.string(), dateText: z.string().nullable(), url: z.string() }); export const LotSchema = z.object({ lotNumber: z.string(), catNo: z.string().nullable(), headline: z.string().nullable(), description: z.string(), image: z.string().nullable(), soldText: z.string().nullable(), closedText: z.string().nullable(), soldFor: z.string().nullable(), }); export const PayloadSchema = z.object({ kind: z.literal('category_page'), sale: SaleSchema, majorGroup: z.string(), category: z.string(), url: z.string(), lots: z.array(LotSchema) }); export type Payload = z.infer; function clean(s: string): string { return s.replace(/&/g, '&').replace(/¢/g, 'ยข').replace(/�?39;|'/g, "'").replace(/"/g, '"').replace(/ /g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } /** * Firm page (Kelleher.cfm) or the SAN archive (pr.cfm) โ†’ Kelleher sales in the cCatalog format. * When the page has a "PricesRealized" anchor only the section after it is used (closed sales). */ export function parseFirmPage(html: string): z.infer[] { const out: z.infer[] = []; const anchor = html.search(/NAME\s*=\s*"PricesRealized"/i); const scope = anchor >= 0 ? html.slice(anchor) : html; const re = /Kelleher Auctions[^<]{0,60}(?:<\/strong>)?\s*(?:<\/font>)?\s*\s*([\s\S]*?)<\/A>/gi; let m: RegExpExecArray | null; while ((m = re.exec(scope))) { if (out.some((s) => s.sale === m![2])) continue; const label = clean(m[3]!); const dm = label.match(/-\s*([A-Za-z]+ \d{1,2}(?:\s*[-โ€“]\s*\d{1,2})?,\s*\d{4})\s*$/); out.push({ sale: m[2]!, title: dm ? label.slice(0, dm.index).trim() : label, dateText: dm ? dm[1]! : null, url: m[1]!.replace(/&/g, '&') }); } return out; } export function parseGroupLinks(html: string, level: 2 | 3): string[] { const re = level === 2 ? /href="(cCatalog2\.cfm\?[^"]+)"/gi : /href="(https?:\/\/StampAuctionNetwork\.com\/cCatalog3\.cfm\?[^"]+)"/gi; const out: string[] = []; let m: RegExpExecArray | null; while ((m = re.exec(html))) { const u = (m[1]!.startsWith('http') ? m[1]! : `${SAN}/${m[1]!}`).replace(/&/g, '&'); if (!out.includes(u)) out.push(u); } return out; } export function parseCategoryPage(html: string, sale: z.infer, url: string): Payload { const q = new URL(url).searchParams; const majorGroup = q.get('MAJGROUP') ?? ''; const category = q.get('CATDESCR') ?? ''; const lots: z.infer[] = []; const tables = html.split(/]*>([\s\S]*?)<\/td>/i)?.[1] ?? ''; const headline = descCell.match(/([\s\S]*?)<\/B>/i)?.[1]; const description = clean(descCell.replace(/]*>[\s\S]*?<\/a>/gi, ' ').replace(/Suggested Bid.*$/i, '')); lots.push({ lotNumber, catNo: t.match(/Cat No:\s*([^<\n]+)/)?.[1]?.trim() ?? null, headline: headline ? clean(headline) : null, description, image: t.match(/SRC="(https?:\/\/StampAuctionNetwork\.com\/Photos\/[^"]+)"/i)?.[1] ?? null, soldText: t.match(/Sold\.{2,}\s*([A-Z$]+\s?[\d,]+(?:\.\d{2})?)/)?.[1]?.trim() ?? null, closedText: t.match(/Closed\.{2,}\s*([A-Za-z]{3}-\d{1,2}-\d{4})/)?.[1] ?? null, soldFor: t.match(/Sold For\s+([\d,]+(?:\.\d{2})?)/)?.[1] ?? null, }); } return { kind: 'category_page', sale, majorGroup, category, url, lots }; } export function closedDate(s: string | null): Date | null { const m = s?.match(/([A-Za-z]{3})-(\d{1,2})-(\d{4})/); if (!m) return null; const mo = MON[m[1]!.toLowerCase()]; if (mo === undefined) return null; return new Date(Date.UTC(Number(m[3]), mo, Number(m[2]))); } export function kelleherCategory(majorGroup: string, text: string): string { const t = `${majorGroup} ${text}`.toLowerCase(); if (/banknote|bank note|paper money|currency/.test(t)) return 'banknotes'; if (/\bcoins?\b|numismat|medal\b/.test(t) && !/stamp|cover|postal/.test(t)) return 'coins'; return 'stamps'; } export class KelleherConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; async *crawl(ctx: CrawlContext): AsyncIterable { const salesPerRun = Number(this.meta.config.salesPerRun ?? 1); const pagesPerRun = Number(this.meta.config.categoryPagesPerRun ?? 30); const cursor = (ctx.options.cursor ?? {}) as { doneSales?: string[]; progress?: Record }; const done = new Set(cursor.doneSales ?? []); const progress: Record = cursor.progress ?? {}; const firmUrl = `${SAN}/Kelleher.cfm`; await this.throttle(); const firm = await ctx.fetch(firmUrl, { responseType: 'text', expect: ['title', 'date'], parse: (r) => ({ title: r.html ? parseFirmPage(r.html)[0]?.title ?? null : null, date: r.html ? parseFirmPage(r.html)[0]?.dateText ?? null : null }) }); if (!firm.success || !firm.html) { ctx.anomaly('page_fetch_failed', `${firmUrl}: ${firm.error ?? firm.httpStatus}`); return; } let discovered = parseFirmPage(firm.html); if (ctx.options.mode === 'backfill') { await this.throttle(); const archive = await ctx.fetch(`${SAN}/pr.cfm`, { responseType: 'text', minQuality: 0 }); if (archive.success && archive.html) for (const s of parseFirmPage(archive.html)) if (!discovered.some((d) => d.sale === s.sale)) discovered.push(s); } // Only sales whose published date is in the past can have prices realized. discovered = discovered.filter((s) => !s.dateText || (dateWords(s.dateText)?.getTime() ?? 0) <= Date.now()); const sales = discovered.filter((s) => !done.has(s.sale)); let count = 0; let processed = 0; for (const sale of sales) { if (processed >= salesPerRun || ctx.signal?.aborted) break; await this.throttle(); const top = await ctx.fetch(sale.url, { responseType: 'text', expect: ['title'], parse: (r) => ({ title: r.html && parseGroupLinks(r.html, 2).length ? sale.title : null }) }); if (!top.success || !top.html) { ctx.anomaly('page_fetch_failed', `${sale.url}: ${top.error ?? top.httpStatus}`); break; } const seen = new Set(progress[sale.sale] ?? []); const pages: string[] = []; for (const g of parseGroupLinks(top.html, 2)) { if (ctx.signal?.aborted) break; await this.throttle(); const grp = await ctx.fetch(g, { responseType: 'text', minQuality: 0 }); if (grp.success && grp.html) pages.push(...parseGroupLinks(grp.html, 3)); } const todo = pages.filter((p) => !seen.has(p)); let n = 0; for (const p of todo) { if (n >= pagesPerRun || ctx.signal?.aborted || this.reached(ctx, count)) break; await this.throttle(); const res = await ctx.fetch(p, { responseType: 'text', expect: ['title', 'price', 'date'], parse: (r) => { const f = r.html ? parseCategoryPage(r.html, sale, p).lots.find((l) => l.soldFor) : null; return f ? { title: f.headline ?? f.description, price: Number(f.soldFor!.replace(/,/g, '')), date: f.closedText } : null; }, }); n++; seen.add(p); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${p}: ${res.error ?? res.httpStatus}`); continue; } const payload = parseCategoryPage(res.html, sale, p); if (payload.lots.length === 0) continue; count++; yield { url: p, externalId: `sale:${sale.sale}:${payload.majorGroup}:${payload.category}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } progress[sale.sale] = [...seen]; if (todo.length - n <= 0) { done.add(sale.sale); delete progress[sale.sale]; processed++; } await ctx.setCursor({ doneSales: [...done].slice(-60), progress, updatedAt: new Date().toISOString() }); if (todo.length - n > 0) break; } } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const out: NormalizedSale[] = []; for (const lot of p.lots) { if (!lot.soldFor) continue; const amount = Number(lot.soldFor.replace(/,/g, '')); const saleDate = closedDate(lot.closedText); if (!Number.isFinite(amount) || amount <= 0 || !saleDate) continue; const currency = /US\$|USD/.test(lot.soldText ?? 'US$') ? 'USD' : null; if (!currency) continue; const title = lot.headline ? `${lot.headline} ${lot.description.replace(lot.headline, '').trim()}`.trim() : lot.description; const url = `${p.url}#lot-${lot.lotNumber}`; const attributes = lotAttributes({ categorySlug: kelleherCategory(p.majorGroup, title), name: lot.headline ?? lot.description.slice(0, 160), set: p.category || null, country: p.majorGroup || null, year: safeYear(lot.headline ?? lot.description), identifiers: { san_lot: `V-${p.sale.sale}-${lot.lotNumber}` }, metadata: { sale: p.sale.sale, sale_title: p.sale.title, major_group: p.majorGroup, category: p.category, cat_no: lot.catNo, price_is_hammer: true }, }); out.push( makeSale({ meta: this.meta, sourceUrl: url, externalId: `${p.sale.sale}-${lot.lotNumber}`, rawTitle: title.slice(0, 500), attributes, price: amount, currency: 'USD', saleDate, buyerPremiumIncluded: false, auctionHouse: 'Daniel F. Kelleher Auctions', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], description: lot.description, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, isBundle: /\b(collection|accumulation|balance|group|lot of|selection|remainder)\b/i.test(title), location: 'US', }), ); } return out; } } export default (meta: ConnectorMeta) => new KelleherConnector(meta);