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, money } from '../_carlib/index.js'; import { isBundleTitle, safeYear } from '../../api/_auction-lib/categories.js'; /** * Cherrystone Philatelic Auctioneers — prices realized per lot from the public catalog category pages. * Engine: Firecrawl (rawHtml). See meta.json accessNotes. */ const SITE = 'https://auctions.cherrystoneauctions.com'; const PARSER_VERSION = '1.0.0'; export const AuctionSchema = z.object({ id: z.string(), title: z.string(), dateText: z.string().nullable() }); export const LotSchema = z.object({ lotNumber: z.string(), title: z.string(), url: z.string(), image: z.string().nullable(), finalPriceText: z.string().nullable(), estimateText: z.string().nullable() }); export const PayloadSchema = z.object({ kind: z.literal('category_page'), auction: AuctionSchema, category: z.string(), categoryUrl: z.string(), lots: z.array(LotSchema) }); export type Payload = z.infer; function clean(s: string): string { return s.replace(/&/g, '&').replace(/�?39;|'/g, "'").replace(/"/g, '"').replace(/ /g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } /** Home page (HTML or markdown) → closed sales with prices realized. */ export function parseHome(text: string): z.infer[] { const out: z.infer[] = []; const push = (id: string, title: string, dateText: string) => { if (!out.some((a) => a.id === id)) out.push({ id, title: clean(title), dateText }); }; const DATE = String.raw`([A-Za-z]+ \d{1,2}(?:\s*[-–]\s*\d{1,2})?,\s*\d{4})`; // HTML: Click here for Prices Realized from - const reHtml = new RegExp(String.raw`href="[^"]*catalog\.aspx\?auctionid=(\d+)"[^>]*>[^<]*<\/a>\s*for Prices Realized from\s+(.+?)\s+-\s+` + DATE, 'gi'); let m: RegExpExecArray | null; while ((m = reHtml.exec(text))) push(m[1]!, m[2]!, m[3]!); // Markdown: [Click here](…catalog.aspx?auctionid=34) for Prices Realized from - const reMd = new RegExp(String.raw`\]\([^)]*catalog\.aspx\?auctionid=(\d+)\)\s*for Prices Realized from\s+(.+?)\s+-\s+` + DATE, 'gi'); while ((m = reMd.exec(text))) push(m[1]!, m[2]!, m[3]!); return out; } /** Catalog page → category page URLs for this auction. */ export function parseCategoryLinks(html: string, auctionId: string): Array<{ name: string; url: string }> { const out: Array<{ name: string; url: string }> = []; const re = /href="((?:https:\/\/auctions\.cherrystoneauctions\.com)?\/Category\/([^"?]+)\.html\?auctionid=(\d+))"/g; let m: RegExpExecArray | null; while ((m = re.exec(html))) { if (m[3] !== auctionId) continue; const url = m[1]!.startsWith('http') ? m[1]! : `${SITE}${m[1]!}`; if (out.some((c) => c.url === url)) continue; const name = m[2]!.replace(/-\d+$/, '').replace(/_/g, ' ').trim(); if (/^All$/i.test(name)) continue; out.push({ name, url }); } return out; } /** Category page → lots. */ export function parseCategoryPage(html: string, auction: z.infer, category: string, categoryUrl: string): Payload { const lots: z.infer[] = []; const blocks = html.split(/
/).slice(1); for (const b of blocks) { const num = b.match(/id="LotNumber">([^<]+)\s*([\s\S]*?)<\/a>/); if (!num || !link) continue; lots.push({ lotNumber: num, title: clean(link[2]!), url: link[1]!, image: b.match(/ { const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 1); const categoriesPerRun = Number(this.meta.config.categoriesPerRun ?? 25); const cursor = (ctx.options.cursor ?? {}) as { doneAuctions?: string[]; progress?: Record }; const done = new Set(cursor.doneAuctions ?? []); const progress: Record = cursor.progress ?? {}; await this.throttle(); const home = await ctx.fetch(`${SITE}/`, { expect: ['title', 'date'], parse: (r) => ({ title: parseHome(r.html ?? r.markdown ?? '')[0]?.title ?? null, date: parseHome(r.html ?? r.markdown ?? '')[0]?.dateText ?? null }) }); if (!home.success) { ctx.anomaly('page_fetch_failed', `home: ${home.error ?? home.httpStatus}`); return; } const auctions = parseHome(home.html ?? home.markdown ?? '').filter((a) => !done.has(a.id)); let count = 0; let processed = 0; for (const auction of auctions) { if (processed >= auctionsPerRun || ctx.signal?.aborted) break; await this.throttle(); const cat = await ctx.fetch(`${SITE}/catalog.aspx?auctionid=${auction.id}`, { expect: ['title'], parse: (r) => ({ title: r.html ? parseCategoryLinks(r.html, auction.id)[0]?.name ?? null : null }) }); if (!cat.success || !cat.html) { ctx.anomaly('page_fetch_failed', `catalog ${auction.id}: ${cat.error ?? cat.httpStatus}`); break; } const seen = new Set(progress[auction.id] ?? []); const categories = parseCategoryLinks(cat.html, auction.id).filter((c) => !seen.has(c.url)); let n = 0; for (const c of categories) { if (n >= categoriesPerRun || ctx.signal?.aborted || this.reached(ctx, count)) break; await this.throttle(); const res = await ctx.fetch(c.url, { expect: ['title', 'price'], parse: (r) => { const f = r.html ? parseCategoryPage(r.html, auction, c.name, c.url).lots.find((l) => l.finalPriceText) : null; return f ? { title: f.title, price: money(f.finalPriceText, 'USD')?.amount ?? null } : null; }, }); n++; seen.add(c.url); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${c.url}: ${res.error ?? res.httpStatus}`); continue; } const payload = parseCategoryPage(res.html, auction, c.name, c.url); if (payload.lots.length === 0) continue; count++; yield { url: c.url, externalId: `auction:${auction.id}:${c.name}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } progress[auction.id] = [...seen]; const remaining = categories.length - n; if (remaining <= 0) { done.add(auction.id); delete progress[auction.id]; processed++; } await ctx.setCursor({ doneAuctions: [...done].slice(-50), progress, updatedAt: new Date().toISOString() }); if (remaining > 0) break; // continue this auction next run } } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const saleDate = dateWords(p.auction.dateText); if (!saleDate) return []; const out: NormalizedSale[] = []; for (const lot of p.lots) { const m = money(lot.finalPriceText, 'USD'); if (!m) continue; // $0 / missing = unsold const categorySlug = cherrystoneCategory(lot.title); const attributes = lotAttributes({ categorySlug, name: lot.title, set: p.category, year: safeYear(lot.title), identifiers: { cherrystone_lot: lot.url.match(/LOT(\d+)\.aspx/i)?.[1] ?? `${p.auction.id}-${lot.lotNumber}` }, metadata: { auction_id: p.auction.id, auction_title: p.auction.title, category: p.category, estimate: lot.estimateText, scott_catalogue: lot.title.match(/\(([0-9A-Za-z-]+)\)/)?.[1] ?? null }, }); out.push( makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: `${p.auction.id}-${lot.lotNumber}`, rawTitle: lot.title, attributes, price: m.amount, currency: 'USD', saleDate, buyerPremiumIncluded: null, auctionHouse: 'Cherrystone Auctions', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, isBundle: isBundleTitle(lot.title) || /\b(collection|accumulation|balance|group of|lot of)\b/i.test(lot.title), location: 'US', }), ); } return out; } } export default (meta: ConnectorMeta) => new CherrystoneConnector(meta);