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, makeSale, md, money, splitMarkdownItems, vehicleAttributes } from '../_carlib/index.js'; const BASE = 'https://www.mecum.com'; const PARSER_VERSION = '1.0.0'; export const LotSchema = z.object({ lotId: z.string(), url: z.string(), title: z.string(), lotNumber: z.string().nullable(), sold: z.boolean(), priceText: z.string().nullable(), subtitle: z.string().nullable(), image: z.string().nullable() }); export const PagePayloadSchema = z.object({ kind: z.literal('lots_page'), auctionSlug: z.string(), auctionName: z.string().nullable(), dateText: z.string().nullable(), startDate: z.string().nullable().optional(), endDate: z.string().nullable().optional(), page: z.number(), lots: z.array(LotSchema) }); export type PagePayload = z.infer; /** Completed auction slugs from the /results/ page HTML (Next.js payload, escaped). Newest first as listed. */ export function parseResultsSlugs(html: string): string[] { const u = html.replace(/\\"/g, '"').replace(/\\\//g, '/'); const out: string[] = []; for (const m of u.matchAll(/\/auctions\/([a-z0-9-]+-(?:19|20)\d{2})\//g)) if (!out.includes(m[1]!)) out.push(m[1]!); return out; } /** Auction page HTML → { startDate, endDate } from the embedded schema.org Event (ISO strings) */ export function parseAuctionDates(html: string): { startDate: string | null; endDate: string | null } { const u = html.replace(/\\"/g, '"'); return { startDate: u.match(/"startDate":"([^"]+)"/)?.[1] ?? null, endDate: u.match(/"endDate":"([^"]+)"/)?.[1] ?? null }; } export function parseLotsPage(markdown: string, auctionSlug: string, page: number): PagePayload { const header = markdown.match(/(?:^|\n)# ([^\n]+)\n\n([^\n]+)\n/); const chunks = splitMarkdownItems(markdown, /^\[View /m); const lots: z.infer[] = []; for (const c of chunks) { const head = c.match(/^\[View ([^\]]+)\]\((https:\/\/www\.mecum\.com\/lots\/(\d+)\/[a-z0-9-]+\/?)[^)]*\)/); if (!head) continue; const lines = c.split('\n').map((l) => l.replace(/\\+$/, '').trim()).filter(Boolean); const priceText = lines.find((l) => /^\$[\d,]+$/.test(l)) ?? null; const sold = /!\[sold\]/i.test(c); const lotNumber = c.match(/\nLot ([A-Z]?\d+(?:\.\d+)?)\b/)?.[1] ?? null; const titleIdx = lines.findIndex((l) => l.startsWith(`[${head[1]}](`)); const subtitle = titleIdx >= 0 ? lines.slice(titleIdx + 1).find((l) => !l.startsWith('[') && !l.startsWith('!')) ?? null : null; lots.push({ lotId: head[3]!, url: head[2]!, title: md.clean(head[1]!), lotNumber, sold, priceText, subtitle: subtitle ? md.clean(subtitle) : null, image: c.match(/!\[[^\]]*\]\((https:\/\/images\.mecum\.com\/[^)\s]+)\)/)?.[1] ?? null }); } return { kind: 'lots_page', auctionSlug, auctionName: header?.[1]?.trim() ?? null, dateText: header?.[2]?.trim() ?? null, page, lots }; } const MEMORABILIA = /\b(sign|neon|gas pump|petroliana|pedal car|poster|helmet|memorabilia|collection of|display|clock|toy)\b/i; export class MecumConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; async *crawl(ctx: CrawlContext): AsyncIterable { const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 1); const lotPages = Number(this.meta.config.lotPagesPerAuction ?? 10); const progress = (ctx.options.cursor?.progress ?? {}) as Record; await this.throttle(); const list = await ctx.fetch(`${BASE}/results/`, { engines: ['api'], responseType: 'text', expect: ['title'], parse: (r) => (r.html ? { title: parseResultsSlugs(r.html)[0] ?? null } : null) }); if (!list.success || !list.html) { ctx.anomaly('page_fetch_failed', `${BASE}/results/: ${list.error ?? list.httpStatus}`); return; } const skipUntil = (ctx.options.cursor?.skipUntil ?? {}) as Record; const nowIso = new Date().toISOString(); const slugs = parseResultsSlugs(list.html).filter((s) => progress[s] !== 'done' && !(skipUntil[s] && skipUntil[s]! > nowIso)); let count = 0; let processed = 0; for (const slug of slugs) { if (processed >= auctionsPerRun || ctx.signal?.aborted) break; // Auction page (plain HTTPS, free) carries schema.org Event dates: skip sales not yet held. await this.throttle(); const ap = await ctx.fetch(`${BASE}/auctions/${slug}/`, { engines: ['api'], responseType: 'text', expect: ['date'], parse: (r) => (r.html ? { date: parseAuctionDates(r.html).startDate } : null), minQuality: 0 }); const dates = ap.html ? parseAuctionDates(ap.html) : { startDate: null, endDate: null }; if (dates.startDate && new Date(dates.startDate).getTime() > Date.now()) { skipUntil[slug] = dates.startDate; await ctx.setCursor({ progress, skipUntil, updatedAt: new Date().toISOString() }); ctx.log.info({ slug, start: dates.startDate }, 'mecum auction not yet held; skipping'); continue; } const startPage = typeof progress[slug] === 'number' ? (progress[slug] as number) : 1; let page = startPage; let finished = false; for (; page < startPage + lotPages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) break; const url = `${BASE}/auctions/${slug}/lots/?page=${page}`; await this.throttle(); const res = await ctx.fetch(url, { engines: ['firecrawl'], waitForMs: 9000, expect: ['title', 'price', 'status'], parse: (r) => { const p = r.markdown ? parseLotsPage(r.markdown, slug, page) : null; const f = p?.lots.find((l) => l.sold && l.priceText); return f ? { title: f.title, price: money(f.priceText, 'USD')?.amount ?? null, status: 'sold' } : p?.lots.length ? { title: p.lots[0]!.title } : null; }, minQuality: 0.3, }); if (!res.success || !res.markdown) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } const payload = { ...parseLotsPage(res.markdown, slug, page), startDate: dates.startDate, endDate: dates.endDate }; if (payload.lots.length === 0) { finished = true; break; } count++; yield { url, externalId: `auction:${slug}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; if (!res.markdown.includes(`/auctions/${slug}/lots/?page=${page + 1}`)) { finished = true; page++; break; } } progress[slug] = finished ? 'done' : page; processed++; await ctx.setCursor({ progress, skipUntil, updatedAt: new Date().toISOString() }); } } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const year = Number(p.auctionSlug.match(/(19|20)\d{2}$/)?.[0] ?? NaN); const saleDate = (p.startDate ? new Date(p.startDate) : null) ?? dateWords(p.dateText ?? '', Number.isFinite(year) ? year : null); if (!saleDate) return []; const out: NormalizedSale[] = []; for (const lot of p.lots) { if (!lot.sold || !lot.priceText) continue; const m = money(lot.priceText, 'USD'); if (!m) continue; const memorabilia = MEMORABILIA.test(lot.title) && !/^\d{4}\s/.test(lot.title); const attributes = vehicleAttributes(lot.title, { country: 'US', identifiers: { mecum_lot_id: lot.lotId }, metadata: { auction: p.auctionName ?? p.auctionSlug, auction_dates: p.dateText, auction_start: p.startDate ?? null, auction_end: p.endDate ?? null, highlights: lot.subtitle }, ...(memorabilia ? { categorySlug: 'automotive_memorabilia' as const } : {}), moto: /motorcycle/i.test(p.auctionSlug) }); out.push(makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: lot.lotId, rawTitle: lot.title, attributes, price: m.amount, currency: 'USD', saleDate, buyerPremiumIncluded: null, auctionHouse: 'Mecum Auctions', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], description: lot.subtitle, location: 'US', observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, confidence: 0.85 })); } return out; } } export default (meta: ConnectorMeta) => new MecumConnector(meta);